diff --git a/.github/workflows/docusaurus.yml b/.github/workflows/docusaurus.yml new file mode 100644 index 00000000..f46fa893 --- /dev/null +++ b/.github/workflows/docusaurus.yml @@ -0,0 +1,20 @@ +name: docusaurus + +on: + push: + branches: + - master + - develop + paths: + - docusaurus/** +jobs: + push_docusaurus: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - name: push + uses: GetStream/push-stream-chat-docusaurus-action@main + with: + target-branch: ${{ github.ref == 'refs/heads/master' && 'production' || 'staging' }} + env: + DOCUSAURUS_GH_TOKEN: ${{ secrets.DOCUSAURUS_GH_TOKEN }} \ No newline at end of file diff --git a/.github/workflows/scripts/install-flutter.sh b/.github/workflows/scripts/install-flutter.sh deleted file mode 100755 index 247d2797..00000000 --- a/.github/workflows/scripts/install-flutter.sh +++ /dev/null @@ -1,13 +0,0 @@ -#!/usr/bin/env bash - -BRANCH=$1 - -if [ "$BRANCH" == "dev" ] -then - # TODO Flutter dev branch is currently broken so we're unable to test MacOS. - echo "TODO: Skipping macOS testing due to Flutter dev branch issue. Switching branch to stable." - BRANCH=stable -fi - -git clone https://github.com/flutter/flutter.git --depth 1 -b $BRANCH _flutter -echo "::add-path::$GITHUB_WORKSPACE/_flutter/bin" \ No newline at end of file diff --git a/.github/workflows/scripts/install-tools.sh b/.github/workflows/scripts/install-tools.sh deleted file mode 100755 index 087cfecb..00000000 --- a/.github/workflows/scripts/install-tools.sh +++ /dev/null @@ -1,6 +0,0 @@ -#!/bin/bash - -flutter pub global activate melos -echo "::add-path::$HOME/.pub-cache/bin" -echo "::add-path::$GITHUB_WORKSPACE/_flutter/.pub-cache/bin" -echo "::add-path::$GITHUB_WORKSPACE/_flutter/bin/cache/dart-sdk/bin" \ No newline at end of file diff --git a/.github/workflows/scripts/coverage.sh b/.github/workflows/scripts/remove-from-coverage.sh similarity index 69% rename from .github/workflows/scripts/coverage.sh rename to .github/workflows/scripts/remove-from-coverage.sh index edff4ae4..c6850e89 100755 --- a/.github/workflows/scripts/coverage.sh +++ b/.github/workflows/scripts/remove-from-coverage.sh @@ -3,4 +3,4 @@ # Fast fail the script on failures. set -e -pub global run remove_from_coverage:remove_from_coverage -f coverage/lcov.info -r '\.g\.dart$' -r '\.freezed\.dart$' \ No newline at end of file +pub global run remove_from_coverage:remove_from_coverage -f coverage/lcov.info -r '\.g\.dart$' -r '\.freezed\.dart$' diff --git a/.github/workflows/stream_flutter_workflow.yml b/.github/workflows/stream_flutter_workflow.yml index aa621854..0baaaff7 100644 --- a/.github/workflows/stream_flutter_workflow.yml +++ b/.github/workflows/stream_flutter_workflow.yml @@ -2,6 +2,7 @@ name: stream_flutter_workflow env: ACTIONS_ALLOW_UNSECURE_COMMANDS: 'true' + flutter_version: "2.2.2" on: pull_request: @@ -9,97 +10,114 @@ on: branches: - master - develop - paths-ignore: - - 'docs/**' jobs: analyze: timeout-minutes: 15 runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 + - name: "Git Checkout" + uses: actions/checkout@v2 with: fetch-depth: 0 - - name: 'Install Flutter' - run: ./.github/workflows/scripts/install-flutter.sh stable - - name: 'Install Tools' + - name: Cache Flutter dependencies + uses: actions/cache@v2 + with: + path: /opt/hostedtoolcache/flutter + key: ${{ env.flutter_version }}-flutter + - name: "Install Flutter" + uses: subosito/flutter-action@v1 + with: + flutter-version: ${{ env.flutter_version }} + - name: "Install Tools" run: | - ./.github/workflows/scripts/install-tools.sh - flutter pub global activate tuneup - - name: 'Bootstrap Workspace' + flutter pub global activate melos + - name: "Bootstrap Workspace" run: melos bootstrap - - name: 'Dart Analyze' + - name: "Dart Analyze" run: | - melos exec -c 3 --ignore="*example*" -- \ - tuneup check - - name: 'Pub Check' + melos run analyze + - name: "Pub Check" if: github.ref == 'refs/heads/master' run: | - melos exec -c 1 --no-private --ignore="*example*" -- \ - pub publish --dry-run + melos run lint:pub + format: runs-on: ubuntu-latest timeout-minutes: 15 steps: - - uses: actions/checkout@v2 + - name: "Git Checkout" + uses: actions/checkout@v2 with: fetch-depth: 0 - - name: 'Install Flutter' - run: ./.github/workflows/scripts/install-flutter.sh stable - - name: 'Install Tools' - run: | - ./.github/workflows/scripts/install-tools.sh - - name: 'Bootstrap Workspace' + - name: Cache Flutter dependencies + uses: actions/cache@v2 + with: + path: /opt/hostedtoolcache/flutter + key: ${{ env.flutter_version }}-flutter + - name: "Install Flutter" + uses: subosito/flutter-action@v1 + with: + flutter-version: ${{ env.flutter_version }} + - name: "Install Tools" + run: flutter pub global activate melos + - name: "Bootstrap Workspace" run: melos bootstrap - - name: 'Dart' + - name: "Melos Format" + run: melos run format + - name: "Validate Formatting" run: | - melos exec -c 1 -- \ - flutter format . ./.github/workflows/scripts/validate-formatting.sh test: - runs-on: ubuntu-latest + runs-on: macos-latest timeout-minutes: 15 steps: - - uses: actions/checkout@v2 + - name: "Git Checkout" + uses: actions/checkout@v2 with: fetch-depth: 0 - - name: 'Install Flutter' - run: ./.github/workflows/scripts/install-flutter.sh stable - - name: 'Install Tools' + - name: Cache Flutter dependencies + uses: actions/cache@v2 + with: + path: /Users/runner/hostedtoolcache/flutter + key: ${{ env.flutter_version }}-flutter + - name: "Install Flutter" + uses: subosito/flutter-action@v1 + with: + flutter-version: ${{ env.flutter_version }} + - name: "Install Tools" run: | - ./.github/workflows/scripts/install-tools.sh - flutter pub global activate coverage - flutter pub global activate remove_from_coverage - - name: 'Bootstrap Workspace' + flutter pub global activate melos + pub global activate remove_from_coverage + - name: "Bootstrap Workspace" run: melos bootstrap - - name: 'Dart Test' - run: | - cd packages/stream_chat - flutter pub run test --coverage coverage/ - format_coverage --lcov --in=coverage/ --out=coverage/lcov.info --packages=.packages --report-on=lib - - name: 'Flutter Test' - run: | - melos exec -c 3 --flutter --dir-exists=test --ignore="*example*" --ignore="*web*" -- \ - flutter test --coverage - - name: CodeCov - run: | - melos exec -c 3 --fail-fast --dir-exists=test --ignore="*example*" --ignore="*web*" -- \ - "\$MELOS_ROOT_PATH/.github/workflows/scripts/coverage.sh" - bash <(curl -s https://codecov.io/bash) -t ${{ secrets.CODECOV_TOKEN }} - - uses: VeryGoodOpenSource/very_good_coverage@v1.1.1 + - name: "Flutter Test" + run: melos run test:all + - name: "Collect Coverage" + run: melos run coverage:ignore-file --no-select + - name: "Upload Coverage" + uses: codecov/codecov-action@v1 + with: + token: ${{secrets.CODECOV_TOKEN}} + files: packages/*/coverage/lcov.info + - name: "Stream Chat Coverage Check" + uses: VeryGoodOpenSource/very_good_coverage@v1.1.1 with: path: packages/stream_chat/coverage/lcov.info - min_coverage: 40 - - uses: VeryGoodOpenSource/very_good_coverage@v1.1.1 + min_coverage: 80 + - name: "Stream Chat Persistence Coverage Check" + uses: VeryGoodOpenSource/very_good_coverage@v1.1.1 with: path: packages/stream_chat_persistence/coverage/lcov.info min_coverage: 95 - - uses: VeryGoodOpenSource/very_good_coverage@v1.1.1 + - name: "Stream Chat Flutter Core Coverage Check" + uses: VeryGoodOpenSource/very_good_coverage@v1.1.1 with: path: packages/stream_chat_flutter_core/coverage/lcov.info min_coverage: 90 - - uses: VeryGoodOpenSource/very_good_coverage@v1.1.1 + - name: "Stream Chat Flutter Coverage Check" + uses: VeryGoodOpenSource/very_good_coverage@v1.1.1 with: path: packages/stream_chat_flutter/coverage/lcov.info - min_coverage: 16 + min_coverage: 67 \ No newline at end of file diff --git a/packages/stream_chat_flutter_core/analysis_options.yaml b/analysis_options.yaml similarity index 90% rename from packages/stream_chat_flutter_core/analysis_options.yaml rename to analysis_options.yaml index 545d5492..71604a87 100644 --- a/packages/stream_chat_flutter_core/analysis_options.yaml +++ b/analysis_options.yaml @@ -1,11 +1,14 @@ analyzer: exclude: - - lib/**/*.g.dart - - lib/**/*.freezed.dart - - example/* - - test/* + - packages/*/lib/**/*.g.dart + - packages/*/lib/src/emoji + - packages/*/lib/**/*.freezed.dart + linter: rules: + # these rules are documented on and in the same order as + # the Dart Lint rules page to make maintenance easier + # https://github.com/dart-lang/linter/blob/master/example/all.yaml - always_use_package_imports - avoid_empty_else - avoid_relative_lib_imports @@ -42,7 +45,6 @@ linter: - avoid_private_typedef_functions - avoid_redundant_argument_values - avoid_return_types_on_setters - - avoid_returning_null - avoid_returning_null_for_void - avoid_shadowing_type_parameters - avoid_single_cascade_in_expression_statements @@ -138,8 +140,7 @@ linter: - package_names - sort_pub_dependencies - # To be added when null-safe: - # - cast_nullable_to_non_nullable - #- unnecessary_null_checks - # - tighten_type_of_initializing_formals - # - null_check_on_nullable_type_parameter \ No newline at end of file + - cast_nullable_to_non_nullable + - unnecessary_null_checks + - tighten_type_of_initializing_formals + - null_check_on_nullable_type_parameter \ No newline at end of file diff --git a/docusaurus/docs/Flutter/assets/channel_header.png b/docusaurus/docs/Flutter/assets/channel_header.png new file mode 100644 index 00000000..98f41c61 Binary files /dev/null and b/docusaurus/docs/Flutter/assets/channel_header.png differ diff --git a/docusaurus/docs/Flutter/assets/channel_header_custom_title.png b/docusaurus/docs/Flutter/assets/channel_header_custom_title.png new file mode 100644 index 00000000..65a9ee75 Binary files /dev/null and b/docusaurus/docs/Flutter/assets/channel_header_custom_title.png differ diff --git a/docusaurus/docs/Flutter/assets/channel_list_header.png b/docusaurus/docs/Flutter/assets/channel_list_header.png new file mode 100644 index 00000000..6f112db9 Binary files /dev/null and b/docusaurus/docs/Flutter/assets/channel_list_header.png differ diff --git a/docusaurus/docs/Flutter/assets/channel_list_header_custom_subtitle.png b/docusaurus/docs/Flutter/assets/channel_list_header_custom_subtitle.png new file mode 100644 index 00000000..3c779857 Binary files /dev/null and b/docusaurus/docs/Flutter/assets/channel_list_header_custom_subtitle.png differ diff --git a/docusaurus/docs/Flutter/assets/channel_list_view.png b/docusaurus/docs/Flutter/assets/channel_list_view.png new file mode 100644 index 00000000..a4390c2e Binary files /dev/null and b/docusaurus/docs/Flutter/assets/channel_list_view.png differ diff --git a/docusaurus/docs/Flutter/assets/channel_preview.png b/docusaurus/docs/Flutter/assets/channel_preview.png new file mode 100644 index 00000000..39f5629b Binary files /dev/null and b/docusaurus/docs/Flutter/assets/channel_preview.png differ diff --git a/docusaurus/docs/Flutter/assets/chat_basics.png b/docusaurus/docs/Flutter/assets/chat_basics.png new file mode 100644 index 00000000..4eb0aa67 Binary files /dev/null and b/docusaurus/docs/Flutter/assets/chat_basics.png differ diff --git a/docusaurus/docs/Flutter/assets/dashboard_firebase_enable.jpeg b/docusaurus/docs/Flutter/assets/dashboard_firebase_enable.jpeg new file mode 100644 index 00000000..e4696ec8 Binary files /dev/null and b/docusaurus/docs/Flutter/assets/dashboard_firebase_enable.jpeg differ diff --git a/docusaurus/docs/Flutter/assets/dashboard_firebase_key.jpeg b/docusaurus/docs/Flutter/assets/dashboard_firebase_key.jpeg new file mode 100644 index 00000000..87243e91 Binary files /dev/null and b/docusaurus/docs/Flutter/assets/dashboard_firebase_key.jpeg differ diff --git a/docusaurus/docs/Flutter/assets/dashboard_save_changes.jpeg b/docusaurus/docs/Flutter/assets/dashboard_save_changes.jpeg new file mode 100644 index 00000000..1c58a93e Binary files /dev/null and b/docusaurus/docs/Flutter/assets/dashboard_save_changes.jpeg differ diff --git a/docusaurus/docs/Flutter/assets/firebase_project_settings.jpeg b/docusaurus/docs/Flutter/assets/firebase_project_settings.jpeg new file mode 100644 index 00000000..4fbc6521 Binary files /dev/null and b/docusaurus/docs/Flutter/assets/firebase_project_settings.jpeg differ diff --git a/docusaurus/docs/Flutter/assets/location_sharing_example.jpg b/docusaurus/docs/Flutter/assets/location_sharing_example.jpg new file mode 100644 index 00000000..7f220379 Binary files /dev/null and b/docusaurus/docs/Flutter/assets/location_sharing_example.jpg differ diff --git a/docusaurus/docs/Flutter/assets/location_sharing_example_message.jpg b/docusaurus/docs/Flutter/assets/location_sharing_example_message.jpg new file mode 100644 index 00000000..707f1388 Binary files /dev/null and b/docusaurus/docs/Flutter/assets/location_sharing_example_message.jpg differ diff --git a/docusaurus/docs/Flutter/assets/location_sharing_example_message_thumbnail.jpg b/docusaurus/docs/Flutter/assets/location_sharing_example_message_thumbnail.jpg new file mode 100644 index 00000000..e2b7d624 Binary files /dev/null and b/docusaurus/docs/Flutter/assets/location_sharing_example_message_thumbnail.jpg differ diff --git a/docusaurus/docs/Flutter/assets/message_input.png b/docusaurus/docs/Flutter/assets/message_input.png new file mode 100644 index 00000000..d63cfbb3 Binary files /dev/null and b/docusaurus/docs/Flutter/assets/message_input.png differ diff --git a/docusaurus/docs/Flutter/assets/message_input_change_position.png b/docusaurus/docs/Flutter/assets/message_input_change_position.png new file mode 100644 index 00000000..859107d2 Binary files /dev/null and b/docusaurus/docs/Flutter/assets/message_input_change_position.png differ diff --git a/docusaurus/docs/Flutter/assets/message_input_quoted_message.png b/docusaurus/docs/Flutter/assets/message_input_quoted_message.png new file mode 100644 index 00000000..c1fda223 Binary files /dev/null and b/docusaurus/docs/Flutter/assets/message_input_quoted_message.png differ diff --git a/docusaurus/docs/Flutter/assets/message_list_view.png b/docusaurus/docs/Flutter/assets/message_list_view.png new file mode 100644 index 00000000..4f9be344 Binary files /dev/null and b/docusaurus/docs/Flutter/assets/message_list_view.png differ diff --git a/docusaurus/docs/Flutter/assets/message_list_view_pin.png b/docusaurus/docs/Flutter/assets/message_list_view_pin.png new file mode 100644 index 00000000..0b6f1c39 Binary files /dev/null and b/docusaurus/docs/Flutter/assets/message_list_view_pin.png differ diff --git a/docusaurus/docs/Flutter/assets/message_list_view_threads.png b/docusaurus/docs/Flutter/assets/message_list_view_threads.png new file mode 100644 index 00000000..2c938766 Binary files /dev/null and b/docusaurus/docs/Flutter/assets/message_list_view_threads.png differ diff --git a/docusaurus/docs/Flutter/assets/message_search_list_view.png b/docusaurus/docs/Flutter/assets/message_search_list_view.png new file mode 100644 index 00000000..b13f5bdc Binary files /dev/null and b/docusaurus/docs/Flutter/assets/message_search_list_view.png differ diff --git a/docusaurus/docs/Flutter/assets/sdk_title.png b/docusaurus/docs/Flutter/assets/sdk_title.png new file mode 100644 index 00000000..4d92e891 Binary files /dev/null and b/docusaurus/docs/Flutter/assets/sdk_title.png differ diff --git a/docusaurus/docs/Flutter/assets/server_key.png b/docusaurus/docs/Flutter/assets/server_key.png new file mode 100644 index 00000000..f11d6fb5 Binary files /dev/null and b/docusaurus/docs/Flutter/assets/server_key.png differ diff --git a/docusaurus/docs/Flutter/assets/swipe_channel.png b/docusaurus/docs/Flutter/assets/swipe_channel.png new file mode 100644 index 00000000..44f13ca5 Binary files /dev/null and b/docusaurus/docs/Flutter/assets/swipe_channel.png differ diff --git a/docusaurus/docs/Flutter/assets/user_list_view.png b/docusaurus/docs/Flutter/assets/user_list_view.png new file mode 100644 index 00000000..4beffacc Binary files /dev/null and b/docusaurus/docs/Flutter/assets/user_list_view.png differ diff --git a/docusaurus/docs/Flutter/assets/using_theme.jpg b/docusaurus/docs/Flutter/assets/using_theme.jpg new file mode 100644 index 00000000..b835d931 Binary files /dev/null and b/docusaurus/docs/Flutter/assets/using_theme.jpg differ diff --git a/docusaurus/docs/Flutter/basics/_category_.json b/docusaurus/docs/Flutter/basics/_category_.json new file mode 100644 index 00000000..76d33569 --- /dev/null +++ b/docusaurus/docs/Flutter/basics/_category_.json @@ -0,0 +1,4 @@ +{ + "label": "Introduction", + "position": 1 +} \ No newline at end of file diff --git a/docusaurus/docs/Flutter/basics/choose_package.mdx b/docusaurus/docs/Flutter/basics/choose_package.mdx new file mode 100644 index 00000000..8be73f01 --- /dev/null +++ b/docusaurus/docs/Flutter/basics/choose_package.mdx @@ -0,0 +1,55 @@ +--- +id: choose_package +sidebar_position: 2 +title: Choosing The Right Flutter Package +--- + +### Why the SDK is split into different packages + +Different applications need different levels of customization and integration with the Stream Chat SDK. +To do this, the Flutter SDK is split into three different packages which build upon the last and give +varying levels of control to the developer. The higher level packages offer better compatibility out of the +box while the lower level SDKs offer fine grained control. There is also a separate package for persistence +which allows you persist data locally which works with all packages. + +### How do I choose? + +#### The case for stream_chat_flutter + +For the quickest way to integrate Stream Chat with your app, the UI SDK (`stream_chat_flutter`) is the +way to go. `stream_chat_flutter` contains prebuilt components that manage most operations like data +fetching, pagination, sending a message, and more. This ensures you have a nearly out-of-the-box +experience adding chat to your applications. It is also possible to use this in conjunction with +lower level operations of the SDK to get the best of both worlds. + +:::note +The package allows customization of components to a large extent making it easy to tweak the theme +to match your app colors and such. If you require any additional feature or customization, feel free +to request this through our support channels. +::: + +Summary: + +For the quickest and easiest way to add Chat to your app with prebuilt UI components, use stream_chat_flutter + + +#### The case for stream_chat_flutter_core + +If your application involves UI that does not fit in with the stream_chat_flutter components, stream_chat_flutter_core +strips away the UI associated with the components and provides the data fetching and manipulation +capabilities while supplying builders for UI. This allows you to implement your own UI and themes +completely independently while not worrying about writing functions for data and pagination. + +Summary: + +For implementing your own custom UI while not having to worry about lower level API calls, use stream_chat_flutter_core. + +#### The case for stream_chat + +The stream_chat package is the Low-level Client (LLC) of Stream Chat in Flutter. This package wraps +the underlying functionality of Stream Chat and allows the most customization in terms of UI, data, +and architecture. + +Summary: + +For the most control over the SDK and dealing with low level calls to the API, use stream_chat. diff --git a/docusaurus/docs/Flutter/basics/introduction.mdx b/docusaurus/docs/Flutter/basics/introduction.mdx new file mode 100644 index 00000000..4c45855c --- /dev/null +++ b/docusaurus/docs/Flutter/basics/introduction.mdx @@ -0,0 +1,71 @@ +--- +slug: / +id: introduction +sidebar_position: 1 +title: About The Flutter SDK +--- +Exploring The Basics Of Stream Chat + +![](../assets/sdk_title.png) + +Stream Chat is a service that helps you easily build a full chat experience in your Flutter (and more) apps. + +This section of the documentation focuses on our Flutter SDK which helps you easily +ship high quality messaging experiences in apps and programs built with the [Flutter toolkit made +by Google](https://flutter.dev). + +The Stream Chat Flutter SDK comprises of four different packages to choose from ranging from ones +giving you complete control to ones that give you a rich out-of-the-box chat experience. + +The packages that make up the Stream Chat SDK are: + +1. Low Level Client (stream_chat): a pure Dart package that can be used on any Dart project. +It provides a low-level client to access the Stream Chat service. +2. Core (stream_chat_flutter_core): provides business logic to fetch common things required +for integrating Stream Chat into your application. +The core package allows more customisation and hence provides business logic but no UI components. +3. UI (stream_chat_flutter): this library includes both a low-level chat SDK and a set of +reusable and customisable UI components. +4. Persistence (stream_chat_persistence): provides a persistence client for fetching and +saving chat data locally. + +We recommend building prototypes using the full UI package since it contains UI widgets already +integrated with Stream's API. [stream_chat_flutter](https://pub.dev/packages/stream_chat_flutter) +is the fastest way to get up and running using Stream chat in your app. + +The Flutter SDK enables you to build any type of chat or messaging experience for Android, iOS, Web +and Desktop. + +If you're building a very custom UI and would prefer a more lean package, +our [core package](https://pub.dev/packages/stream_chat_flutter) will be suited to this use case. Core allows you to build custom, +expressive UIs while retaining the benefits of our full Flutter SDK. +APIs for accessing and controlling users, sending messages, etc are seamlessly integrated into +this package and accessible via providers and builders. + +Before going into the docs, let's take a small detour to look at how the elements of Stream Chat are structured. + +There are two core elements in chat, Users and Channels. +Channels are groups of one or more users that can message each other. +In an app, you need to have a user connected to query channels. + +There is no specific distinction between a chat between two people and a group chat, +but there is a way to create a unique chat between a certain number of people by creating a distinct channel. + +![](../assets/chat_basics.png) + +In essence, a normal two-person chat would be a distinct channel created with two members (you cannot add or delete members in this channel), whereas a group created with two people would simply be a non distinct channel (possible to add or remove members). + +Note: It is also possible to add more than two people in a distinct channel which retains the same add/removal properties and resembles the Slack DMs where you can DM one or more people as well. + +In summary, if you were creating a Whatsapp-like app, the first screen would be a list of channels - which on opening would show a list of messages that were sent by the users in the Channel. + +While this is a simplistic overview of the service, the Flutter SDK handles the UI and more time consuming things (media upload, offline storage, theming, etc.) for you. + +Before reading the docs, consider trying our [online API tour](https://getstream.io/chat/get_started/), +it is a nice way to learn how the API works. +It's in-browser so Javascript-based but the ideas are pretty much the same as Dart. + +You may also like to look at the [Flutter tutorial](https://getstream.io/chat/flutter/tutorial/) +which focuses on using the UI package to get Stream Chat integrated into a Flutter app. + +Further sections break down each individual packages and explain several common operations. \ No newline at end of file diff --git a/docusaurus/docs/Flutter/guides/_category_.json b/docusaurus/docs/Flutter/guides/_category_.json new file mode 100644 index 00000000..cb58ac0d --- /dev/null +++ b/docusaurus/docs/Flutter/guides/_category_.json @@ -0,0 +1,4 @@ +{ + "label": "Guides", + "position": 2 +} \ No newline at end of file diff --git a/docusaurus/docs/Flutter/guides/adding_custom_attachments.mdx b/docusaurus/docs/Flutter/guides/adding_custom_attachments.mdx new file mode 100644 index 00000000..0c427713 --- /dev/null +++ b/docusaurus/docs/Flutter/guides/adding_custom_attachments.mdx @@ -0,0 +1,260 @@ +--- +id: adding_custom_attachments +sidebar_position: 4 +title: Adding Custom Attachments +--- + +Adding Your Own Types Of Attachments To A Message + +### Introduction + +Stream Chat supports attachment types like images, video and files by default. You can also add your +own types of attachments through the SDK such as location, audio, etc. + +This involves doing three things: + +1) Rendering the attachment thumbnail in the `MessageInput` + +2) Sending a message with the custom attachment + +3) Rendering the custom message attachment + +To do this, let's check out an example to add location sharing to Stream Chat. + +### Location Sharing + +Let's build an example of location sharing option in the app: + +![](../assets/location_sharing_example.jpg) + +* Show a "Share Location" button next to MessageInput Textfield. + +* When the user presses this button, it should fetch the current location coordinates of the user, and send a message on the channel as follows: + +```dart +Message( + text: 'This is my location', + attachments: [ + Attachment( + uploadState: UploadState.success(), + type: 'location', + extraData: { + 'latitude': 'fetched_latitude', + 'longitude': 'fetched_longitude', + }, + ), + ], +) +``` + +For our example, we are going to use [geolocator](https://pub.dev/packages/geolocator) library. +Please check their [setup instructions](https://pub.dev/packages/geolocator) on their docs. + +NOTE: If you are testing on iOS simulator, you will need to set some dummy coordinates, as mentioned [here](https://stackoverflow.com/a/31238119/7489541). +Also don't forget to enable "location update" capability in background mode, from XCode. + +On the receiver end, `location` type attachment should be rendered in map view, in the `MessageListView`. +We are going to use [Google Static Maps API](https://developers.google.com/maps/documentation/maps-static/overview) to render the map in the message. +You can use other libraries as well such as [google_maps_flutter](https://pub.dev/packages/google_maps_flutter). + +First, we add a button which when clicked fetches and shares location into the `MessageInput`: + +```dart +MessageInput( + actions: [ + InkWell( + child: Icon( + Icons.location_on, + size: 20.0, + color: StreamChatTheme.of(context).colorTheme.grey, + ), + onTap: () { + var channel = StreamChannel.of(context).channel; + var user = StreamChat.of(context).user; + + _determinePosition().then((value) { + channel.sendMessage( + Message( + text: 'This is my location', + attachments: [ + Attachment( + uploadState: UploadState.success(), + type: 'location', + extraData: { + 'latitude': value.latitude.toString(), + 'longitude': value.longitude.toString(), + }, + ), + ], + ), + ); + }).catchError((err) { + print('Error getting location!'); + }); + }, + ), + ], +), + +Future _determinePosition() async { + bool serviceEnabled; + LocationPermission permission; + + serviceEnabled = await Geolocator.isLocationServiceEnabled(); + if (!serviceEnabled) { + return Future.error('Location services are disabled.'); + } + + permission = await Geolocator.checkPermission(); + if (permission == LocationPermission.denied) { + permission = await Geolocator.requestPermission(); + if (permission == LocationPermission.deniedForever) { + return Future.error( + 'Location permissions are permanently denied, we cannot request permissions.'); + } + + if (permission == LocationPermission.denied) { + return Future.error( + 'Location permissions are denied'); + } + } + + return await Geolocator.getCurrentPosition(); +} +``` + +Next, we build the Static Maps URL (Add your API key before using the code snippet): + +```dart + String _buildMapAttachment(String lat, String long) { + var baseURL = 'https://maps.googleapis.com/maps/api/staticmap?'; + var url = Uri( + scheme: 'https', + host: 'maps.googleapis.com', + port: 443, + path: '/maps/api/staticmap', + queryParameters: { + 'center': '${lat},${long}', + 'zoom': '15', + 'size': '600x300', + 'maptype': 'roadmap', + 'key': 'YOUR_API_KEY', + 'markers': 'color:red|${lat},${long}' + }); + + return url.toString(); + } +``` + +And then modify the MessageListView and tell it how to build a location attachment: + +```dart +MessageListView( + customAttachmentBuilders: { + 'location': (context, message, attachments) { + var attachmentWidget = Image.network( + _buildMapAttachment( + attachments[0].extraData['latitude'], + attachments[0].extraData['longitude'], + ), + ); + + return wrapAttachmentWidget(context, attachmentWidget, null, true, BorderRadius.circular(8.0)); + } + }, +), +``` + +This gives us the final location attachment: + +![](../assets/location_sharing_example_message.jpg) + +Additionally, you can also add a thumbnail if a message has a location attachment (unlike in this case, where we sent the message directly). + +To do this, we will: + +1) Add an attachment instead of sending a message + +2) Customize the `MessageInput` + +First, we add the attachment when the location button is clicked: + +```dart + GlobalKey _messageInputKey = GlobalKey(); + + MessageInput( + key: _messageInputKey, + actions: [ + InkWell( + child: Icon( + Icons.location_on, + size: 20.0, + color: StreamChatTheme.of(context).colorTheme.grey, + ), + onTap: () { + _determinePosition().then((value) { + _messageInputKey.currentState.addAttachment( + Attachment( + uploadState: UploadState.success(), + type: 'location', + extraData: { + 'latitude': value.latitude.toString(), + 'longitude': value.longitude.toString(), + }, + ), + ); + }).catchError((err) { + print('Error getting location!'); + }); + }, + ), + ], + ), +``` + +After this, we can build the thumbnail: + +```dart +MessageInput( + key: _messageInputKey, + actions: [ + InkWell( + child: Icon( + Icons.location_on, + size: 20.0, + color: StreamChatTheme.of(context).colorTheme.grey, + ), + onTap: () { + _determinePosition().then((value) { + _messageInputKey.currentState.addAttachment( + Attachment( + uploadState: UploadState.success(), + type: 'location', + extraData: { + 'latitude': value.latitude.toString(), + 'longitude': value.longitude.toString(), + }, + ), + ); + }).catchError((err) { + print('Error getting location!'); + }); + }, + ), + ], + attachmentThumbnailBuilders: { + 'location': (context, attachment) { + return Image.network( + _buildMapAttachment( + attachment.extraData['latitude'], + attachment.extraData['longitude'], + ), + ); + }, + }, +), +``` + +And we can see the thumbnails in the MessageInput: + +![](../assets/location_sharing_example_message_thumbnail.jpg) diff --git a/docusaurus/docs/Flutter/guides/adding_local_data_persistence.mdx b/docusaurus/docs/Flutter/guides/adding_local_data_persistence.mdx new file mode 100644 index 00000000..21ec99ce --- /dev/null +++ b/docusaurus/docs/Flutter/guides/adding_local_data_persistence.mdx @@ -0,0 +1,6 @@ +--- +id: local_data_persistence +sidebar_position: 2 +title: Adding Local Data Persistence +--- + diff --git a/docusaurus/docs/Flutter/guides/adding_push_notifcations.mdx b/docusaurus/docs/Flutter/guides/adding_push_notifcations.mdx new file mode 100644 index 00000000..529f03cb --- /dev/null +++ b/docusaurus/docs/Flutter/guides/adding_push_notifcations.mdx @@ -0,0 +1,234 @@ +--- +id: adding_push_notifications +sidebar_position: 3 +title: Adding Push Notifications +--- + +Adding Push Notifications To Your Application + +### Introduction + +Push notifications are a core part of the experience for a messaging app. Users often need to be notified +of new messages and old notifications sometimes need to be updated silently as well. + +This guide details how to add push notifications to your app. + +Make sure to check [this section](https://getstream.io/chat/docs/flutter-dart/push_introduction/?language=dart) of the docs to read about the push delivery logic. + +### Setup FCM + +To integrate push notifications in your Flutter app you need to use the package [firebase_messaging](https://pub.dev/packages/firebase_messaging). + + +Follow the [Firebase documentation](https://firebase.flutter.dev/docs/messaging/overview/) to know how to set up the plugin for both Android and iOS. + + +Once that's done FCM should be able to send push notifications to your devices. + +### Integration with Stream + +#### Step 1 + +From the [Firebase Console](https://console.firebase.google.com/), select the project your app belongs to. + +#### Step 2 + +Click on the gear icon next to `Project Overview` and navigate to **Project settings** + +![](../assets/firebase_project_settings.jpeg) + +#### Step 3 + +Navigate to the `Cloud Messaging` tab + +#### Step 4 + +Under `Project Credentials`, locate the `Server key` and copy it + +![](../assets/server_key.png) + +#### Step 5 + +Upload the `Server Key` in your chat dashboard + +![](../assets/dashboard_firebase_enable.jpeg) + +![](../assets/dashboard_firebase_key.jpeg) + + +:::note +We are setting up the Android section, but this will work for both Android and iOS if you're using Firebase for both of them! +::: + +#### Step 6 + +Save your push notification settings changes + +![](../assets/dashboard_save_changes.jpeg) + +**OR** + +Upload the `Server Key` via API call using a backend SDK + +```js +await client.updateAppSettings({ + firebase_config: { + server_key: 'server_key', + notification_template: `{"message":{"notification":{"title":"New messages","body":"You have {{ unread_count }} new message(s) from {{ sender.name }}"},"android":{"ttl":"86400s","notification":{"click_action":"OPEN_ACTIVITY_1"}}}}`, + data_template: `{"sender":"{{ sender.id }}","channel":{"type": "{{ channel.type }}","id":"{{ channel.id }}"},"message":"{{ message.id }}"}` + }, +}); +``` + +### Registering a device at Stream Backend + +Once you configure Firebase server key and set it up on Stream dashboard a device that is supposed to receive push notifications needs to be registered at Stream backend. This is usually done by listening for Firebase device token updates and passing them to the backend as follows: + +```dart +firebaseMessaging.onTokenRefresh.listen((token) { + client.addDevice(token, PushProvider.firebase); +}); +``` + +### Possible issues + + +We only send push notifications when the user doesn't have any active websocket connection (which is established when you call `client.connectUser`). If you set the [onBackgroundEventReceived](https://pub.dev/documentation/stream_chat_flutter/latest/stream_chat_flutter/StreamChat/onBackgroundEventReceived.html) property of the StreamChat widget, when your app goes to background, your device will keep the ws connection alive for 1 minute, and so within this period, you won't receive any push notification. + +Make sure to read the [general push docs](https://getstream.io/chat/docs/flutter-dart/push_introduction/?language=dart) in order to avoid known gotchas that may make your relationship with notifications go bad 😢 + +### Testing if Push Notifications are Setup Correctly + +If you're not sure if you've set up push notifications correctly (e.g. you don't always receive them, they work unreliably), you can follow these steps to make sure your config is correct and working: + +1. Clone our repo for push testing git clone git@github.com:GetStream/chat-push-test.git + +2. `cd flutter` + +3. In folder run `flutter pub get` + +4. Input your api key and secret in `lib/main.dart` + +5. Change the bundle identifier/application ID and development team/user so you can run the app in your device (**do not** run on iOS simulator, Android emulator is fine) + +6. Add your `google-services.json/GoogleService-Info.plist` + +7. Run the app + +8. Accept push notification permission (iOS only) + +9. Tap on `Device ID` and copy it + +10. Send the app to background + +11. After configuring [stream-cli](https://github.com/GetStream/stream-cli) paste the following command on command line using your user ID + +```shell +stream chat:push:test -u +``` + +You should get a test push notification + +### App in the background but still connected + +The [StreamChat](https://pub.dev/documentation/stream_chat_flutter/latest/stream_chat_flutter/StreamChat-class.html) widget lets you define a [onBackgroundEventReceived](https://pub.dev/documentation/stream_chat_flutter/latest/stream_chat_flutter/StreamChat/onBackgroundEventReceived.html) handler in order to handle events while the app is in the background, but the client is still connected. + +This is useful because it lets you keep the connection alive in cases in which the app goes in the background just for some seconds (eg: multitasking, picking pictures from the gallery...) + +You can even customize the [backgroundKeepAlive](https://pub.dev/documentation/stream_chat_flutter/latest/stream_chat_flutter/StreamChat/backgroundKeepAlive.html) duration. + +In order to show notifications in such a case we suggest using the package [flutter_local_notifications](https://pub.dev/packages/flutter_local_notifications); follow the package guide to successfully set up the plugin. + +Once that's done you should set the [onBackgroundEventReceived](https://pub.dev/documentation/stream_chat_flutter/latest/stream_chat_flutter/StreamChat/onBackgroundEventReceived.html); here is an example: + +```dart +... +StreamChat( + client: client, + onBackgroundEventReceived: (e) { + final currentUserId = client.state.user.id; + if (![ + EventType.messageNew, + EventType.notificationMessageNew, + ].contains(event.type) || + event.user.id == currentUserId) { + return; + } + if (event.message == null) return; + final flutterLocalNotificationsPlugin = FlutterLocalNotificationsPlugin(); + final initializationSettingsAndroid = + AndroidInitializationSettings('launch_background'); + final initializationSettingsIOS = IOSInitializationSettings(); + final initializationSettings = InitializationSettings( + android: initializationSettingsAndroid, + iOS: initializationSettingsIOS, + ); + await flutterLocalNotificationsPlugin.initialize(initializationSettings); + await flutterLocalNotificationsPlugin.show( + event.message.id.hashCode, + event.message.user.name, + event.message.text, + NotificationDetails( + android: AndroidNotificationDetails( + 'message channel', + 'Message channel', + 'Channel used for showing messages', + priority: Priority.high, + importance: Importance.high, + ), + iOS: IOSNotificationDetails(), + ), + ); + }, + child: .... +); +... +``` + +As you can see we generate a local notification whenever a message.new or notification.message_new event is received. + +:::note +Using `flutter_local_notifications` is a great way to implement notifications while the is in foreground too! You can generate a local notification listening to events using the method `streamChatClient.on()` and react to the events you want. +::: + +### Saving notification messages to the offline storage + +You may want to save received messages when you receive them via a notification so that later on when you open the app they're already there. + +To do this we need to update the push notification data payload at Stream Dashboard and clear the notification one: + +```json +{ + "message_id": "{{ message.id }}", + "channel_id": "{{ channel.id }}", + "channel_type": "{{ channel.type }}" +} +``` + +Then we need to integrate the package [stream_chat_persistence](https://pub.dev/packages/stream_chat_persistence) in our app that exports a persistence client, learn [here](https://pub.dev/packages/stream_chat_persistence#usage) how to set it up. + +Then during the call `firebaseMessaging.configure(...)` we need to set the `onBackgroundMessage` parameter using a TOP-LEVEL or STATIC function to handle background messages; here is an example: + +```dart +Future myBackgroundMessageHandler(message) async { + if (message.containsKey('data')) { + final data = message['data']; + final messageId = data['message_id']; + final channelId = data['channel_id']; + final channelType = data['channel_type']; + final cid = '$channelType:$channelId'; + + final client = StreamChatClient(apiKey); + final persistenceClient = StreamChatPersistenceClient(); + await persistenceClient.connect(userId); + + final message = await client.getMessage(messageId).then((res) => res.message); + + await persistenceClient.updateMessages(cid, [message]); + persistenceClient.disconnect(); + + /// This can be done using the package flutter_local_notifications as we did before 👆 + _showLocalNotification(); + } +} +``` diff --git a/docusaurus/docs/Flutter/guides/introduction.mdx b/docusaurus/docs/Flutter/guides/introduction.mdx new file mode 100644 index 00000000..fc8f385e --- /dev/null +++ b/docusaurus/docs/Flutter/guides/introduction.mdx @@ -0,0 +1,5 @@ +--- +id: introduction +sidebar_position: 1 +title: Introduction +--- \ No newline at end of file diff --git a/docusaurus/docs/Flutter/guides/migration_guide_2_0.mdx b/docusaurus/docs/Flutter/guides/migration_guide_2_0.mdx new file mode 100644 index 00000000..394137bd --- /dev/null +++ b/docusaurus/docs/Flutter/guides/migration_guide_2_0.mdx @@ -0,0 +1,298 @@ +--- +id: mig_guide_2_0 +sidebar_position: 5 +title: Migrating to 2.0 (Null-safety) +--- + +A Migration Guide For Switching To v2.0 Of The Flutter SDK + +### Overview + +v2.0 of the Stream Chat Flutter SDK brings along several changes - primarily making the SDK null-safe. +Null safety allows your apps to run faster, with fewer errors, and with less code. + +Check [this link](https://flutter.dev/docs/null-safety) for more about Null Safety in Flutter. + +This guide is intended to enumerate and better explain the changes in the SDK. + +The changes will be listed by package and a concise changelog will follow with more info. + +### Changelog of `stream_chat_flutter` + +#### 🛑️ Breaking Changes from 1.5.4 + +* Migrate this package to null safety + +* Renamed `ChannelImage` to `ChannelAvatar` + +* Updated `StreamChatThemeData.reactionIcons` to accept custom builder + +* Renamed `ColorTheme` properties to reflect the purpose of the colors + + * `ColorTheme.black` -> `ColorTheme.textHighEmphasis` + * `ColorTheme.grey` -> `ColorTheme.textLowEmphasis` + * `ColorTheme.greyGainsboro` -> `ColorTheme.disabled` + * `ColorTheme.greyWhisper` -> `ColorTheme.borders` + * `ColorTheme.whiteSmoke` -> `ColorTheme.inputBg` + * `ColorTheme.whiteSnow` -> `ColorTheme.appBg` + * `ColorTheme.white` -> `ColorTheme.barsBg` + * `ColorTheme.blueAlice` -> `ColorTheme.linkBg` + * `ColorTheme.accentBlue` -> `ColorTheme.accentPrimary` + * `ColorTheme.accentRed` -> `ColorTheme.accentError` + * `ColorTheme.accentGreen` -> `ColorTheme.accentInfo` + +* `ChannelListCore` options property is removed in favor of individual properties + + * `options.state` -> `bool state` + * `options.watch` -> `bool watch` + * `options.presence` -> `bool presence` + +* `UserListView` options property is removed in favor of individual properties + + * `options.presence` -> `bool presence` + +* Renamed `ImageHeader` to `GalleryHeader` + +* Renamed `ImageFooter` to `GalleryFooter` + +* `MessageBuilder` and `ParentMessageBuilder` signature is now + +``` +typedef MessageBuilder = Widget Function( + BuildContext, + MessageDetails, + List, + MessageWidget defaultMessageWidget, + ); +``` + +The last parameter is the default `MessageWidget` +You can call `.copyWith` to customize just a subset of properties + +#### ✅ Added + +Added video compress options (frame and quality) to MessageInput +`TypingIndicator` now has a property called `parentId` to show typing indicator specific to threads +#493: add support for `MessageListView` header/footer +`MessageWidget` accepts a `userAvatarBuilder` +Added `pinMessage` ui support +Added `MessageListView.threadSeparatorBuilder` property +Added `MessageInput.onError` property to allow error handling +Added `GalleryHeader`/`GalleryFooter` theme classes + +#### 🐞 Fixed + +#483: Keyboard covers input text box when editing message +`Modals` are shown using the nearest `Navigator` to make using the SDK easier in a nested navigator use case +#484: messages don't update without a reload +`MessageListView` not rendering if the user is not a member of the channel +Fix `MessageInput` overflow when there are no actions +Minor fixes and improvements + +### Migrating to 2.0 for `stream_chat_flutter` + +:::note +If you are migrating your full Flutter project to null-safety, first make sure you follow the +instructions from the [official Null Safety migration guide](https://dart.dev/null-safety/migration-guide). +::: + +To migrate to v2.0 for `stream_chat_flutter`, first change the version of the package to the latest +null-safe version. + +```yaml +dependencies: + stream_chat_flutter: ^2.0.0 +``` + +Upon doing this, all breaking changes from the package will take immediate effect. Here are steps to +remedy the issues: + +1) Replace the offending class names with the revised class names + + * `ChannelImage` -> `ChannelAvatar` + * `ImageHeader` -> `GalleryHeader` + * `ImageFooter` -> `GalleryFooter` + +2) The new version comes with revised color names since the previous names do not suit light/dark mode +nomenclature. Make sure any old colors used from theme are changed over to the new theme color names: + + * `ColorTheme.black` -> `ColorTheme.textHighEmphasis` + * `ColorTheme.grey` -> `ColorTheme.textLowEmphasis` + * `ColorTheme.greyGainsboro` -> `ColorTheme.disabled` + * `ColorTheme.greyWhisper` -> `ColorTheme.borders` + * `ColorTheme.whiteSmoke` -> `ColorTheme.inputBg` + * `ColorTheme.whiteSnow` -> `ColorTheme.appBg` + * `ColorTheme.white` -> `ColorTheme.barsBg` + * `ColorTheme.blueAlice` -> `ColorTheme.linkBg` + * `ColorTheme.accentBlue` -> `ColorTheme.accentPrimary` + * `ColorTheme.accentRed` -> `ColorTheme.accentError` + * `ColorTheme.accentGreen` -> `ColorTheme.accentInfo` + +3) We decided to make messages easier to customize and now supply the default implementation of the +messages in the builder - so you can now customize a single parameter without having to redo the +entire implementation. Please reform your builders to take into account the new format: + +``` +typedef MessageBuilder = Widget Function( + BuildContext, + MessageDetails, + List, + MessageWidget defaultMessageWidget, + ); +``` + +To tweak any of the default properties individually, you can use `defaultMessageWidget.copyWith()`. + +### Changelog of `stream_chat_flutter_core` + +#### 🛑️ Breaking Changes from 1.5.3 + +* Migrate this package to null safety +* `channelsBloc.queryChannels()`, `ChannelListCore` options param/property is removed in favor of individual params/properties + * `options.state` -> `bool state` + * `options.watch` -> `bool watch` + * `options.presence` -> `bool presence` +* `usersBloc.queryUsers()`, `UserListCore` options param/property is removed in favor of individual params/properties + * `options.presence` -> `bool presence` + +#### ✅ Added + +* Monitor connection using `connectivity_plus` package + +#### 🐞 Fixed + +* Minor fixes +* Performance improvements + +### Migrating to 2.0 for `stream_chat_flutter_core` + +:::note +If you are migrating your full Flutter project to null-safety, first make sure you follow the +instructions from the [official Null Safety migration guide](https://dart.dev/null-safety/migration-guide). +::: + +To migrate to v2.0 for `stream_chat_flutter_core`, first change the version of the package to the latest +null-safe version. + +```yaml +dependencies: + stream_chat_flutter_core: ^2.0.0 +``` + +Upon doing this, all breaking changes from the package will take immediate effect. Here are steps to +remedy the issue: + +:::note +The major changes in `stream_chat_flutter_core` consist of changing over from a map full of options +to a more type safe and sound approach by changing over to explicit parameters. +::: + +1) Change over Core widget implementations by using the explicit parameters instead of the options map. +Use these explicit parameters in the widget constructor instead of the option keys: + + * `options.state` -> `bool state` + * `options.watch` -> `bool watch` + * `options.presence` -> `bool presence` + +2) Change over query calls in the BLoCs in the same way (change from options map to explicit parameters +in the constructor) + +### Changelog of `stream_chat` + +#### 🛑️ Breaking Changes from 1.5.3 + +* Migrate this package to null safety +* `ConnectUserWithProvider` now requires `tokenProvider` as a required param. (Removed from the constructor) +* `client.disconnect()` is now divided into two different functions + * `client.closeConnection()` -> for closing user websocket connection. + * `client.disconnectUser()` -> for disconnecting user and resetting client state. +* `client.devToken()` now returns a Token model instead of String. +* `ApiError` is removed in favor of `StreamChatError` + * `StreamChatError` -> parent type for all the stream errors. + * `StreamWebSocketError` -> for user websocket related errors. + * `StreamChatNetworkError` -> for network related errors. +* `client.queryChannels()`, `channel.query()` options param is removed in favor of individual params + * `option.state` -> `bool state` + * `option.watch` -> `bool watch` + * `option.presence` -> `bool presence` +* `client.queryUsers()` options param is removed in favor of individual params + * `option.presence` -> `bool presence` +* Added typed filters + +#### 🐞 Fixed + +* #369: Client does not return without internet connection +* Several minor fixes +* Performance improvements + +#### ✅ Added + +* New Location enum is introduced for easily changing the client location/baseUrl. +* New `client.openConnection()` and `client.closeConnection()` is introduced to connect/disconnect user ws connection. +* New `client.partialUpdateMessage` and `channel.partialUpdateMessage` methods +* `connectWebSocket` parameter in connect user calls to use the client in "connection-less" mode. + +#### 🔄 Changed + +* `baseURL` is now deprecated in favor of using Location to change data location. + +### Migrating to 2.0 for `stream_chat` + +If you are migrating your full Flutter project to null-safety, first make sure you follow the +instructions from the [official Null Safety migration guide](https://dart.dev/null-safety/migration-guide). +::: + +To migrate to v2.0 for `stream_chat`, first change the version of the package to the latest +null-safe version. + +```yaml +dependencies: + stream_chat: ^2.0.0 +``` + +Upon doing this, all breaking changes from the package will take immediate effect. Here are steps to +remedy the issues: + +1) Change over the constructor of `connectUserWithProvider()` to the new format which has `tokenProvider` as a required param. + +2) We added more nuance to `disconnectUser()` by adding two new methods - one to close the connection +and the other to disconnect the user. This allows more fine-grained control of disconnection. + + * `client.closeConnection()` -> for closing user websocket connection. + * `client.disconnectUser()` -> for disconnecting user and resetting client state. + +3) We refactored how we handle errors - new error types are now introduced that replace ApiError. + + * `StreamChatError` -> parent type for all the stream errors. + * `StreamWebSocketError` -> for user websocket related errors. + * `StreamChatNetworkError` -> for network related errors. + +4) We changed over from a map full of options to a more type-safe and sound approach by changing over to explicit parameters. + +Use these explicit parameters in the query parameters instead of the option keys: + +* `client.queryChannels()`, `channel.query()` options param is removed in favor of individual params + * `option.state` -> `bool state` + * `option.watch` -> `bool watch` + * `option.presence` -> `bool presence` +* `client.queryUsers()` options param is removed in favor of individual params + * `option.presence` -> `bool presence` + +5) We added type-safe filters to make filtering in the app easier. Change over the filters to the +new implementation. + +As an example, in the old app this filter: + +```dart + filter: { + 'members': { + '\$in': [StreamChat.of(context).user.id], + } + }, +``` + +Would turn into: + +```dart + filter: Filter.in_('members', [StreamChat.of(context).user.id]) +``` diff --git a/docusaurus/docs/Flutter/stream_chat_flutter/_category_.json b/docusaurus/docs/Flutter/stream_chat_flutter/_category_.json new file mode 100644 index 00000000..242afb7b --- /dev/null +++ b/docusaurus/docs/Flutter/stream_chat_flutter/_category_.json @@ -0,0 +1,4 @@ +{ + "label": "Stream Chat Flutter", + "position": 3 +} \ No newline at end of file diff --git a/docusaurus/docs/Flutter/stream_chat_flutter/channel_header.mdx b/docusaurus/docs/Flutter/stream_chat_flutter/channel_header.mdx new file mode 100644 index 00000000..bf65a863 --- /dev/null +++ b/docusaurus/docs/Flutter/stream_chat_flutter/channel_header.mdx @@ -0,0 +1,84 @@ +--- +id: channel_header +sidebar_position: 10 +title: ChannelHeader +--- + +A Widget To Display Common Channel Details + +Find the pub.dev documentation [here](https://pub.dev/documentation/stream_chat_flutter/latest/stream_chat_flutter/ChannelHeader-class.html) + +![](../assets/channel_header.png) + +### Background + +When a user opens a channel, it is helpful to provide context of which channel they are in. This may +be in the form of a channel name or the users in the channel. Along with that, there also needs to be +a way for the user to look at more details of the channel (media, pinned messages, actions, etc.) and +preferably also a way to navigate back to where they came from. + +To encapsulate all of this functionality into one widget, the Flutter SDK contains a `ChannelHeader` +widget which provides these out of the box. + +### Basic Example + +Let's just add a `ChannelHeader` to a page with a `MessageListView` and a `MessageInput` to display +and send messages. + +```dart +class ChannelPage extends StatelessWidget { + const ChannelPage({ + Key? key, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: ChannelHeader(), + body: Column( + children: [ + Expanded( + child: MessageListView( + threadBuilder: (_, parentMessage) { + return ThreadPage( + parent: parentMessage, + ); + }, + ), + ), + MessageInput(), + ], + ), + ); + } +} +``` + +### Customizing Parts Of The Header + +The header works like a `ListTile` widget. + +Use the `title`, `subtitle`, `leading`, or `actions` parameters to substitute the widgets for your own. + +```dart +//... +ChannelHeader( + title: Text('My Custom Name'), +), +``` + +![](../assets/channel_header_custom_title.png) + +### Showing Connection State + +The `ChannelHeader` can also display connection state below the tile which shows the user if they +are connected or offline, etc. on connection events. + +To enable this, use the `showConnectionStateTile` property. + +```dart +//... +ChannelHeader( + showConnectionStateTile: true, +), +``` diff --git a/docusaurus/docs/Flutter/stream_chat_flutter/channel_list_header.mdx b/docusaurus/docs/Flutter/stream_chat_flutter/channel_list_header.mdx new file mode 100644 index 00000000..d6930cb4 --- /dev/null +++ b/docusaurus/docs/Flutter/stream_chat_flutter/channel_list_header.mdx @@ -0,0 +1,88 @@ +--- +id: channel_list_header +sidebar_position: 9 +title: ChannelListHeader +--- + +A Header Widget For A List Of Channels + +Find the pub.dev documentation [here](https://pub.dev/documentation/stream_chat_flutter/latest/stream_chat_flutter/ChannelListHeader-class.html) + +![](../assets/channel_list_header.png) + +### Background + +A common pattern for most messaging apps is to show a list of Channels (chats) on the first screen +and navigate to an individual one on being clicked. On this first page where the list of channels are +displayed, it is usual to have functionality such as adding a new chat, display the user logged in, etc. + +To encapsulate all of this functionality into one widget, the Flutter SDK contains a `ChannelListHeader` +widget which provides these out of the box. + +### Basic Example + +This is a basic example of a page which has a `ChannelListView` and a `ChannelListHeader` to recreate a +common Channels Page. + +```dart +class DemoPage extends StatelessWidget { + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: ChannelListHeader(), + body: ChannelsBloc( + child: ChannelListView( + filter: Filter.in_('members', [StreamChat.of(context).user.id]), + sort: [SortOption('last_message_at')], + pagination: PaginationParams( + limit: 20, + ), + channelWidget: ChannelPage(), + ), + ), + ); + } +} +``` + +### Customizing Parts Of The Header + +The header works like a `ListTile` widget. + +Use the `titleBuilder`, `subtitle`, `leading`, or `actions` parameters to substitute the widgets for your own. + +```dart +//... +ChannelListHeader( + subtitle: Text('My Custom Subtitle'), +), +``` + +![](../assets/channel_list_header_custom_subtitle.png) + +The `titleBuilder` param helps you build different titles depending on the connection state: + +```dart +//... +ChannelListHeader( + titleBuilder: (context, status, client) { + switch(status) { + /// Return your title widget + } + }, +), +``` + +### Showing Connection State + +The `ChannelListHeader` can also display connection state below the tile which shows the user if they +are connected or offline, etc. on connection events. + +To enable this, use the `showConnectionStateTile` property. + +```dart +//... +ChannelListHeader( + showConnectionStateTile: true, +), +``` diff --git a/docusaurus/docs/Flutter/stream_chat_flutter/channel_list_view.mdx b/docusaurus/docs/Flutter/stream_chat_flutter/channel_list_view.mdx new file mode 100644 index 00000000..eb7baf20 --- /dev/null +++ b/docusaurus/docs/Flutter/stream_chat_flutter/channel_list_view.mdx @@ -0,0 +1,111 @@ +--- +id: channel_list_view +sidebar_position: 4 +title: ChannelListView +--- + +A Widget For Displaying A List Of Channels + +Find the pub.dev documentation [here](https://pub.dev/documentation/stream_chat_flutter/latest/stream_chat_flutter/ChannelListView-class.html) + +![](../assets/channel_list_view.png) + +### Background + +Channels are fundamental elements of Stream Chat and constitute shared spaces which allow users to +message each other. + +1:1 conversations and groups are both examples of channels, albeit with some (distinct/non-distinct) +differences. Displaying the list of channels that a user is a part of is a pattern present in most messaging apps. + +The `ChannelListView` widget allows displaying a list of channels to a user. By default, this is NOT +ONLY the channels that the user is a part of. This section goes into setting up and using a `ChannelListView` +widget. + +### Basic Example + +Here is a basic example of the `ChannelListView` widget. It consists of the main widget itself, a `Filter` +to filter only the channels that the user is a part of, sorting by last message time, pagination params, +and the widget to use when a particular channel is clicked. + +```dart +class ChannelListPage extends StatelessWidget { + @override + Widget build(BuildContext context) { + return Scaffold( + body: ChannelsBloc( + child: ChannelListView( + filter: Filter.in_('members', [StreamChat.of(context).user.id]), + sort: [SortOption('last_message_at')], + pagination: PaginationParams( + limit: 20, + ), + channelWidget: ChannelPage(), + ), + ), + ); + } +} +``` + +This example by default displays the channels that a user is a part of. Now let's look at customizing +the widget. + +### Customizing the Channel Preview + +A common aspect of the widget needed to be tweaked according to each app is the Channel Preview (the +Channel tile in the list). To do this, we use the `channelPreviewBuilder` param like this: + +```dart +ChannelListView( + ... + channelPreviewBuilder: (context, channel) { + return ListTile( + tileColor: Colors.amberAccent, + title: Center( + child: ChannelName(), + ), + ); + }, +), +``` + +Which gives you a new Channel preview in the list: + +![](../assets/channel_preview.png) + +### Adding Swipe Actions + +To add actions (such as delete, more info, etc) when Channel preview is swiped left, set the `swipeToAction` +parameter to `true`. + +```dart +ChannelListView( + ... + swipeToAction: true, +), +``` + +This adds two basic actions - info and delete: + +![](../assets/swipe_channel.png) + +To add custom actions of your own, use the `swipeActions` param: + +```dart +ChannelListView( + ... + swipeToAction: true, + swipeActions: [ + SwipeAction( + color: Colors.blue, + iconWidget: Icon(Icons.add), + onTap: (channel) { + // Things to do on icon tap + }, + ), + // Other actions here + ] +), +``` + diff --git a/docusaurus/docs/Flutter/stream_chat_flutter/introduction.mdx b/docusaurus/docs/Flutter/stream_chat_flutter/introduction.mdx new file mode 100644 index 00000000..28640483 --- /dev/null +++ b/docusaurus/docs/Flutter/stream_chat_flutter/introduction.mdx @@ -0,0 +1,18 @@ +--- +id: introduction +sidebar_position: 1 +title: Introduction +--- + +Understanding The UI Package Of The Flutter SDK + +### What function does `stream_chat_flutter` serve? + +The UI SDK (`stream_chat_flutter`) contains official Flutter components for Stream Chat, a service for building chat applications. + +While the Stream Chat service provides the backend for messaging and the LLC provides an easy way to +use it in your Flutter apps, we wanted to make sure that adding Chat functionality to your app was as quick as possible. + +The UI package is built on top of the low-level client and the core package and allows you to build a +full fledged app with either the inbuilt components, modify existing components, or easily add widgets +of your own to match your app's style better. diff --git a/docusaurus/docs/Flutter/stream_chat_flutter/message_input.mdx b/docusaurus/docs/Flutter/stream_chat_flutter/message_input.mdx new file mode 100644 index 00000000..f33f85e9 --- /dev/null +++ b/docusaurus/docs/Flutter/stream_chat_flutter/message_input.mdx @@ -0,0 +1,170 @@ +--- +id: message_input +sidebar_position: 6 +title: MessageInput +--- + +A Widget Dealing With Everything Related To Sending A Message + +Find the pub.dev documentation [here](https://pub.dev/documentation/stream_chat_flutter/latest/stream_chat_flutter/MessageInput-class.html) + +![](../assets/message_input.png) + +### Background + +In Stream Chat, we can send messages in a channel. However, sending a message isn't as simple as adding +a `TextField` and logic for sending a message. It involves additional processes like addition of media, +quoting a message, adding a custom command like a GIF board, and much more. Moreover, most apps also +need to customize the input to match their theme, overall color and structure pattern, etc. + +To do this, we created a `MessageInput` widget which abstracts all expected functionality a modern input +needs - and allows you to use it out of the box. + +### Basic Example + +A `StreamChannel` is required above the widget tree in which the `MessageInput` is rendered since the channel is +where the messages sent actually go. Let's look at a common example of how we could use the `MessageInput`: + +```dart +class ChannelPage extends StatelessWidget { + const ChannelPage({ + Key key, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: ChannelHeader(), + body: Column( + children: [ + Expanded( + child: MessageListView( + threadBuilder: (_, parentMessage) { + return ThreadPage( + parent: parentMessage, + ); + }, + ), + ), + MessageInput(), + ], + ), + ); + } +} +``` + +It is common to put this widget in the same page of a `MessageListView` as the bottom widget. + +### Quoting A Message + +The quoting functionality allows us to 'reply' to a specific message without creating a thread out of it. +It adds the other message as context when sending a message and also displays it above the sent message. + +To quote a message, we provide a `quotedMessage` to the `MessageInput`. + +```dart +Message? message; + +// ... +MessageInput( + quotedMessage: message, +), +``` + +This will add the message given above the message about to be sent. + +While you can implement your own functionality to select which message to reply to, the `MessageListView` +widget helps in this case since it has an inbuilt `onMessageSwiped` callback which we can use. + +```dart + +class ChannelPage extends StatefulWidget { + @override + _ChannelPageState createState() => _ChannelPageState(); +} + +class _ChannelPageState extends State { + Message? quotedMessage; + + @override + Widget build(BuildContext context) { + return Scaffold( + body: Column( + children: [ + Expanded( + child: MessageListView( + // ... + onMessageSwiped: (message) { + setState(() { + quotedMessage = message; + }); + }, + ), + ), + MessageInput( + quotedMessage: _quotedMessage, + onQuotedMessageCleared: () { + setState(() => _quotedMessage = null); + }, + ), + ], + ), + ); + } +} +``` + +![](../assets/message_input_quoted_message.png) + +### Adding Custom Actions + +By default, the `MessageInput` has two actions: one for attachments and one for commands like Giphy. +To add your own action, we use the `actions` parameter like this: + +```dart +MessageInput( + actions: [ + InkWell( + child: Icon( + Icons.location_on, + size: 20.0, + color: StreamChatTheme.of(context).colorTheme.grey, + ), + onTap: () { + // Do something here + }, + ), + ], +), +``` + +This will add on your action to the existing ones. + +### Disable Attachments + +To disable attachments being added to the message, set the `disableAttachments` parameter to true. + +```dart +MessageInput( + disableAttachments: true, +), +``` + +### Changing Position Of MessageInput Components + +You can also change the position of the TextField, actions and 'send' button relative to each other. + +To do this, use the `actionsLocation` or `sendButtonLocation` parameters which help you decide the location +of the buttons in the input. + +For example, if we want the actions on the right and the send button inside the TextField, we can do: + +```dart +MessageInput( + sendButtonLocation: SendButtonLocation.inside, + actionsLocation: ActionsLocation.right, +), +``` + +![](../assets/message_input_change_position.png) diff --git a/docusaurus/docs/Flutter/stream_chat_flutter/message_list_view.mdx b/docusaurus/docs/Flutter/stream_chat_flutter/message_list_view.mdx new file mode 100644 index 00000000..0ac78f7d --- /dev/null +++ b/docusaurus/docs/Flutter/stream_chat_flutter/message_list_view.mdx @@ -0,0 +1,115 @@ +--- +id: message_list_view +sidebar_position: 5 +title: MessageListView +--- + +A Widget For Displaying A List Of Messages + +Find the pub.dev documentation [here](https://pub.dev/documentation/stream_chat_flutter/latest/stream_chat_flutter/MessageListView-class.html) + +![](../assets/message_list_view.png) + +### Background + +Every channel can contain a list of messages sent by users inside it. The `MessageListView` widget +displays the list of messages inside a particular channel along with possible attachments and +other message attributes (if the message is pinned for example). This sets it apart from the `MessageSearchListView` +which may not contain messages only from a single channel and is used to search for messages across +many. + +### Basic Example + +The `MessageListView` shows the list of messages of the current channel. It has inbuilt support for +common messaging functionality: displaying and editing messages, adding / modifying reactions, support +for quoting messages, pinning messages, and more. + +An example of how you can use the MessageListView is: + +```dart +class ChannelPage extends StatelessWidget { + const ChannelPage({ + Key key, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: ChannelHeader(), + body: Column( + children: [ + Expanded( + child: MessageListView( + threadBuilder: (_, parentMessage) { + return ThreadPage( + parent: parentMessage, + ); + }, + ), + ), + MessageInput(), + ], + ), + ); + } +} +``` + +### Enable Threads + +Threads are made of a parent message and replies linked to it. To enable threading, the SDK requires you +to supply a `threadBuilder` which will supply the page when the thread is clicked. + +```dart +MessageListView( + threadBuilder: (_, parentMessage) { + return ThreadPage( + parent: parentMessage, + ); + }, +), +``` + +![](../assets/message_list_view_threads.png) + +The `MessageListView` itself can render the thread by supplying the `parentMessage` parameter. + +```dart +MessageListView( + parentMessage: parent, +), +``` + +### Building Custom Messages + +You can also supply your own implementation for displaying messages using the `messageBuilder` parameter. + +:::note +To customize the existing implementation, look at the `MessageWidget` documentation instead. +::: + +```dart +MessageListView( + messageBuilder: (context, details, messageList, defaultImpl) { + // Your implementation of the message here + // E.g: return Text(details.message.text ?? ''); + }, +), +``` + +### Enabling Message Pinning + +Message pins save and highlight the message in the `MessageListView`. To enable users to pin the message, +make sure the pin permissions are granted for different types of users on the dashboard. After confirming +the appropriate users have permissions, add the user types in the `pinPermissions` parameter. + +```dart +MessageListView( + //... + pinPermissions: ['admin', 'userType1', 'userType2'], +), +``` + +This will allow these user types to pin messages through the message actions modal. + +![](../assets/message_list_view_pin.png) diff --git a/docusaurus/docs/Flutter/stream_chat_flutter/message_search_list_view.mdx b/docusaurus/docs/Flutter/stream_chat_flutter/message_search_list_view.mdx new file mode 100644 index 00000000..864b5e49 --- /dev/null +++ b/docusaurus/docs/Flutter/stream_chat_flutter/message_search_list_view.mdx @@ -0,0 +1,62 @@ +--- +id: message_search_list_view +sidebar_position: 8 +title: MessageSearchListView +--- + +A Widget To Search For Messages Across Channels + +Find the pub.dev documentation [here](https://pub.dev/documentation/stream_chat_flutter/latest/stream_chat_flutter/MessageSearchListView-class.html) + +![](../assets/message_search_list_view.png) + +### Background + +Users in Stream Chat can have several channels and it can get hard to remember which channel has the +message they are searching for. As such, there needs to be a way to search for a message across multiple +channels. This is where `MessageSearchListView` comes in. + +### Basic Example + +While the MessageListView is tied to a certain `StreamChannel`, a `MessageSearchListView` is not. + +```dart +class MessageSearchPage extends StatelessWidget { + @override + Widget build(BuildContext context) { + return Scaffold( + body: MessageSearchBloc( + child: MessageSearchListView( + filters: Filter.in_('members', [StreamChat.of(context).user!.id],), + messageQuery: 'your query here', + paginationParams: PaginationParams(limit: 20), + ), + ), + ); + } +} +``` + +### Customize The Result Tiles + +You can use your own widget for the result items using the `itemBuilder` parameter. + +```dart +MessageSearchListView( + // ... + itemBuilder: (context, response) { + return Text(response.message.text); + }, +), +``` + +### Show Result Count + +You show the number of results via the `showResultCount` parameter. + +```dart +MessageSearchListView( + // ... + showResultCount: true, +), +``` diff --git a/docusaurus/docs/Flutter/stream_chat_flutter/message_widget.mdx b/docusaurus/docs/Flutter/stream_chat_flutter/message_widget.mdx new file mode 100644 index 00000000..5060bcac --- /dev/null +++ b/docusaurus/docs/Flutter/stream_chat_flutter/message_widget.mdx @@ -0,0 +1,98 @@ +--- +id: message_widget +sidebar_position: 11 +title: MessageWidget +--- + +A Widget For Displaying Messages And Attachments + +Find the pub.dev documentation [here](https://pub.dev/documentation/stream_chat_flutter/latest/stream_chat_flutter/MessageWidget-class.html) + +### Background + +There are several things that need to be displayed with text in a message in a modern messaging app: +attachments, highlights if the message is pinned, user avatars of the sender, etc. + +To encapsulate all of this functionality into one widget, the Flutter SDK contains a `MessageWidget` +widget which provides these out of the box. + +### Basic Example (Modifying `MessageWidget` in `MessageListView`) + +Primarily, the `MessageWidget` is used in the `MessageListView`. To customize only a few properties +of the `MessageWidget` without supplying all other properties, the `messageBuilder` builder supplies +a default implementation of the widget for us to modify. + +```dart +class ChannelPage extends StatelessWidget { + const ChannelPage({ + Key key, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + return Scaffold( + body: MessageListView( + messageBuilder: (context, details, messageList, defaultMessageWidget) { + return defaultMessageWidget.copyWith( + showThreadReplyIndicator: false, + ); + }, + ), + + ); + } +} +``` + +### Building A Custom Attachment + +When a custom attachment type (location, audio, etc.) is sent, the MessageWidget also needs to know +how to build it. For this purpose, we can use the `customAttachmentBuilders` parameter. + +As an example, if a message has a attachment type 'location', we do: + +```dart +MessageWidget( + //... + customAttachmentBuilders: { + 'location': (context, message, attachments) { + var attachmentWidget = Image.network( + _buildMapAttachment( + attachments[0].extraData['latitude'], + attachments[0].extraData['longitude'], + ), + ); + + return wrapAttachmentWidget(context, attachmentWidget, null, true, BorderRadius.circular(8.0)); + } + }, +) +``` + +You can also override the builder for existing attachment types like `image` and `video`. + +### Show User Avatar For Messages + +You can decide to show, hide, or remove user avatars of the sender of the message. To do this, set +the `showUserAvatar` property like this: + +```dart +MessageWidget( + //... + showUserAvatar = DisplayWidget.show, +) +``` + +### Reverse the message + +In most cases, `MessageWidget` needs to be a different orientation depending upon if the sender is the +user or someone else. + +For this, we use the `reverse` parameter to change the orientation of the message: + +```dart +MessageWidget( + //... + reverse = true, +) +``` diff --git a/docusaurus/docs/Flutter/stream_chat_flutter/setup.mdx b/docusaurus/docs/Flutter/stream_chat_flutter/setup.mdx new file mode 100644 index 00000000..22e5c6c5 --- /dev/null +++ b/docusaurus/docs/Flutter/stream_chat_flutter/setup.mdx @@ -0,0 +1,48 @@ +--- +id: setup +sidebar_position: 2 +title: Setup +--- + +Understanding Setup For `stream_chat_flutter` + +### Add pub.dev dependency + +First, you need to add the `stream_chat_flutter` dependency to your `pubspec.yaml`. + +You can either run this command: + +```shell +flutter pub add stream_chat_flutter +``` + +OR + +Add this line in the dependencies section of your pubspec.yaml after substituting latest version: + +```yaml +dependencies: + stream_chat_flutter: ^latest_version +``` + +You can find the package details on [pub.dev](https://pub.dev/packages/stream_chat_flutter). + +### Details On Platform Support + +`stream_chat_flutter` was originally created for Android and iOS mobile platforms. As Flutter matured, +support for additional platforms was added and the package now has experimental support for web and desktop as +[detailed here](https://getstream.io/blog/announcing-experimental-multi-platform-support-for-the-stream-flutter-sdk/). + +However, platforms other than mobile may have additional constraints due to not supporting all plugins, +which will be addressed by the respective plugin creators over time. + +### Setup: iOS + +The library uses [flutter file picker plugin](https://github.com/miguelpruivo/flutter_file_picker) to pick files from the os. +Follow [this wiki](https://github.com/miguelpruivo/flutter_file_picker/wiki/Setup#ios) to fulfill iOS requirements. + +We also use [video_player](https://pub.dev/packages/video_player) to reproduce videos. +Follow [this guide](https://pub.dev/packages/video_player#installation) to fulfill the requirements. + +To pick images from the camera, we use the [image_picker](https://pub.dev/packages/image_picker) plugin. +Follow [these instructions](https://pub.dev/packages/image_picker#ios) to check the requirements. diff --git a/docusaurus/docs/Flutter/stream_chat_flutter/stream_chat_and_theming.mdx b/docusaurus/docs/Flutter/stream_chat_flutter/stream_chat_and_theming.mdx new file mode 100644 index 00000000..d527b738 --- /dev/null +++ b/docusaurus/docs/Flutter/stream_chat_flutter/stream_chat_and_theming.mdx @@ -0,0 +1,71 @@ +--- +id: stream_chat_and_theming +sidebar_position: 3 +title: StreamChat And Theming +--- + +Understanding How To Customize Widgets Using `StreamChatTheme` + +Find the pub.dev documentation [here](https://pub.dev/documentation/stream_chat_flutter/latest/stream_chat_flutter/StreamChatTheme-class.html) and [here](https://pub.dev/documentation/stream_chat_flutter/latest/stream_chat_flutter/StreamChatThemeData-class.html) + +### Background + +Stream's UI SDK makes it easy for developers to add custom styles and attributes to our widgets. Like most Flutter frameworks, Stream exposes a dedicated widget for theming. + +Using `StreamChatTheme`, users can customize most aspects of our UI widgets by setting attributes using `StreamChatThemeData`. + +Similar to the `Theme` and `ThemeData` in Flutter, Stream Chat uses a top level [inherited widget](https://api.flutter.dev/flutter/widgets/InheritedWidget-class.html) to provide theming information throughout your application. This can be optionally set at the top of your application tree or at a localized point in your widget sub-tree. + +If you'd like to customize the look and feel of Stream chat across your entire application, we recommend setting your theme at the top level. Conversely, users can customize specific screens or widgets by wrapping components in a `StreamChatTheme`. + +### A closer look at StreamChatThemeData + +Looking at the constructor for `StreamChatThemeData`, we can see the full list of properties and widgets available for customization. + +Some high-level properties such as `textTheme` or `colorTheme` can be set application-wide directly from this class. In contrast, larger components such as `ChannelHeader`, `MessageInputs`, etc. have been broken up into smaller theme objects. + +```dart +factory StreamChatThemeData({ + Brightness? brightness, + TextTheme? textTheme, + ColorTheme? colorTheme, + ChannelListHeaderTheme? channelListHeaderTheme, + ChannelPreviewTheme? channelPreviewTheme, + ChannelTheme? channelTheme, + MessageTheme? otherMessageTheme, + MessageTheme? ownMessageTheme, + MessageInputTheme? messageInputTheme, + Widget Function(BuildContext, Channel)? defaultChannelImage, + Widget Function(BuildContext, User)? defaultUserImage, + IconThemeData? primaryIconTheme, + List? reactionIcons, + }); +``` + +### Stream Chat Theme in use + +Let's take a look at customizing widgets using `StreamChatTheme`. In the example below, we can change the default color theme to yellow and override the channel header's typography and colors. + +```dart +builder: (context, child) => StreamChat( + client: client, + child: child, + streamChatThemeData: StreamChatThemeData( + colorTheme: ColorTheme.light( + primaryAccent: const Color(0xffffe072), + ), + channelTheme: ChannelTheme( + channelHeaderTheme: ChannelHeaderTheme( + color: const Color(0xffd34646), + title: TextStyle( + color: Colors.white, + ), + ), + ), + ), + ), +``` + +We are creating this class at the very top of our widget tree using the `streamChatThemeData` parameter found in the `StreamChat` widget. + +![](../assets/using_theme.jpg) diff --git a/docusaurus/docs/Flutter/stream_chat_flutter/user_list_view.mdx b/docusaurus/docs/Flutter/stream_chat_flutter/user_list_view.mdx new file mode 100644 index 00000000..61dfbeb4 --- /dev/null +++ b/docusaurus/docs/Flutter/stream_chat_flutter/user_list_view.mdx @@ -0,0 +1,88 @@ +--- +id: user_list_view +sidebar_position: 7 +title: UserListView +--- + +A Widget For Displaying And Selecting Users + +Find the pub.dev documentation [here](https://pub.dev/documentation/stream_chat_flutter/latest/stream_chat_flutter/UserListView-class.html) + +![](../assets/user_list_view.png) + +### Background + +A list of users is required for many different purposes: showing a list of users in a Channel, +selecting users to add in a channel, etc. The `UserListView` displays and allows selection of a list +of users along with multiple display configurations like a list and grid. + +### Basic Example + +Let's take a look at an example where we use the widget to autocomplete user names: + +```dart +class UsersListPage extends StatelessWidget { + @override + Widget build(BuildContext context) { + return Scaffold( + body: UsersBloc( + child: UsersListView( + filter: Filter.notEqual('id', StreamChat.of(context).user!.id), + sort: [ + SortOption( + 'name', + direction: 1, + ), + ], + pagination: PaginationParams( + limit: 25, + ), + ), + ), + ); + } +} +``` + +### Customize The User Items + +You can use your own widget for the user items using the `userItemBuilder` parameter. + +```dart +UsersListView( + // ... + userItemBuilder: (context, user, isSelected) { + return Text(user.name); + }, +), +``` + +### Group Alphabetically + +You can group alphabetically using the `groupAlphabetically` parameter: + +```dart +UsersListView( + //... + groupAlphabetically: true, +), +``` + +### Selecting Users + +The `UserListView` widget allows selecting users in a list by supplying a selected users list and callbacks +for when user items are tapped. + +```dart +Set? selectedUsers = {}; + +UsersListView( + //... + selectedUsers: selectedUsers, + onUserTap: (user, _) { + setState(() { + selectedUsers.add(user); + }); + }, +), +``` diff --git a/docusaurus/docs/Flutter/stream_chat_flutter_core/_category_.json b/docusaurus/docs/Flutter/stream_chat_flutter_core/_category_.json new file mode 100644 index 00000000..8d738e89 --- /dev/null +++ b/docusaurus/docs/Flutter/stream_chat_flutter_core/_category_.json @@ -0,0 +1,4 @@ +{ + "label": "Stream Chat Flutter Core", + "position": 4 +} \ No newline at end of file diff --git a/docusaurus/docs/Flutter/stream_chat_flutter_core/channel_list_core.mdx b/docusaurus/docs/Flutter/stream_chat_flutter_core/channel_list_core.mdx new file mode 100644 index 00000000..4196a14c --- /dev/null +++ b/docusaurus/docs/Flutter/stream_chat_flutter_core/channel_list_core.mdx @@ -0,0 +1,68 @@ +--- +id: channel_list_core +sidebar_position: 4 +title: ChannelListCore +--- + +A Widget For Building A List Of Channels + +### Background + +The UI SDK of Stream Chat supplies a `ChannelListView` class that builds a list of channels fetching +according to the filters and sort order given. However, in some cases, implementing novel UI is necessary +that cannot be done using the customization approaches given in the widget. + +To do this, we extracted the logic required for fetching channels into a 'Core' widget - a widget that +fetches channels in the expected way via the usual params but does not supply any UI and instead +exposes builders to build the UI in situations such as loading, empty data, errors, and on data received. + +### Basic Example + +`ChannelListCore` is a simplified class that allows fetching a list of +channels while exposing UI builders. + +This allows you to construct your own UI while not having to +worry about the specific logic of fetching channels in your app. + +A `ChannelListController` is used to reload and paginate data. + +```dart +class ChannelListPage extends StatelessWidget { + @override + Widget build(BuildContext context) { + return Scaffold( + body: ChannelListCore( + filter: Filter.in_( + 'members', + [StreamChat.of(context).user!.id], + ), + sort: [SortOption('last_message_at')], + pagination: PaginationParams( + limit: 20, + ), + errorBuilder: (context, err) { + return Center( + child: Text('An error has occured'), + ); + }, + emptyBuilder: (context) { + return Center( + child: Text('Nothing here...'), + ); + }, + loadingBuilder: (context) { + return Center( + child: CircularProgressIndicator(), + ); + }, + listBuilder: (context, list) { + return ChannelPage(list); + } + ), + ); + } +} +``` + +Make sure to have a `StreamChatCore` ancestor in order to provide the +information about the channels. \ No newline at end of file diff --git a/docusaurus/docs/Flutter/stream_chat_flutter_core/channels_bloc.mdx b/docusaurus/docs/Flutter/stream_chat_flutter_core/channels_bloc.mdx new file mode 100644 index 00000000..3cb1728a --- /dev/null +++ b/docusaurus/docs/Flutter/stream_chat_flutter_core/channels_bloc.mdx @@ -0,0 +1,90 @@ +--- +id: channels_bloc +sidebar_position: 7 +title: ChannelsBloc +--- + +A Widget Dedicated To The Management Of A Channel List With Pagination. + +### Background + +Most widgets in the Core SDK are focused on fetching a particular type of object from Stream Chat - channels, +messages, users, etc. The BLoC widgets bundle up the base functions used to fetch data as well as the current +data fetched by the respective functions. Furthermore, the Core widgets use this BLoC to fetch new or +existing data and build UI based on it. + +All Core and UI widgets which focus on fetching a list of objects need to have their respective functions +above them in the widget tree. The ChannelListCore and ChannelListView require the ChannelsBloc +above them in the widget hierarchy without which they will fail. + +### Understanding The Widget + +`ChannelsBloc` is used together with `ChannelListCore` to manage a list of +Channels with pagination, re-ordering, querying and other operations +associated with Channels. + +`ChannelsBloc` can be accessed at anytime by using the static `.of` method +using Flutter's `BuildContext`. + +```dart +var _channelsBloc = ChannelsBloc.of(context); +``` + +The `ChannelsBloc` widget encapsulates common functionality related to channel lists such as fetching +the existing channels and querying new channels and also supplies them down the widget tree. + +The widget is required for the respective core widget (`ChannelListCore`) to fetch channels and hence +must be above the core widget in the tree. + +Here is a basic implementation of `ChannelsBloc`: + +```dart +ChannelsBloc( + child: // Further Widget Tree +), +``` + +The `ChannelsBloc` widget allows three customisations: + +#### Lock Channels Order + +ChannelsBloc may change the order of channels when new messages arrive. To lock this order, we can +set the `lockChannelsOrder` property to true. + +```dart +ChannelsBloc( + lockChannelsOrder: true, + child: // Further Widget Tree +), +``` + +#### Set custom channel order + +We can decide the order of the channels in the list by supplying a comparator to the `channelsComparator` +parameter: + +```dart +ChannelsBloc( + channelsComparator: (a, b) { + return a.createdAt!.millisecondsSinceEpoch > + b.createdAt!.millisecondsSinceEpoch + ? 1 + : -1; + }, + child: // Further Widget Tree +), +``` + +#### Decide if channel should be added on new message event + +When a new message arrives, a `message.new` event is received. We can decide if we want to add the channel +to the list using the `shouldAddChannel` parameter which is a callback supplying the event data: + +```dart +ChannelsBloc( + shouldAddChannel: (event) { + return event.message!.extraData['priority'] == '1'; + }, + child: // Further Widget Tree +), +``` \ No newline at end of file diff --git a/docusaurus/docs/Flutter/stream_chat_flutter_core/introduction.mdx b/docusaurus/docs/Flutter/stream_chat_flutter_core/introduction.mdx new file mode 100644 index 00000000..3c6a70f2 --- /dev/null +++ b/docusaurus/docs/Flutter/stream_chat_flutter_core/introduction.mdx @@ -0,0 +1,80 @@ +--- +id: introduction +sidebar_position: 1 +title: Introduction +--- + +Understanding The Core Package Of The Flutter SDK + +This package provides business logic to fetch common things required for integrating Stream Chat into your application. +The core package allows more customisation and hence provides business logic but no UI components. +Please use the `stream_chat_flutter` package for the full fledged suite of UI components or `stream_chat` for the low-level client. + +### Background + +In the early days of the Flutter SDK, the SDK was only split into the LLC (`stream_chat`) and +the UI package (`stream_chat_flutter`). With this you could use a fully built interface with the UI package +or a fully custom interface with the LLC. However, we soon recognised the need for a third intermediary +package which made tasks like building and modifying a list of channels or messages easy but without +the complexity of using low level components. The Core package (`stream_chat_flutter_core`) is a manifestation +of the same idea and allows you to build an interface with Stream Chat without having to deal with +low level code and architecture as well as implementing your own theme and UI effortlessly. +Also, it has very few dependencies. + +We will now explore the components of this intermediary package and understand how it helps you build +the experience you want your users to have. + +The package primarily contains three types of classes: + +* Business Logic Components +* Core Components +* Core Controllers + +### Business Logic Components + +These components allow you to have the maximum and lower-level control of the queries being executed. + +In BLoCs, the basic functionalities - such as queries for messages, channels or queries - are bundled up +and passed along down the tree. Using a BLoC allows you to either create your own way to fetch and +build UIs or use an inbuilt Core widget to do the work such as queries, pagination, etc for you. + +The BLoCs we provide are: + +* ChannelsBloc +* MessageSearchBloc +* UsersBloc + +### Core Components + +Core components usually are an easy way to fetch data associated with Stream Chat. +Core components use functions exposed by the respective BLoCs (for example the ChannelListCore uses the ChannelsBloc) +and use the respective controllers for various operations. Unlike heavier components from the UI +package, core components are decoupled from UI and they expose builders instead to help you build +a fully custom interface. + +Data fetching can be controlled with the controllers of the respective core components. + +* ChannelListCore (Fetch a list of channels) +* MessageListCore (Fetch a list of messages from a channel) +* MessageSearchListCore (Fetch a list of search messages) +* UserListCore (Fetch a list of users) +* StreamChatCore (This is different from the other core components - it is a version of StreamChat decoupled from theme and initialisations.) + +### Core Controllers + +Core Controllers are supplied to respective CoreList widgets which allows reloading and pagination of data whenever needed. + +Unlike the UI package, the Core package allows a fully custom user interface built with the data. This +in turn provides a few challenges: we do not know implicitly when to paginate your list or reload your data. + +While this is handled out of the box in the UI package since the List implementation is inbuilt, a controller +needs to be used in the core package notifying the core components to reload or paginate the data existing +currently. For this, each core component has a respective controller which you can use to call the +specific function (reload / paginate) whenever such an event is triggered through / needed in your UI. + +* ChannelListController +* MessageListController +* MessageSearchListController +* ChannelListController + +This section goes into the individual core package widgets and their functional use. diff --git a/docusaurus/docs/Flutter/stream_chat_flutter_core/message_list_core.mdx b/docusaurus/docs/Flutter/stream_chat_flutter_core/message_list_core.mdx new file mode 100644 index 00000000..5fd5877a --- /dev/null +++ b/docusaurus/docs/Flutter/stream_chat_flutter_core/message_list_core.mdx @@ -0,0 +1,70 @@ +--- +id: message_list_core +sidebar_position: 5 +title: MessageListCore +--- + +A Widget For Building A List Of Messages + +### Background + +The UI SDK of Stream Chat supplies a `MessageListView` class that builds a list of channels fetching +according to the filters and sort order given. However, in some cases, implementing novel UI is necessary +that cannot be done using the customization approaches given in the widget. + +To do this, we extracted the logic required for fetching channels into a 'Core' widget - a widget that +fetches channels in the expected way via the usual params but does not supply any UI and instead +exposes builders to build the UI in situations such as loading, empty data, errors, and on data received. + +### Basic Example + +`MessageListCore` is a simplified class that allows fetching a list of +messages while exposing UI builders. + +This allows you to construct your own UI while not having to +worry about the specific logic of fetching messages in a channel. + +A `MessageListController` is used to paginate data. + +```dart +class ChannelPage extends StatelessWidget { + const ChannelPage({ + Key key, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + return Scaffold( + body: Column( + children: [ + Expanded( + child: MessageListCore( + emptyBuilder: (context) { + return Center( + child: Text('Nothing here...'), + ); + }, + loadingBuilder: (context) { + return Center( + child: CircularProgressIndicator(), + ); + }, + messageListBuilder: (context, list) { + return MessagesPage(list); + }, + errorWidgetBuilder: (context, err) { + return Center( + child: Text('Error'), + ); + }, + ), + ), + ], + ), + ); + } +} +``` + +Make sure to have a `StreamChannel` ancestor in order to provide the +information about the channels. \ No newline at end of file diff --git a/docusaurus/docs/Flutter/stream_chat_flutter_core/message_search_bloc.mdx b/docusaurus/docs/Flutter/stream_chat_flutter_core/message_search_bloc.mdx new file mode 100644 index 00000000..15f5a6d6 --- /dev/null +++ b/docusaurus/docs/Flutter/stream_chat_flutter_core/message_search_bloc.mdx @@ -0,0 +1,40 @@ +--- +id: message_search_list_block +sidebar_position: 8 +title: MessageSearchListBloc +--- + +A Widget Used To Manage A List Of Messages With Pagination. + +### Background + +Most widgets in the Core SDK are focused on fetching a particular type of object from Stream Chat - channels, +messages, users etc. The BLoC widgets bundle up the base functions used to fetch data as well as the current +data fetched by the respective functions. Furthermore, the Core widgets use this BLoC to fetch new or +existing data and build UI based on it. + +All Core and UI widgets which focus on fetching a list of objects need to have their respective functions +above them in the widget tree. The MessageSearchListCore and MessageSearchListView require the +MessageSearchListCore above them in the widget hierarchy without which they will fail. + +### Understanding The Widget + +This class can be used to load messages, perform queries, etc. + +`MessageSearchBloc` can be accessed at anytime by using the static `.of` method +using Flutter's BuildContext. + +```dart +var _searchBloc = MessageSearchBloc.of(context); +``` + +The `MessageSearchBloc` widget encapsulates common functionality related to searching for messages +across channels and also supplies them down the widget tree. + +Here is a basic implementation of `ChannelsBloc`: + +```dart +MessageSearchBloc( + child: // Further Widget Tree +), +``` \ No newline at end of file diff --git a/docusaurus/docs/Flutter/stream_chat_flutter_core/message_search_list_core.mdx b/docusaurus/docs/Flutter/stream_chat_flutter_core/message_search_list_core.mdx new file mode 100644 index 00000000..70cc026f --- /dev/null +++ b/docusaurus/docs/Flutter/stream_chat_flutter_core/message_search_list_core.mdx @@ -0,0 +1,41 @@ +--- +id: message_search_list_core +sidebar_position: 6 +title: MessageSearchListCore +--- + +A Widget For Displaying Message Searches + +### Background + +The UI SDK of Stream Chat supplies a `MessageSearchListView` class that builds a list of channels fetching +according to the filters and sort order given. However, in some cases, implementing novel UI is necessary +that cannot be done using the customization approaches given in the widget. + +To do this, we extracted the logic required for fetching channels into a 'Core' widget - a widget that +fetches channels in the expected way via the usual params but does not supply any UI and instead +exposes builders to build the UI in situations such as loading, empty data, errors, and on data received. + +### Basic Example + +`MessageSearchListCore` is a simplified class that allows searching for + messages across channels while exposing UI builders. + A `MessageSearchListController` is used to load and paginate data. + +```dart +class MessageSearchPage extends StatelessWidget { + @override + Widget build(BuildContext context) { + return Scaffold( + body: MessageSearchListCore( + messageQuery: _messageFilter, + filters: _channelsFilter, + paginationParams: PaginationParams(limit: 20), + ), + ); + } +} +``` + +Make sure to have a `MessageSearchBloc` ancestor in order to provide the +information about the messages. \ No newline at end of file diff --git a/docusaurus/docs/Flutter/stream_chat_flutter_core/setup.mdx b/docusaurus/docs/Flutter/stream_chat_flutter_core/setup.mdx new file mode 100644 index 00000000..15b15a56 --- /dev/null +++ b/docusaurus/docs/Flutter/stream_chat_flutter_core/setup.mdx @@ -0,0 +1,28 @@ +--- +id: setup +sidebar_position: 2 +title: Setup +--- + +Understanding Setup For `stream_chat_flutter_core` + +### Add pub.dev dependency + +First, you need to add the `stream_chat_flutter_core` dependency to your pubspec.yaml + +You can either run this command: + +```shell +flutter pub add stream_chat_flutter_core +``` + +OR + +Add this line in the dependencies section of your pubspec.yaml after substituting latest version: + +```yaml +dependencies: + stream_chat_flutter_core: ^latest_version +``` + +You can find the package details on [pub.dev](https://pub.dev/packages/stream_chat_flutter_core). diff --git a/docusaurus/docs/Flutter/stream_chat_flutter_core/stream_chat_core.mdx b/docusaurus/docs/Flutter/stream_chat_flutter_core/stream_chat_core.mdx new file mode 100644 index 00000000..2a43ae52 --- /dev/null +++ b/docusaurus/docs/Flutter/stream_chat_flutter_core/stream_chat_core.mdx @@ -0,0 +1,26 @@ +--- +id: stream_chat_core +sidebar_position: 3 +title: StreamChatCore +--- + +`StreamChatCore` is a version of `StreamChat` found in `stream_chat_flutter` that is decoupled from +theme and initialisations. + +`StreamChatCore` is used to provide information about the chat client to the widget tree. +This Widget is used to react to life cycle changes and system updates. +When the app goes into the background, the websocket connection is automatically closed and when it goes back to foreground the connection is opened again. + +Like the `StreamChat` widget in the higher level UI package, the `StreamChatCore` widget should +be on the top level before using any Stream functionality: + +```dart +return MaterialApp( + title: 'Stream Chat Core Example', + home: HomeScreen(), + builder: (context, child) => StreamChatCore( + client: client, + child: child!, + ), + ); +``` diff --git a/docusaurus/docs/Flutter/stream_chat_flutter_core/user_list_core.mdx b/docusaurus/docs/Flutter/stream_chat_flutter_core/user_list_core.mdx new file mode 100644 index 00000000..622d0025 --- /dev/null +++ b/docusaurus/docs/Flutter/stream_chat_flutter_core/user_list_core.mdx @@ -0,0 +1,64 @@ +--- +id: user_list_core +sidebar_position: 10 +title: UserListCore +--- + +A Widget For Building A List Of Users + +### Background + +The UI SDK of Stream Chat supplies a `UserListView` class that builds a list of channels fetching +according to the filters and sort order given. However, in some cases, implementing novel UI is necessary +that cannot be done using the customization approaches given in the widget. + +To do this, we extracted the logic required for fetching channels into a 'Core' widget - a widget that +fetches channels in the expected way via the usual params but does not supply any UI and instead +exposes builders to build the UI in situations such as loading, empty data, errors, and on data received. + +### Basic Example + +`UserListCore` is a simplified class that allows fetching users while +exposing UI builders. +A `UserListController` is used to load and paginate data. + +```dart +class UsersListPage extends StatelessWidget { + @override + Widget build(BuildContext context) { + return Scaffold( + body: UsersListCore( + sort: [SortOption('last_active')], + pagination: PaginationParams( + limit: 20, + ), + errorBuilder: (err) { + return Center( + child: Text('An error has occured'), + ); + }, + emptyBuilder: (context) { + return Center( + child: Text('Nothing here...'), + ); + }, + emptyBuilder: (context) { + return Center( + child: CircularProgressIndicator(), + ); + }, + listBuilder: (context, list) { + return UsersPage(list); + } + ), + ); + } +} +``` + +`UsersBloc` must be the ancestor of this widget. This is necessary since +`UserListCore` depends on functionality contained within `UsersBloc`. + +The parameters `listBuilder`, `loadingBuilder`, `emptyBuilder` and +`errorBuilder` must all be supplied and not null. + diff --git a/docusaurus/docs/Flutter/stream_chat_flutter_core/users_bloc.mdx b/docusaurus/docs/Flutter/stream_chat_flutter_core/users_bloc.mdx new file mode 100644 index 00000000..61463599 --- /dev/null +++ b/docusaurus/docs/Flutter/stream_chat_flutter_core/users_bloc.mdx @@ -0,0 +1,37 @@ +--- +id: users_bloc +sidebar_position: 9 +title: UsersBloc +--- + +A Widget Dedicated To The Management Of A Users List With Pagination. + +### Background + +Most widgets in the Core SDK are focused on fetching a particular type of object from Stream Chat - channels, +messages, users, etc. The BLoC widgets bundle up the base functions used to fetch data as well as the current +data fetched by the respective functions. Furthermore, the Core widgets use this BLoC to fetch new or +existing data and build UI based on it. + +All Core and UI widgets which focus on fetching a list of objects need to have their respective functions +above them in the widget tree. The UserListCore and UserListView require the UserListCore +above them in the widget hierarchy without which they will fail. + +### Understanding The Widget + +`UsersBloc` can be accessed at anytime by using the static `.of` method +using Flutter's `BuildContext`. + +```dart +var _userBloc_ = UsersBloc.of(context); +``` + +The `UsersBloc` widget encapsulates common functionality related to user lists and also supplies them down the widget tree. + +Here is a basic implementation of `UsersBloc`: + +```dart +UsersBloc( + child: // Further Widget Tree +), +``` \ No newline at end of file diff --git a/melos.yaml b/melos.yaml index 6a6c74dc..6232c3fa 100644 --- a/melos.yaml +++ b/melos.yaml @@ -1,4 +1,4 @@ -name: stream_chat_dart +name: stream_chat_flutter versioning: mode: independent @@ -7,57 +7,68 @@ packages: - packages/** scripts: + lint:all: + run: melos run analyze && melos run format + description: Run all static analysis checks - # - Requires `pub global activate tuneup`. - analyze: > - melos exec -c 1 --fail-fast -- \ - pub global run tuneup check + analyze: + run: | + melos exec -c 4 --ignore="*example*" -- \ + dart analyze --fatal-infos . + description: | + Run `dart analyze` in all packages. + - Note: you can also rely on your IDEs Dart Analysis / Issues window. - format: pub global run flutter_plugin_tools format + format: + run: flutter format --set-exit-if-changed . + description: | + Run `flutter format --set-exit-if-changed .` in all packages. + lint:pub: + run: | + melos exec -c 4 --no-private --ignore="*example*" -- \ + pub publish --dry-run + description: | + Run `pub publish --dry-run` in all packages. + - Note: you can also rely on your IDEs Dart Analysis / Issues window. - build:examples:ios: > - melos exec -c 1 --scope="*example*" --fail-fast -- \ - flutter build ios --no-codesign + generate:all: + run: melos run generate:dart && melos run generate:flutter + description: Build all generated files for Dart & Flutter packages in this project. + generate:dart: + run: melos exec -c 1 --depends-on="build_runner" --no-flutter -- "dart run build_runner build --delete-conflicting-outputs" + description: Build all generated files for Dart packages in this project. - build:examples:android: > - melos exec -c 1 --scope="*example*" --fail-fast -- \ - flutter build apk + generate:flutter: + run: melos exec -c 1 --depends-on="build_runner" --flutter -- "flutter pub run build_runner build --delete-conflicting-outputs" + description: Build all generated files for Flutter packages in this project. - # Build any plugin example apps that have MacOS support. - # - Requires `flutter config --enable-macos-desktop` enabled. - # - Requires `flutter channel master && flutter upgrade`. - build:examples:macos: > - melos exec -c 1 --scope="*example*" --dir-exists=macos --fail-fast -- \ - flutter build macos + test:all: + run: melos run test:dart --no-select && melos run test:flutter --no-select + description: Run all Dart & Flutter tests in this project. + test:dart: + run: melos exec -c 1 --fail-fast -- "flutter test --coverage" + description: Run Dart tests for a specific package in this project. + select-package: + flutter: false + dir-exists: test - test:dart: > - melos exec -c 1 --fail-fast --no-flutter --dir-exists=test --ignore="*example*" --ignore="*web*" -- \ - flutter pub run test + test:flutter: + run: melos exec -c 3 --fail-fast -- "flutter test --coverage" + description: Run Flutter tests for a specific package in this project. + select-package: + flutter: true + dir-exists: test - test:flutter: > - melos exec -c 1 --fail-fast --flutter --dir-exists=test --ignore="*example*" --ignore="*web*" -- \ - flutter test - - test:web: > - melos exec -c 1 --fail-fast --dir-exists=test --scope="*web*" -- \ - flutter test --platform=chrome - - - lint:pub: > - melos exec -c 5 --fail-fast --no-private --ignore="*example*" -- \ - pub publish --dry-run - - - postclean: > - melos exec -- \ - rm -rf ./build ./android/.gradle ./ios/.symlinks ./ios/Pods ./android/.idea ./.idea ./.dart-tool/build - -dev_dependencies: - pedantic: 1.9.2 + coverage:ignore-file: + run: | + melos exec -c 4 --fail-fast -- "\$MELOS_ROOT_PATH/.github/workflows/scripts/remove-from-coverage.sh" + description: Removes all the ignored files from the coverage report. + select-package: + dir-exists: coverage environment: - sdk: ">=2.7.0 <3.0.0" - flutter: ">=1.22.4 <2.0.0" \ No newline at end of file + sdk: '>=2.12.0 <3.0.0' + flutter: '>=1.22.4 <2.0.0' \ No newline at end of file diff --git a/packages/stream_chat/CHANGELOG.md b/packages/stream_chat/CHANGELOG.md index d6d08a3c..e05599e1 100644 --- a/packages/stream_chat/CHANGELOG.md +++ b/packages/stream_chat/CHANGELOG.md @@ -1,3 +1,100 @@ +## 2.0.0 + +🛑️ Breaking Changes from `1.5.3` + +- migrate this package to null safety +- `ConnectUserWithProvider` now requires `tokenProvider` as a required param. (Removed from the constructor) +- `client.disconnect()` is now divided into two different functions + - `client.closeConnection()` -> for closing user websocket connection. + - `client.disconnectUser()` -> for disconnecting user and resetting client state. +- `client.devToken()` now returns a `Token` model instead of `String`. +- `ApiError` is removed in favor of `StreamChatError` + - `StreamChatError` -> parent type for all the stream errors. + - `StreamWebSocketError` -> for user websocket related errors. + - `StreamChatNetworkError` -> for network related errors. +- `client.queryChannels()`, `channel.query()` options param is removed in favor of individual params + - `option.state` -> bool state + - `option.watch` -> bool watch + - `option.presence` -> bool presence +- `client.queryUsers()` options param is removed in favor of individual params + - `option.presence` -> bool presence +- Migrate this package to null safety +- Added typed filters + +🐞 Fixed + +- [#369](https://github.com/GetStream/stream-chat-flutter/issues/369): Client does not return without internet connection +- several minor fixes +- performance improvements + + +✅ Added + +- New `Location` enum is introduced for easily changing the client location/baseUrl. +- New `client.openConnection()` and `client.closeConnection()` is introduced to connect/disconnect user ws connection. +- New `client.partialUpdateMessage` and `channel.partialUpdateMessage` methods +- `connectWebSocket` parameter in connect user calls to use the client in "connection-less" mode. + +🔄 Changed + +- `baseURL` is now deprecated in favor of using `Location` to change data location. + +## 2.0.0-nullsafety.8 + +🐞 Fixed +- Export `PushProvider` enum + +## 2.0.0-nullsafety.7 + +🛑️ Breaking Changes from `2.0.0-nullsafety.6` + +- `ConnectUserWithProvider` now requires `tokenProvider` as a required param. (Removed from the constructor) +- `client.disconnect()` is now divided into two different functions + - `client.closeConnection()` -> for closing user websocket connection. + - `client.disconnectUser()` -> for disconnecting user and resetting client state. +- `client.devToken()` now returns a `Token` model instead of `String`. +- `ApiError` is removed in favor of `StreamChatError` + - `StreamChatError` -> parent type for all the stream errors. + - `StreamWebSocketError` -> for user websocket related errors. + - `StreamChatNetworkError` -> for network related errors. +- `client.queryChannels()`, `channel.query()` options param is removed in favor of individual params + - `option.state` -> bool state + - `option.watch` -> bool watch + - `option.presence` -> bool presence +- `client.queryUsers()` options param is removed in favor of individual params + - `option.presence` -> bool presence + +✅ Added + +- New `Location` enum is introduced for easily changing the client location/baseUrl. +- New `client.openConnection()` and `client.closeConnection()` is introduced to connect/disconnect user ws connection. + +🔄 Changed + +- `baseURL` is now deprecated in favor of using `Location` to change data location. + +## 2.0.0-nullsafety.6 + +- Fix thread reply not working with attachments +- Minor fixes +## 2.0.0-nullsafety.5 + +- Minor fixes +- Performance improvements +- Fixed `skip_push` in `client.sendMessage` +- Added partial message update method + +## 2.0.0-nullsafety.2 + +- Added new `Filter.raw` constructor +- Changed extraData +- Minor fixes + +## 2.0.0-nullsafety.1 + +- Migrate this package to null safety +- Added typed filters + ## 1.5.3 - fix: `StreamChatClient.connect` returns quicker when you're using the persistence package @@ -34,7 +131,8 @@ - Save pinned messages in offline storage - Minor fixes - `StreamClient.QueryChannels` now returns a Stream and fetches the channels from storage before calling the api -- Added `StreamClient.QueryChannelsOnline` and `StreamClient.QueryChannelsOffline` to fetch channels only from online or offline +- Added `StreamClient.QueryChannelsOnline` and `StreamClient.QueryChannelsOffline` to fetch channels only from online or + offline ## 1.2.0-beta @@ -45,7 +143,8 @@ ## 1.1.0-beta - Fixed minor bugs -- Add support for custom attachment upload [docs here](https://getstream.io/chat/docs/flutter-dart/file_uploads/?language=dart) +- Add support for custom attachment + upload [docs here](https://getstream.io/chat/docs/flutter-dart/file_uploads/?language=dart) - Add support for asynchronous attachment upload ## 1.0.3-beta @@ -55,7 +154,8 @@ ## 1.0.2-beta -- Deprecated `setUser`, `setGuestUser`, `setUserWithProvider` in favor of `connectUser`, `connectGuestUser`, `connectUserWithProvider` +- Deprecated `setUser`, `setGuestUser`, `setUserWithProvider` in favor of `connectUser`, `connectGuestUser` + , `connectUserWithProvider` - Optimised reaction updates - i.e., Update first call Api later. ## 1.0.1-beta @@ -65,9 +165,11 @@ ## 1.0.0-beta - 🛑 **BREAKING** Renamed `Client` to less generic `StreamChatClient` -- 🛑 **BREAKING** Segregated the persistence layer into separate package [stream_chat_persistence](https://pub.dev/packages/stream_chat_persistence) +- 🛑 **BREAKING** Segregated the persistence layer into separate + package [stream_chat_persistence](https://pub.dev/packages/stream_chat_persistence) - 🛑 **BREAKING** Moved `Client.backgroundKeepAlive` to [core package](https://pub.dev/packages/stream_chat_core) -- 🛑 **BREAKING** Moved `Client.showLocalNotification` to [core package](https://pub.dev/packages/stream_chat_core) and renamed it to `StreamChatCore.onBackgroundEventReceived` +- 🛑 **BREAKING** Moved `Client.showLocalNotification` to [core package](https://pub.dev/packages/stream_chat_core) and + renamed it to `StreamChatCore.onBackgroundEventReceived` - Removed `flutter` dependency. This is now a pure Dart package 🥳 - Minor improvements and bugfixes @@ -122,7 +224,8 @@ ## 0.2.20 -- Return offline data only if the backend is unreachable. This avoids the glitch of the ChannelListView because we cannot sort by custom properties. +- Return offline data only if the backend is unreachable. This avoids the glitch of the ChannelListView because we + cannot sort by custom properties. ## 0.2.19 @@ -136,7 +239,7 @@ ## 0.2.17+1 -- Do not retry messages when server returns error +- Do not retry messages when server returns error ## 0.2.17 diff --git a/packages/stream_chat/analysis_options.yaml b/packages/stream_chat/analysis_options.yaml deleted file mode 100644 index ddc691d0..00000000 --- a/packages/stream_chat/analysis_options.yaml +++ /dev/null @@ -1,144 +0,0 @@ -analyzer: - exclude: - - lib/**/*.g.dart - - lib/**/*.freezed.dart - - example/* - - test/* -linter: - rules: - - always_use_package_imports - - avoid_empty_else - - avoid_relative_lib_imports - - avoid_slow_async_io - - avoid_types_as_parameter_names - - cancel_subscriptions - - close_sinks - - control_flow_in_finally - - empty_statements - - hash_and_equals - - invariant_booleans - - iterable_contains_unrelated_type - - list_remove_unrelated_type - - literal_only_boolean_expressions - - no_adjacent_strings_in_list - - no_duplicate_case_values - - no_logic_in_create_state - - prefer_void_to_null - - test_types_in_equals - - throw_in_finally - - unnecessary_statements - - unrelated_type_equality_checks - - omit_local_variable_types - - use_key_in_widget_constructors - - valid_regexps - - always_declare_return_types - - always_require_non_null_named_parameters - - annotate_overrides - - avoid_bool_literals_in_conditional_expressions - - avoid_catching_errors - - avoid_init_to_null - - avoid_null_checks_in_equality_operators - - avoid_positional_boolean_parameters - - avoid_private_typedef_functions - - avoid_redundant_argument_values - - avoid_return_types_on_setters - - avoid_returning_null_for_void - - avoid_shadowing_type_parameters - - avoid_single_cascade_in_expression_statements - - avoid_unnecessary_containers - - avoid_unused_constructor_parameters - - await_only_futures - - camel_case_extensions - - camel_case_types - - cascade_invocations - - - constant_identifier_names - - curly_braces_in_flow_control_structures - - directives_ordering - - empty_catches - - empty_constructor_bodies - - exhaustive_cases - - file_names - - implementation_imports - - join_return_with_assignment - - leading_newlines_in_multiline_strings - - library_names - - library_prefixes - - lines_longer_than_80_chars - - missing_whitespace_between_adjacent_strings - - non_constant_identifier_names - - null_closures - - one_member_abstracts - - only_throw_errors - - package_api_docs - - package_prefixed_library_names - - parameter_assignments - - prefer_adjacent_string_concatenation - - prefer_asserts_in_initializer_lists - - prefer_asserts_with_message - - prefer_collection_literals - - prefer_conditional_assignment - - prefer_const_constructors - - prefer_const_constructors_in_immutables - - prefer_const_declarations - - prefer_const_literals_to_create_immutables - - prefer_constructors_over_static_methods - - prefer_contains - - prefer_equal_for_default_values - - prefer_expression_function_bodies - - prefer_final_fields - - prefer_final_in_for_each - - prefer_final_locals - - prefer_function_declarations_over_variables - - prefer_generic_function_type_aliases - - prefer_if_elements_to_conditional_expressions - - prefer_if_null_operators - - prefer_initializing_formals - - prefer_inlined_adds - - prefer_int_literals - - prefer_interpolation_to_compose_strings - - prefer_is_empty - - prefer_is_not_empty - - prefer_is_not_operator - - prefer_null_aware_operators - - prefer_single_quotes - - prefer_spread_collections - - prefer_typing_uninitialized_variables - - provide_deprecation_message - - public_member_api_docs - - recursive_getters - - sized_box_for_whitespace - - slash_for_doc_comments - - sort_child_properties_last - - sort_constructors_first - - sort_unnamed_constructors_first - - - type_annotate_public_apis - - type_init_formals - - unnecessary_await_in_return - - unnecessary_brace_in_string_interps - - unnecessary_const - - unnecessary_getters_setters - - unnecessary_lambdas - - unnecessary_new - - unnecessary_null_aware_assignments - - unnecessary_null_in_if_null_operators - - unnecessary_nullable_for_final_variable_declarations - - unnecessary_parenthesis - - unnecessary_raw_strings - - unnecessary_string_escapes - - unnecessary_string_interpolations - - unnecessary_this - - use_is_even_rather_than_modulo - - use_late_for_private_fields_and_variables - - use_rethrow_when_possible - - use_setters_to_change_properties - - use_to_and_as_if_applicable - - package_names - - sort_pub_dependencies - - # To be added when null-safe: - # - cast_nullable_to_non_nullable - #- unnecessary_null_checks - # - tighten_type_of_initializing_formals - # - null_check_on_nullable_type_parameter \ No newline at end of file diff --git a/packages/stream_chat/build.yaml b/packages/stream_chat/build.yaml index ddbd70dd..d439bddb 100644 --- a/packages/stream_chat/build.yaml +++ b/packages/stream_chat/build.yaml @@ -4,5 +4,4 @@ targets: json_serializable: options: explicit_to_json: true - field_rename: snake - any_map: true + field_rename: snake \ No newline at end of file diff --git a/packages/stream_chat/example/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/packages/stream_chat/example/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata index 1d526a16..919434a6 100644 --- a/packages/stream_chat/example/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata +++ b/packages/stream_chat/example/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -2,6 +2,6 @@ + location = "self:"> diff --git a/packages/stream_chat/example/lib/main.dart b/packages/stream_chat/example/lib/main.dart index 2d0a529f..167f5f6b 100644 --- a/packages/stream_chat/example/lib/main.dart +++ b/packages/stream_chat/example/lib/main.dart @@ -2,12 +2,9 @@ import 'package:flutter/material.dart'; import 'package:stream_chat/stream_chat.dart'; Future main() async { - /// Create a new instance of [StreamChatClient] passing the apikey obtained from your - /// project dashboard. - final client = StreamChatClient( - 'b67pax5b2wdq', - logLevel: Level.INFO, - ); + /// Create a new instance of [StreamChatClient] + /// by passing the apikey obtained from your project dashboard. + final client = StreamChatClient('b67pax5b2wdq', logLevel: Level.INFO); /// Set the current user. In a production scenario, this should be done using /// a backend to generate a user token using our server SDK. @@ -16,12 +13,12 @@ Future main() async { await client.connectUser( User( id: 'cool-shadow-7', - extraData: { + extraData: const { 'image': 'https://getstream.io/random_png/?id=cool-shadow-7&name=Cool+shadow', }, ), - 'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VyX2lkIjoiY29vbC1zaGFkb3ctNyJ9.gkOlCRb1qgy4joHPaxFwPOdXcGvSPvp6QY0S4mpRkVo', + '''eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VyX2lkIjoiY29vbC1zaGFkb3ctNyJ9.gkOlCRb1qgy4joHPaxFwPOdXcGvSPvp6QY0S4mpRkVo''', ); /// Creates a channel using the type `messaging` and `godevs`. @@ -44,55 +41,57 @@ Future main() async { /// Example using Stream's Low Level Dart client. class StreamExample extends StatelessWidget { - /// To initialize this example, an instance of [client] and [channel] is required. + /// To initialize this example, an instance of + /// [client] and [channel] is required. const StreamExample({ - Key key, - @required this.client, - @required this.channel, + Key? key, + required this.client, + required this.channel, }) : super(key: key); - /// Instance of [StreamChatClient] we created earlier. This contains information about - /// our application and connection state. + /// Instance of [StreamChatClient] we created earlier. + /// This contains information about our application and connection state. final StreamChatClient client; /// The channel we'd like to observe and participate. final Channel channel; @override - Widget build(BuildContext context) { - return MaterialApp( - title: 'Stream Chat Dart Example', - home: HomeScreen(channel: channel), - ); - } + Widget build(BuildContext context) => MaterialApp( + title: 'Stream Chat Dart Example', + home: HomeScreen(channel: channel), + ); } /// Main screen of our application. The layout is comprised of an [AppBar] /// containing the channel name and a [MessageView] displaying recent messages. class HomeScreen extends StatelessWidget { /// [HomeScreen] is constructed using the [Channel] we defined earlier. - const HomeScreen({Key key, @required this.channel}) : super(key: key); + const HomeScreen({ + Key? key, + required this.channel, + }) : super(key: key); /// Channel object containing the [Channel.id] we'd like to observe. final Channel channel; @override Widget build(BuildContext context) { - final messages = channel.state.channelStateStream; + final messages = channel.state!.messagesStream; return Scaffold( appBar: AppBar( title: Text('Channel: ${channel.id}'), ), body: SafeArea( - child: StreamBuilder( + child: StreamBuilder?>( stream: messages, builder: ( BuildContext context, - AsyncSnapshot snapshot, + AsyncSnapshot?> snapshot, ) { if (snapshot.hasData && snapshot.data != null) { return MessageView( - messages: snapshot.data.messages.reversed.toList(), + messages: snapshot.data!.reversed.toList(), channel: channel, ); } else if (snapshot.hasError) { @@ -104,8 +103,8 @@ class HomeScreen extends StatelessWidget { } return const Center( child: SizedBox( - width: 100.0, - height: 100.0, + width: 100, + height: 100, child: CircularProgressIndicator(), ), ); @@ -121,9 +120,9 @@ class HomeScreen extends StatelessWidget { class MessageView extends StatefulWidget { /// Message takes the latest list of messages and the current channel. const MessageView({ - Key key, - @required this.messages, - @required this.channel, + Key? key, + required this.messages, + required this.channel, }) : super(key: key); /// List of messages sent in the given channel. @@ -137,8 +136,8 @@ class MessageView extends StatefulWidget { } class _MessageViewState extends State { - TextEditingController _controller; - ScrollController _scrollController; + late final TextEditingController _controller; + late final ScrollController _scrollController; List get _messages => widget.messages; @@ -166,86 +165,85 @@ class _MessageViewState extends State { } @override - Widget build(BuildContext context) { - return Column( - children: [ - Expanded( - child: ListView.builder( - controller: _scrollController, - itemCount: _messages.length, - reverse: true, - itemBuilder: (BuildContext context, int index) { - final item = _messages[index]; - if (item.user.id == widget.channel.client.uid) { - return Align( - alignment: Alignment.centerRight, - child: Padding( - padding: const EdgeInsets.all(8.0), - child: Text(item.text), - ), - ); - } else { - return Align( - alignment: Alignment.centerLeft, - child: Padding( - padding: const EdgeInsets.all(8.0), - child: Text(item.text), - ), - ); - } - }, + Widget build(BuildContext context) => Column( + children: [ + Expanded( + child: ListView.builder( + controller: _scrollController, + itemCount: _messages.length, + reverse: true, + itemBuilder: (BuildContext context, int index) { + final item = _messages[index]; + if (item.user?.id == widget.channel.client.uid) { + return Align( + alignment: Alignment.centerRight, + child: Padding( + padding: const EdgeInsets.all(8), + child: Text(item.text ?? ''), + ), + ); + } else { + return Align( + alignment: Alignment.centerLeft, + child: Padding( + padding: const EdgeInsets.all(8), + child: Text(item.text ?? ''), + ), + ); + } + }, + ), ), - ), - Padding( - padding: const EdgeInsets.all(8.0), - child: Row( - children: [ - Expanded( - child: TextField( - controller: _controller, - decoration: const InputDecoration( - hintText: 'Enter your message', - ), - ), - ), - Material( - type: MaterialType.circle, - color: Colors.blue, - clipBehavior: Clip.hardEdge, - child: InkWell( - onTap: () async { - // We can send a new message by calling `sendMessage` on - // the current channel. After sending a message, the - // TextField is cleared and the list view is scrolled - // to show the new item. - if (_controller.value.text.isNotEmpty) { - await widget.channel.sendMessage( - Message(text: _controller.value.text), - ); - _controller.clear(); - _updateList(); - } - }, - child: const Padding( - padding: EdgeInsets.all(8.0), - child: Center( - child: Icon( - Icons.send, - color: Colors.white, - ), + Padding( + padding: const EdgeInsets.all(8), + child: Row( + children: [ + Expanded( + child: TextField( + controller: _controller, + decoration: const InputDecoration( + hintText: 'Enter your message', ), ), ), - ) - ], - ), - ) - ], - ); - } + Material( + type: MaterialType.circle, + color: Colors.blue, + clipBehavior: Clip.hardEdge, + child: InkWell( + onTap: () async { + // We can send a new message by calling `sendMessage` on + // the current channel. After sending a message, the + // TextField is cleared and the list view is scrolled + // to show the new item. + if (_controller.value.text.isNotEmpty) { + await widget.channel.sendMessage( + Message(text: _controller.value.text), + ); + _controller.clear(); + _updateList(); + } + }, + child: const Padding( + padding: EdgeInsets.all(8), + child: Center( + child: Icon( + Icons.send, + color: Colors.white, + ), + ), + ), + ), + ) + ], + ), + ) + ], + ); } -/// Helper extension for quickly retrieving the current user id from a [StreamChatClient]. +/// Helper extension for quickly retrieving +/// the current user id from a [StreamChatClient]. extension on StreamChatClient { - String get uid => state.user.id; + String get uid => state.user!.id; } diff --git a/packages/stream_chat/example/pubspec.yaml b/packages/stream_chat/example/pubspec.yaml index 2a8b7eb2..1b092b3e 100644 --- a/packages/stream_chat/example/pubspec.yaml +++ b/packages/stream_chat/example/pubspec.yaml @@ -1,21 +1,22 @@ name: example description: A new Flutter project. -publish_to: 'none' +publish_to: "none" version: 1.0.0+1 environment: - sdk: ">=2.7.0 <3.0.0" + sdk: '>=2.12.0 <3.0.0' dependencies: + cupertino_icons: ^1.0.0 flutter: sdk: flutter - cupertino_icons: ^1.0.0 - stream_chat: + stream_chat: path: ../ dev_dependencies: flutter_test: sdk: flutter + flutter: - uses-material-design: true \ No newline at end of file + uses-material-design: true diff --git a/packages/stream_chat/lib/src/api/responses.g.dart b/packages/stream_chat/lib/src/api/responses.g.dart deleted file mode 100644 index 1f290d6e..00000000 --- a/packages/stream_chat/lib/src/api/responses.g.dart +++ /dev/null @@ -1,399 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND - -part of 'responses.dart'; - -// ************************************************************************** -// JsonSerializableGenerator -// ************************************************************************** - -SyncResponse _$SyncResponseFromJson(Map json) { - return SyncResponse() - ..duration = json['duration'] as String - ..events = (json['events'] as List) - ?.map((e) => e == null - ? null - : Event.fromJson((e as Map)?.map( - (k, e) => MapEntry(k as String, e), - ))) - ?.toList(); -} - -QueryChannelsResponse _$QueryChannelsResponseFromJson(Map json) { - return QueryChannelsResponse() - ..duration = json['duration'] as String - ..channels = (json['channels'] as List) - ?.map((e) => e == null ? null : ChannelState.fromJson(e as Map)) - ?.toList(); -} - -TranslateMessageResponse _$TranslateMessageResponseFromJson(Map json) { - return TranslateMessageResponse() - ..duration = json['duration'] as String - ..message = json['message'] == null - ? null - : TranslatedMessage.fromJson((json['message'] as Map)?.map( - (k, e) => MapEntry(k as String, e), - )); -} - -QueryMembersResponse _$QueryMembersResponseFromJson(Map json) { - return QueryMembersResponse() - ..duration = json['duration'] as String - ..members = (json['members'] as List) - ?.map((e) => e == null - ? null - : Member.fromJson((e as Map)?.map( - (k, e) => MapEntry(k as String, e), - ))) - ?.toList(); -} - -QueryUsersResponse _$QueryUsersResponseFromJson(Map json) { - return QueryUsersResponse() - ..duration = json['duration'] as String - ..users = (json['users'] as List) - ?.map((e) => e == null - ? null - : User.fromJson((e as Map)?.map( - (k, e) => MapEntry(k as String, e), - ))) - ?.toList(); -} - -QueryReactionsResponse _$QueryReactionsResponseFromJson(Map json) { - return QueryReactionsResponse() - ..duration = json['duration'] as String - ..reactions = (json['reactions'] as List) - ?.map((e) => e == null - ? null - : Reaction.fromJson((e as Map)?.map( - (k, e) => MapEntry(k as String, e), - ))) - ?.toList(); -} - -QueryRepliesResponse _$QueryRepliesResponseFromJson(Map json) { - return QueryRepliesResponse() - ..duration = json['duration'] as String - ..messages = (json['messages'] as List) - ?.map((e) => e == null - ? null - : Message.fromJson((e as Map)?.map( - (k, e) => MapEntry(k as String, e), - ))) - ?.toList(); -} - -ListDevicesResponse _$ListDevicesResponseFromJson(Map json) { - return ListDevicesResponse() - ..duration = json['duration'] as String - ..devices = (json['devices'] as List) - ?.map((e) => e == null - ? null - : Device.fromJson((e as Map)?.map( - (k, e) => MapEntry(k as String, e), - ))) - ?.toList(); -} - -SendFileResponse _$SendFileResponseFromJson(Map json) { - return SendFileResponse() - ..duration = json['duration'] as String - ..file = json['file'] as String; -} - -SendImageResponse _$SendImageResponseFromJson(Map json) { - return SendImageResponse() - ..duration = json['duration'] as String - ..file = json['file'] as String; -} - -SendReactionResponse _$SendReactionResponseFromJson(Map json) { - return SendReactionResponse() - ..duration = json['duration'] as String - ..message = json['message'] == null - ? null - : Message.fromJson((json['message'] as Map)?.map( - (k, e) => MapEntry(k as String, e), - )) - ..reaction = json['reaction'] == null - ? null - : Reaction.fromJson((json['reaction'] as Map)?.map( - (k, e) => MapEntry(k as String, e), - )); -} - -ConnectGuestUserResponse _$ConnectGuestUserResponseFromJson(Map json) { - return ConnectGuestUserResponse() - ..duration = json['duration'] as String - ..accessToken = json['access_token'] as String - ..user = json['user'] == null - ? null - : User.fromJson((json['user'] as Map)?.map( - (k, e) => MapEntry(k as String, e), - )); -} - -UpdateUsersResponse _$UpdateUsersResponseFromJson(Map json) { - return UpdateUsersResponse() - ..duration = json['duration'] as String - ..users = (json['users'] as Map)?.map( - (k, e) => MapEntry( - k as String, - e == null - ? null - : User.fromJson((e as Map)?.map( - (k, e) => MapEntry(k as String, e), - ))), - ); -} - -UpdateMessageResponse _$UpdateMessageResponseFromJson(Map json) { - return UpdateMessageResponse() - ..duration = json['duration'] as String - ..message = json['message'] == null - ? null - : Message.fromJson((json['message'] as Map)?.map( - (k, e) => MapEntry(k as String, e), - )); -} - -SendMessageResponse _$SendMessageResponseFromJson(Map json) { - return SendMessageResponse() - ..duration = json['duration'] as String - ..message = json['message'] == null - ? null - : Message.fromJson((json['message'] as Map)?.map( - (k, e) => MapEntry(k as String, e), - )); -} - -GetMessageResponse _$GetMessageResponseFromJson(Map json) { - return GetMessageResponse() - ..duration = json['duration'] as String - ..message = json['message'] == null - ? null - : Message.fromJson((json['message'] as Map)?.map( - (k, e) => MapEntry(k as String, e), - )) - ..channel = json['channel'] == null - ? null - : ChannelModel.fromJson((json['channel'] as Map)?.map( - (k, e) => MapEntry(k as String, e), - )); -} - -SearchMessagesResponse _$SearchMessagesResponseFromJson(Map json) { - return SearchMessagesResponse() - ..duration = json['duration'] as String - ..results = (json['results'] as List) - ?.map((e) => e == null ? null : GetMessageResponse.fromJson(e as Map)) - ?.toList(); -} - -GetMessagesByIdResponse _$GetMessagesByIdResponseFromJson(Map json) { - return GetMessagesByIdResponse() - ..duration = json['duration'] as String - ..messages = (json['messages'] as List) - ?.map((e) => e == null - ? null - : Message.fromJson((e as Map)?.map( - (k, e) => MapEntry(k as String, e), - ))) - ?.toList(); -} - -UpdateChannelResponse _$UpdateChannelResponseFromJson(Map json) { - return UpdateChannelResponse() - ..duration = json['duration'] as String - ..channel = json['channel'] == null - ? null - : ChannelModel.fromJson((json['channel'] as Map)?.map( - (k, e) => MapEntry(k as String, e), - )) - ..members = (json['members'] as List) - ?.map((e) => e == null - ? null - : Member.fromJson((e as Map)?.map( - (k, e) => MapEntry(k as String, e), - ))) - ?.toList() - ..message = json['message'] == null - ? null - : Message.fromJson((json['message'] as Map)?.map( - (k, e) => MapEntry(k as String, e), - )); -} - -PartialUpdateChannelResponse _$PartialUpdateChannelResponseFromJson(Map json) { - return PartialUpdateChannelResponse() - ..duration = json['duration'] as String - ..channel = json['channel'] == null - ? null - : ChannelModel.fromJson((json['channel'] as Map)?.map( - (k, e) => MapEntry(k as String, e), - )) - ..members = (json['members'] as List) - ?.map((e) => e == null - ? null - : Member.fromJson((e as Map)?.map( - (k, e) => MapEntry(k as String, e), - ))) - ?.toList(); -} - -InviteMembersResponse _$InviteMembersResponseFromJson(Map json) { - return InviteMembersResponse() - ..duration = json['duration'] as String - ..channel = json['channel'] == null - ? null - : ChannelModel.fromJson((json['channel'] as Map)?.map( - (k, e) => MapEntry(k as String, e), - )) - ..members = (json['members'] as List) - ?.map((e) => e == null - ? null - : Member.fromJson((e as Map)?.map( - (k, e) => MapEntry(k as String, e), - ))) - ?.toList() - ..message = json['message'] == null - ? null - : Message.fromJson((json['message'] as Map)?.map( - (k, e) => MapEntry(k as String, e), - )); -} - -RemoveMembersResponse _$RemoveMembersResponseFromJson(Map json) { - return RemoveMembersResponse() - ..duration = json['duration'] as String - ..channel = json['channel'] == null - ? null - : ChannelModel.fromJson((json['channel'] as Map)?.map( - (k, e) => MapEntry(k as String, e), - )) - ..members = (json['members'] as List) - ?.map((e) => e == null - ? null - : Member.fromJson((e as Map)?.map( - (k, e) => MapEntry(k as String, e), - ))) - ?.toList() - ..message = json['message'] == null - ? null - : Message.fromJson((json['message'] as Map)?.map( - (k, e) => MapEntry(k as String, e), - )); -} - -SendActionResponse _$SendActionResponseFromJson(Map json) { - return SendActionResponse() - ..duration = json['duration'] as String - ..message = json['message'] == null - ? null - : Message.fromJson((json['message'] as Map)?.map( - (k, e) => MapEntry(k as String, e), - )); -} - -AddMembersResponse _$AddMembersResponseFromJson(Map json) { - return AddMembersResponse() - ..duration = json['duration'] as String - ..channel = json['channel'] == null - ? null - : ChannelModel.fromJson((json['channel'] as Map)?.map( - (k, e) => MapEntry(k as String, e), - )) - ..members = (json['members'] as List) - ?.map((e) => e == null - ? null - : Member.fromJson((e as Map)?.map( - (k, e) => MapEntry(k as String, e), - ))) - ?.toList() - ..message = json['message'] == null - ? null - : Message.fromJson((json['message'] as Map)?.map( - (k, e) => MapEntry(k as String, e), - )); -} - -AcceptInviteResponse _$AcceptInviteResponseFromJson(Map json) { - return AcceptInviteResponse() - ..duration = json['duration'] as String - ..channel = json['channel'] == null - ? null - : ChannelModel.fromJson((json['channel'] as Map)?.map( - (k, e) => MapEntry(k as String, e), - )) - ..members = (json['members'] as List) - ?.map((e) => e == null - ? null - : Member.fromJson((e as Map)?.map( - (k, e) => MapEntry(k as String, e), - ))) - ?.toList() - ..message = json['message'] == null - ? null - : Message.fromJson((json['message'] as Map)?.map( - (k, e) => MapEntry(k as String, e), - )); -} - -RejectInviteResponse _$RejectInviteResponseFromJson(Map json) { - return RejectInviteResponse() - ..duration = json['duration'] as String - ..channel = json['channel'] == null - ? null - : ChannelModel.fromJson((json['channel'] as Map)?.map( - (k, e) => MapEntry(k as String, e), - )) - ..members = (json['members'] as List) - ?.map((e) => e == null - ? null - : Member.fromJson((e as Map)?.map( - (k, e) => MapEntry(k as String, e), - ))) - ?.toList() - ..message = json['message'] == null - ? null - : Message.fromJson((json['message'] as Map)?.map( - (k, e) => MapEntry(k as String, e), - )); -} - -EmptyResponse _$EmptyResponseFromJson(Map json) { - return EmptyResponse()..duration = json['duration'] as String; -} - -ChannelStateResponse _$ChannelStateResponseFromJson(Map json) { - return ChannelStateResponse() - ..duration = json['duration'] as String - ..channel = json['channel'] == null - ? null - : ChannelModel.fromJson((json['channel'] as Map)?.map( - (k, e) => MapEntry(k as String, e), - )) - ..messages = (json['messages'] as List) - ?.map((e) => e == null - ? null - : Message.fromJson((e as Map)?.map( - (k, e) => MapEntry(k as String, e), - ))) - ?.toList() - ..members = (json['members'] as List) - ?.map((e) => e == null - ? null - : Member.fromJson((e as Map)?.map( - (k, e) => MapEntry(k as String, e), - ))) - ?.toList() - ..watcherCount = json['watcher_count'] as int - ..read = (json['read'] as List) - ?.map((e) => e == null - ? null - : Read.fromJson((e as Map)?.map( - (k, e) => MapEntry(k as String, e), - ))) - ?.toList(); -} diff --git a/packages/stream_chat/lib/src/api/retry_policy.dart b/packages/stream_chat/lib/src/api/retry_policy.dart deleted file mode 100644 index a4d1e7bd..00000000 --- a/packages/stream_chat/lib/src/api/retry_policy.dart +++ /dev/null @@ -1,39 +0,0 @@ -import 'package:meta/meta.dart'; -import 'package:stream_chat/src/client.dart'; -import 'package:stream_chat/src/exceptions.dart'; - -/// The retry options -class RetryPolicy { - /// Instantiate a new RetryPolicy - RetryPolicy({ - @required this.shouldRetry, - @required this.retryTimeout, - this.attempt, - }); - - /// The number of attempts tried so far - int attempt = 0; - - /// This function evaluates if we should retry the failure - final bool Function(StreamChatClient client, int attempt, ApiError apiError) - shouldRetry; - - /// In the case that we want to retry a failed request the retryTimeout - /// method is called to determine the timeout - final Duration Function( - StreamChatClient client, int attempt, ApiError apiError) retryTimeout; - - /// Creates a copy of [RetryPolicy] with specified attributes overridden. - RetryPolicy copyWith({ - bool Function(StreamChatClient client, int attempt, ApiError apiError) - shouldRetry, - Duration Function(StreamChatClient client, int attempt, ApiError apiError) - retryTimeout, - int attempt, - }) => - RetryPolicy( - retryTimeout: retryTimeout ?? this.retryTimeout, - shouldRetry: shouldRetry ?? this.shouldRetry, - attempt: attempt ?? this.attempt, - ); -} diff --git a/packages/stream_chat/lib/src/api/retry_queue.dart b/packages/stream_chat/lib/src/api/retry_queue.dart deleted file mode 100644 index ac0e14d7..00000000 --- a/packages/stream_chat/lib/src/api/retry_queue.dart +++ /dev/null @@ -1,194 +0,0 @@ -import 'dart:async'; - -import 'package:collection/collection.dart'; -import 'package:logging/logging.dart'; -import 'package:meta/meta.dart'; -import 'package:stream_chat/src/api/channel.dart'; -import 'package:stream_chat/src/api/retry_policy.dart'; -import 'package:stream_chat/src/event_type.dart'; -import 'package:stream_chat/src/exceptions.dart'; -import 'package:stream_chat/src/models/message.dart'; -import 'package:stream_chat/stream_chat.dart'; - -/// The retry queue associated to a channel -class RetryQueue { - /// Instantiate a new RetryQueue object - RetryQueue({ - @required this.channel, - this.logger, - }) { - _retryPolicy = channel.client.retryPolicy; - - _listenConnectionRecovered(); - - _listenFailedEvents(); - } - - /// The channel of this queue - final Channel channel; - - /// The logger associated to this queue - final Logger logger; - - final _subscriptions = []; - - void _listenConnectionRecovered() { - _subscriptions - .add(channel.client.on(EventType.connectionRecovered).listen((event) { - if (!_isRetrying && event.online) { - _startRetrying(); - } - })); - } - - final HeapPriorityQueue _messageQueue = HeapPriorityQueue(_byDate); - bool _isRetrying = false; - RetryPolicy _retryPolicy; - - /// Add a list of messages - void add(List messages) { - logger?.info('added ${messages.length} messages'); - final messageList = _messageQueue.toList(); - _messageQueue.addAll(messages - .where((element) => !messageList.any((m) => m.id == element.id))); - - if (_messageQueue.isNotEmpty && !_isRetrying) { - _startRetrying(); - } - } - - Future _startRetrying() async { - logger?.info('start retrying'); - _isRetrying = true; - final retryPolicy = _retryPolicy.copyWith(attempt: 0); - - while (_messageQueue.isNotEmpty) { - final message = _messageQueue.first; - try { - logger?.info('retry attempt ${retryPolicy.attempt}'); - await _sendMessage(message); - logger?.info('message sent - removing it from the queue'); - _messageQueue.remove(message); - logger?.info('now ${_messageQueue.length} messages in the queue'); - retryPolicy.attempt = 0; - } catch (error) { - ApiError apiError; - if (error is DioError) { - if (error.type == DioErrorType.RESPONSE) { - _messageQueue.remove(message); - return; - } - apiError = ApiError( - error.response?.data, - error.response?.statusCode, - ); - } else if (error is ApiError) { - apiError = error; - if (apiError.status?.toString()?.startsWith('4') == true) { - _messageQueue.remove(message); - return; - } - } - - if (!retryPolicy.shouldRetry( - channel.client, - retryPolicy.attempt, - apiError, - )) { - _messageQueue.toList().forEach(_sendFailedEvent); - _isRetrying = false; - return; - } - - retryPolicy.attempt++; - final timeout = retryPolicy.retryTimeout( - channel.client, - retryPolicy.attempt, - apiError, - ); - await Future.delayed(timeout); - } - } - _isRetrying = false; - } - - void _sendFailedEvent(Message message) { - final newStatus = message.status == MessageSendingStatus.sending - ? MessageSendingStatus.failed - : (message.status == MessageSendingStatus.updating - ? MessageSendingStatus.failed_update - : MessageSendingStatus.failed_delete); - channel.state.addMessage(message.copyWith( - status: newStatus, - )); - } - - Future _sendMessage(Message message) async { - if (message.status == MessageSendingStatus.failed_update || - message.status == MessageSendingStatus.updating) { - await channel.updateMessage(message); - } else if (message.status == MessageSendingStatus.failed || - message.status == MessageSendingStatus.sending) { - await channel.sendMessage(message); - } else if (message.status == MessageSendingStatus.failed_delete || - message.status == MessageSendingStatus.deleting) { - await channel.deleteMessage(message); - } - } - - void _listenFailedEvents() { - _subscriptions.add(channel.on().listen((event) { - final messageList = _messageQueue.toList(); - if (event.message != null) { - final messageIndex = - messageList.indexWhere((m) => m.id == event.message.id); - if (messageIndex == -1 && - [ - MessageSendingStatus.failed_update, - MessageSendingStatus.failed, - MessageSendingStatus.failed_delete, - ].contains(event.message.status)) { - logger?.info('add message from events'); - add([event.message]); - } else if (messageIndex != -1 && - [ - MessageSendingStatus.sent, - null, - ].contains(event.message.status)) { - _messageQueue.remove(messageList[messageIndex]); - } - } - })); - } - - /// Call this method to dispose this object - void dispose() { - _messageQueue.clear(); - _subscriptions.forEach((s) => s.cancel()); - } - - static int _byDate(Message m1, Message m2) { - final date1 = _getMessageDate(m1); - final date2 = _getMessageDate(m2); - - return date1.compareTo(date2); - } - - static DateTime _getMessageDate(Message m1) { - switch (m1.status) { - case MessageSendingStatus.failed_delete: - case MessageSendingStatus.deleting: - return m1.deletedAt; - - case MessageSendingStatus.failed: - case MessageSendingStatus.sending: - return m1.createdAt; - - case MessageSendingStatus.failed_update: - case MessageSendingStatus.updating: - return m1.updatedAt; - default: - return null; - } - } -} diff --git a/packages/stream_chat/lib/src/api/web_socket_channel_html.dart b/packages/stream_chat/lib/src/api/web_socket_channel_html.dart deleted file mode 100644 index 9ddd83e5..00000000 --- a/packages/stream_chat/lib/src/api/web_socket_channel_html.dart +++ /dev/null @@ -1,7 +0,0 @@ -import 'package:web_socket_channel/html.dart'; -import 'package:web_socket_channel/web_socket_channel.dart'; - -/// Html version of websocket implementation -/// Used in Flutter web version -WebSocketChannel connectWebSocket(String url, {Iterable protocols}) => - HtmlWebSocketChannel.connect(url, protocols: protocols); diff --git a/packages/stream_chat/lib/src/api/web_socket_channel_io.dart b/packages/stream_chat/lib/src/api/web_socket_channel_io.dart deleted file mode 100644 index ed37ba7f..00000000 --- a/packages/stream_chat/lib/src/api/web_socket_channel_io.dart +++ /dev/null @@ -1,7 +0,0 @@ -import 'package:web_socket_channel/io.dart'; -import 'package:web_socket_channel/web_socket_channel.dart'; - -/// IO version of websocket implementation -/// Used in Flutter mobile version -WebSocketChannel connectWebSocket(String url, {Iterable protocols}) => - IOWebSocketChannel.connect(url, protocols: protocols); diff --git a/packages/stream_chat/lib/src/api/web_socket_channel_stub.dart b/packages/stream_chat/lib/src/api/web_socket_channel_stub.dart deleted file mode 100644 index 7e2e47bd..00000000 --- a/packages/stream_chat/lib/src/api/web_socket_channel_stub.dart +++ /dev/null @@ -1,9 +0,0 @@ -import 'package:web_socket_channel/web_socket_channel.dart'; - -/// Stub version of websocket implementation -/// Used just for conditional library import -WebSocketChannel connectWebSocket(String url, - {Iterable protocols, - Map headers, - Duration pingInterval}) => - throw UnimplementedError(); diff --git a/packages/stream_chat/lib/src/api/websocket.dart b/packages/stream_chat/lib/src/api/websocket.dart deleted file mode 100644 index 0f78b1bf..00000000 --- a/packages/stream_chat/lib/src/api/websocket.dart +++ /dev/null @@ -1,323 +0,0 @@ -import 'dart:async'; -import 'dart:convert'; -import 'dart:math'; - -import 'package:logging/logging.dart'; -import 'package:meta/meta.dart'; -import 'package:rxdart/rxdart.dart'; -import 'package:stream_chat/src/api/connection_status.dart'; -import 'package:stream_chat/src/models/event.dart'; -import 'package:stream_chat/src/models/user.dart'; -import 'package:web_socket_channel/web_socket_channel.dart'; - -/// Typedef which exposes an [Event] as the only parameter. -typedef EventHandler = void Function(Event); - -/// Typedef used for connecting to a websocket. Method returns a -/// [WebSocketChannel] and accepts a connection [url] and an optional -/// [Iterable] of `protocols`. -typedef ConnectWebSocket = WebSocketChannel Function(String url, - {Iterable protocols}); - -// TODO: parse error even -// TODO: if parsing an error into an event fails we should not hide the -// TODO: original error -/// A WebSocket connection that reconnects upon failure. -class WebSocket { - /// Creates a new websocket - /// To connect the WS call [connect] - WebSocket({ - @required this.baseUrl, - this.user, - this.connectParams, - this.connectPayload, - this.handler, - this.logger, - this.connectFunc, - this.reconnectionMonitorInterval = 1, - this.healthCheckInterval = 20, - this.reconnectionMonitorTimeout = 40, - }) { - final qs = Map.from(connectParams); - - final data = Map.from(connectPayload); - - data['user_details'] = user.toJson(); - qs['json'] = json.encode(data); - - if (baseUrl.startsWith('https')) { - _path = baseUrl.replaceFirst('https://', ''); - _path = Uri.https(_path, 'connect', qs) - .toString() - .replaceFirst('https', 'wss'); - } else if (baseUrl.startsWith('http')) { - _path = baseUrl.replaceFirst('http://', ''); - _path = - Uri.http(_path, 'connect', qs).toString().replaceFirst('http', 'ws'); - } else { - _path = Uri.https(baseUrl, 'connect', qs) - .toString() - .replaceFirst('https', 'wss'); - } - } - - /// WS base url - final String baseUrl; - - /// User performing the WS connection - final User user; - - /// Querystring connection parameters - final Map connectParams; - - /// WS connection payload - final Map connectPayload; - - /// Functions that will be called every time a new event is received from the - /// connection - final EventHandler handler; - - /// A WS specific logger instance - final Logger logger; - - /// Connection function - /// Used only for testing purpose - @visibleForTesting - final ConnectWebSocket connectFunc; - - /// Interval of the reconnection monitor timer - /// This checks that it received a new event in the last - /// [reconnectionMonitorTimeout] seconds, otherwise it considers the - /// connection unhealthy and reconnects the WS - final int reconnectionMonitorInterval; - - /// Interval of the health event sending timer - /// This sends a health event every [healthCheckInterval] seconds in order to - /// make the server aware that the client is still listening - final int healthCheckInterval; - - /// The timeout that uses the reconnection monitor timer to consider the - /// connection unhealthy - final int reconnectionMonitorTimeout; - - final _connectionStatusController = - BehaviorSubject.seeded(ConnectionStatus.disconnected); - - set _connectionStatus(ConnectionStatus status) => - _connectionStatusController.add(status); - - /// The current connection status value - ConnectionStatus get connectionStatus => _connectionStatusController.value; - - /// This notifies of connection status changes - Stream get connectionStatusStream => - _connectionStatusController.stream; - - String _path; - int _retryAttempt = 1; - WebSocketChannel _channel; - Timer _healthCheck, _reconnectionMonitor; - DateTime _lastEventAt; - bool _manuallyDisconnected = false; - bool _connecting = false; - bool _reconnecting = false; - - Event _decodeEvent(String source) => Event.fromJson(json.decode(source)); - - Completer _connectionCompleter = Completer(); - - /// Connect the WS using the parameters passed in the constructor - Future connect() { - _manuallyDisconnected = false; - - if (_connecting) { - logger.severe('already connecting'); - return null; - } - - _connecting = true; - _connectionStatus = ConnectionStatus.connecting; - - logger.info('connecting to $_path'); - - _channel = - connectFunc?.call(_path) ?? WebSocketChannel.connect(Uri.parse(_path)); - _channel.stream.listen( - (data) { - final jsonData = json.decode(data); - if (jsonData['error'] != null) { - return _onConnectionError(jsonData['error']); - } - _onData(data); - }, - onError: (error, stacktrace) { - _onConnectionError(error, stacktrace); - }, - onDone: () { - _onDone(); - }, - ); - return _connectionCompleter.future; - } - - void _onDone() { - _connecting = false; - if (_manuallyDisconnected) { - return; - } - - logger.info('connection closed | closeCode: ${_channel.closeCode} | ' - 'closedReason: ${_channel.closeReason}'); - - if (!_reconnecting) { - _reconnect(); - } - } - - void _onData(data) { - if (_manuallyDisconnected) { - return; - } - - final event = _decodeEvent(data); - logger.info('received new event: $data'); - - if (_lastEventAt == null) { - logger.info('connection estabilished'); - _connecting = false; - _reconnecting = false; - _lastEventAt = DateTime.now(); - - _connectionStatus = ConnectionStatus.connected; - _retryAttempt = 1; - - if (!_connectionCompleter.isCompleted) { - _connectionCompleter.complete(event); - } - - _startReconnectionMonitor(); - _startHealthCheck(); - } - - handler(event); - _lastEventAt = DateTime.now(); - } - - Future _onConnectionError(error, [stacktrace]) async { - logger..severe('error connecting')..severe(error); - if (stacktrace != null) { - logger.severe(stacktrace); - } - _connecting = false; - - if (!_reconnecting) { - _connectionStatus = ConnectionStatus.disconnected; - } - - if (!_connectionCompleter.isCompleted) { - _cancelTimers(); - _connectionCompleter.completeError(error, stacktrace); - } else if (!_reconnecting) { - return _reconnect(); - } - } - - void _reconnectionTimer(_) { - final now = DateTime.now(); - if (_lastEventAt != null && - now.difference(_lastEventAt).inSeconds > reconnectionMonitorTimeout) { - _channel.sink.close(); - } - } - - void _startReconnectionMonitor() { - _reconnectionMonitor = Timer.periodic( - Duration(seconds: reconnectionMonitorInterval), - _reconnectionTimer, - ); - - _reconnectionTimer(_reconnectionMonitor); - } - - void _reconnectTimer() async { - if (!_reconnecting) { - return; - } - if (_connecting) { - logger.info('already connecting'); - return; - } - - logger.info('reconnecting..'); - - _cancelTimers(); - - try { - await connect(); - } catch (e) { - logger.log(Level.SEVERE, e.toString()); - } - await Future.delayed( - Duration(seconds: min(_retryAttempt * 5, 25)), - () { - _reconnectTimer(); - _retryAttempt++; - }, - ); - } - - Future _reconnect() async { - logger.info('reconnect'); - if (!_reconnecting) { - _reconnecting = true; - _connectionStatus = ConnectionStatus.connecting; - } - - _reconnectTimer(); - } - - void _cancelTimers() { - _lastEventAt = null; - if (_healthCheck != null) { - _healthCheck.cancel(); - } - if (_reconnectionMonitor != null) { - _reconnectionMonitor.cancel(); - } - } - - void _healthCheckTimer(_) { - logger.info('sending health.check'); - _channel.sink.add("{'type': 'health.check'}"); - } - - void _startHealthCheck() { - logger.info('start health check monitor'); - - _healthCheck = Timer.periodic( - Duration(seconds: healthCheckInterval), - _healthCheckTimer, - ); - - _healthCheckTimer(_healthCheck); - } - - /// Disconnects the WS and releases eventual resources - Future disconnect() async { - _connecting = false; - if (!_connectionCompleter.isCompleted) { - _connectionCompleter.complete(); - } - if (_manuallyDisconnected) { - return; - } - logger.info('disconnecting'); - _connectionCompleter = Completer(); - _cancelTimers(); - _reconnecting = false; - _manuallyDisconnected = true; - _connectionStatus = ConnectionStatus.disconnected; - await _connectionStatusController.close(); - return _channel.sink.close(); - } -} diff --git a/packages/stream_chat/lib/src/client.dart b/packages/stream_chat/lib/src/client.dart deleted file mode 100644 index fc6e122a..00000000 --- a/packages/stream_chat/lib/src/client.dart +++ /dev/null @@ -1,1516 +0,0 @@ -// ignore_for_file: unnecessary_getters_setters - -import 'dart:async'; -import 'dart:convert'; - -import 'package:dio/dio.dart'; -import 'package:logging/logging.dart'; -import 'package:meta/meta.dart'; -import 'package:rxdart/rxdart.dart'; -import 'package:stream_chat/src/api/channel.dart'; -import 'package:stream_chat/src/api/connection_status.dart'; -import 'package:stream_chat/src/api/requests.dart'; -import 'package:stream_chat/src/api/responses.dart'; -import 'package:stream_chat/src/api/retry_policy.dart'; -import 'package:stream_chat/src/api/websocket.dart'; -import 'package:stream_chat/src/attachment_file_uploader.dart'; -import 'package:stream_chat/src/db/chat_persistence_client.dart'; -import 'package:stream_chat/src/event_type.dart'; -import 'package:stream_chat/src/exceptions.dart'; -import 'package:stream_chat/src/extensions/map_extension.dart'; -import 'package:stream_chat/src/models/attachment_file.dart'; -import 'package:stream_chat/src/models/channel_model.dart'; -import 'package:stream_chat/src/models/channel_state.dart'; -import 'package:stream_chat/src/models/event.dart'; -import 'package:stream_chat/src/models/message.dart'; -import 'package:stream_chat/src/models/own_user.dart'; -import 'package:stream_chat/src/models/user.dart'; -import 'package:stream_chat/src/platform_detector/platform_detector.dart'; -import 'package:stream_chat/version.dart'; -import 'package:uuid/uuid.dart'; - -/// Handler function used for logging records. Function requires a single -/// [LogRecord] as the only parameter. -typedef LogHandlerFunction = void Function(LogRecord record); - -/// Used for decoding [Map] data to a generic type `T`. -typedef DecoderFunction = T Function(Map); - -/// A function which can be used to request a Stream Chat API token from your -/// own backend server. Function requires a single [userId]. -typedef TokenProvider = Future Function(String userId); - -/// Provider used to send push notifications. -enum PushProvider { - /// Send notifications using Google's Firebase Cloud Messaging - firebase, - - /// Send notifications using Apple's Push Notification service - apn -} - -extension on PushProvider { - /// Returns the string notion for [PushProvider]. - String get name { - if (this == PushProvider.apn) { - return 'apn'; - } else { - return 'firebase'; - } - } -} - -/// The official Dart client for Stream Chat, -/// a service for building chat applications. -/// This library can be used on any Dart project and on both mobile and web apps -/// with Flutter. -/// -/// You can sign up for a Stream account at https://getstream.io/chat/ -/// -/// The Chat client will manage API call, event handling and manage the -/// websocket connection to Stream Chat servers. -/// -/// ```dart -/// final client = StreamChatClient("stream-chat-api-key"); -/// ``` -class StreamChatClient { - /// Create a client instance with default options. - /// You should only create the client once and re-use it across your - /// application. - StreamChatClient( - this.apiKey, { - this.tokenProvider, - this.baseURL = _defaultBaseURL, - this.logLevel = Level.WARNING, - this.logHandlerFunction, - Duration connectTimeout = const Duration(seconds: 6), - Duration receiveTimeout = const Duration(seconds: 6), - Dio httpClient, - RetryPolicy retryPolicy, - this.attachmentFileUploader, - }) { - _retryPolicy = retryPolicy ?? - RetryPolicy( - retryTimeout: - (StreamChatClient client, int attempt, ApiError error) => - Duration(seconds: 1 * attempt), - shouldRetry: (StreamChatClient client, int attempt, ApiError error) => - attempt < 5, - ); - - attachmentFileUploader ??= StreamAttachmentFileUploader(this); - - state = ClientState(this); - - _setupLogger(); - _setupDio(httpClient, receiveTimeout, connectTimeout); - - logger.info('instantiating new client'); - } - - set chatPersistenceClient(ChatPersistenceClient value) { - _originalChatPersistenceClient = value; - } - - ChatPersistenceClient _originalChatPersistenceClient; - - /// Chat persistence client - ChatPersistenceClient get chatPersistenceClient => _chatPersistenceClient; - - ChatPersistenceClient _chatPersistenceClient; - - /// Attachment uploader - AttachmentFileUploader attachmentFileUploader; - - /// Whether the chat persistence is available or not - bool get persistenceEnabled => _chatPersistenceClient != null; - - RetryPolicy _retryPolicy; - - bool _synced = false; - - /// The retry policy options getter - RetryPolicy get retryPolicy => _retryPolicy; - - /// This client state - ClientState state; - - /// By default the Chat client will write all messages with level Warn or - /// Error to stdout. - /// - /// During development you might want to enable more logging information, - /// you can change the default log level when constructing the client. - /// - /// ```dart - /// final client = StreamChatClient("stream-chat-api-key", - /// logLevel: Level.INFO); - /// ``` - final Level logLevel; - - /// Client specific logger instance. - /// Refer to the class [Logger] to learn more about the specific - /// implementation. - final Logger logger = Logger.detached('📡'); - - /// A function that has a parameter of type [LogRecord]. - /// This is called on every new log record. - /// By default the client will use the handler returned by - /// [_getDefaultLogHandler]. - /// Setting it you can handle the log messages directly instead of have them - /// written to stdout, - /// this is very convenient if you use an error tracking tool or if you want - /// to centralize your logs into one facility. - /// - /// ```dart - /// myLogHandlerFunction = (LogRecord record) { - /// // do something with the record (ie. send it to Sentry or Fabric) - /// } - /// - /// final client = StreamChatClient("stream-chat-api-key", - /// logHandlerFunction: myLogHandlerFunction); - ///``` - LogHandlerFunction logHandlerFunction; - - /// Your project Stream Chat api key. - /// Find your API keys here https://getstream.io/dashboard/ - String apiKey; - - /// Your project Stream Chat base url. - final String baseURL; - - /// A function in which you send a request to your own backend to get a Stream - /// Chat API token. - /// - /// The token will be the return value of the function. - /// It's used by the client to refresh the token once expired or to connect - /// the user without a predefined token using [connectUserWithProvider]. - final TokenProvider tokenProvider; - - /// [Dio] httpClient - /// It's be chosen because it's easy to use and supports interesting features - /// out of the box (Interceptors, Global configuration, FormData, - /// File downloading etc.) - @visibleForTesting - Dio httpClient = Dio(); - - static const _defaultBaseURL = 'chat-us-east-1.stream-io-api.com'; - static const _tokenExpiredErrorCode = 40; - StreamSubscription _connectionStatusSubscription; - Future Function(ConnectionStatus) _connectionStatusHandler; - - final BehaviorSubject _controller = BehaviorSubject(); - - /// Stream of [Event] coming from websocket connection - /// Listen to this or use the [on] method to filter specific event types - Stream get stream => _controller.stream; - - final _wsConnectionStatusController = - BehaviorSubject.seeded(ConnectionStatus.disconnected); - - set _wsConnectionStatus(ConnectionStatus status) => - _wsConnectionStatusController.add(status); - - /// The current status value of the websocket connection - ConnectionStatus get wsConnectionStatus => - _wsConnectionStatusController.value; - - /// This notifies the connection status of the websocket connection. - /// Listen to this to get notified when the websocket tries to reconnect. - Stream get wsConnectionStatusStream => - _wsConnectionStatusController.stream; - - /// The current user token - String token; - - /// The id of the current websocket connection - String get connectionId => _connectionId; - - bool _anonymous = false; - String _connectionId; - WebSocket _ws; - - bool get _hasConnectionId => _connectionId != null; - - void _setupDio( - Dio httpClient, - Duration receiveTimeout, - Duration connectTimeout, - ) { - logger.info('http client setup'); - - this.httpClient = httpClient ?? Dio(); - - String url; - if (!baseURL.startsWith('https') && !baseURL.startsWith('http')) { - url = Uri.https(baseURL, '').toString(); - } else { - url = baseURL; - } - - this.httpClient.options.baseUrl = url; - this.httpClient.options.receiveTimeout = receiveTimeout.inMilliseconds; - this.httpClient.options.connectTimeout = connectTimeout.inMilliseconds; - this.httpClient.interceptors.add( - InterceptorsWrapper( - onRequest: (options) async { - options.queryParameters.addAll(_commonQueryParams); - options.headers.addAll(_httpHeaders); - - if (_connectionId != null && - (options.data is Map || options.data == null)) { - options.data = { - 'connection_id': _connectionId, - ...options.data ?? {}, - }; - } - - var stringData = options.data.toString(); - - if (options.data is FormData) { - final multiPart = (options.data as FormData).files[0]?.value; - stringData = - '${multiPart?.filename} - ${multiPart?.contentType}'; - } - - logger.info(''' - - method: ${options.method} - url: ${options.uri} - headers: ${options.headers} - data: $stringData - - '''); - - return options; - }, - onError: _tokenExpiredInterceptor, - ), - ); - } - - Future _tokenExpiredInterceptor(DioError err) async { - final apiError = ApiError( - err.response?.data, - err.response?.statusCode, - ); - - if (apiError.code == _tokenExpiredErrorCode) { - logger.info('token expired'); - - if (tokenProvider != null) { - httpClient.lock(); - final userId = state.user.id; - - await _disconnect(); - - final newToken = await tokenProvider(userId); - await Future.delayed(const Duration(seconds: 4)); - token = newToken; - - httpClient.unlock(); - - await connectUser(User(id: userId), newToken); - - try { - return await httpClient.request( - err.request.path, - cancelToken: err.request.cancelToken, - data: err.request.data, - onReceiveProgress: err.request.onReceiveProgress, - onSendProgress: err.request.onSendProgress, - queryParameters: err.request.queryParameters, - options: err.request, - ); - } catch (err) { - return err; - } - } - } - - return err; - } - - LogHandlerFunction _getDefaultLogHandler() { - final levelEmojiMapper = { - Level.INFO.name: 'ℹ️', - Level.WARNING.name: '⚠️', - Level.SEVERE.name: '🚨', - }; - return (LogRecord record) { - print( - '(${record.time}) ' - '${levelEmojiMapper[record.level.name] ?? record.level.name} ' - '${record.loggerName} ${record.message}', - ); - if (record.stackTrace != null) { - print(record.stackTrace); - } - }; - } - - Logger _detachedLogger( - String name, - ) => - Logger.detached(name) - ..level = logLevel - ..onRecord.listen(logHandlerFunction ?? _getDefaultLogHandler()); - - void _setupLogger() { - logger.level = logLevel; - - logHandlerFunction ??= _getDefaultLogHandler(); - - logger.onRecord.listen(logHandlerFunction); - - logger.info('logger setup'); - } - - /// Call this function to dispose the client - void dispose() async { - await _chatPersistenceClient?.disconnect(); - await _disconnect(); - httpClient.close(); - await _controller.close(); - state.dispose(); - await _wsConnectionStatusController.close(); - } - - Map get _httpHeaders => { - 'Authorization': token, - 'stream-auth-type': _authType, - 'X-Stream-Client': _userAgent, - 'Content-Encoding': 'gzip', - }; - - /// Set the current user, this triggers a connection to the API. - /// It returns a [Future] that resolves when the connection is setup. - @Deprecated('Use `connectUser` instead. Will be removed in Future releases') - Future setUser(User user, String token) => connectUser(user, token); - - /// Connects the current user, this triggers a connection to the API. - /// It returns a [Future] that resolves when the connection is setup. - Future connectUser(User user, String token) async { - if (_connectCompleter != null && !_connectCompleter.isCompleted) { - logger.warning('Already connecting'); - throw Exception('Already connecting'); - } - - _connectCompleter = Completer(); - - logger.info('connect user'); - state.user = OwnUser.fromJson(user.toJson()); - this.token = token; - _anonymous = false; - - return connect().then((event) { - _connectCompleter.complete(event); - return event; - }).catchError((e, s) { - _connectCompleter.completeError(e, s); - throw e; - }); - } - - /// Set the current user using the [tokenProvider] to fetch the token. - /// It returns a [Future] that resolves when the connection is setup. - @Deprecated( - 'Use `connectUserWithProvider` instead. Will be removed in Future releases', - ) - Future setUserWithProvider(User user) => connectUserWithProvider(user); - - /// Connects the current user using the [tokenProvider] to fetch the token. - /// It returns a [Future] that resolves when the connection is setup. - Future connectUserWithProvider(User user) async { - if (tokenProvider == null) { - throw Exception(''' - TokenProvider must be provided in the constructor in order to use `connectUserWithProvider` method. - Use `connectUser` providing a token. - '''); - } - final token = await tokenProvider(user.id); - return connectUser(user, token); - } - - /// Stream of [Event] coming from websocket connection - /// Pass an eventType as parameter in order to filter just a type of event - Stream on([ - String eventType, - String eventType2, - String eventType3, - String eventType4, - ]) => - stream.where((event) => - eventType == null || - (event.type != null && - (event.type == eventType || - event.type == eventType2 || - event.type == eventType3 || - event.type == eventType4))); - - /// Method called to add a new event to the [_controller]. - void handleEvent(Event event) async { - logger.info('handle new event: ${event.toJson()}'); - if (event.connectionId != null) { - _connectionId = event.connectionId; - } - - if (!event.isLocal) { - if (_synced && event.createdAt != null) { - await _chatPersistenceClient?.updateConnectionInfo(event); - await _chatPersistenceClient?.updateLastSyncAt(event.createdAt); - } - } - - if (event.user != null) { - state._updateUser(event.user); - } - - if (event.me != null) { - state.user = event.me; - } - _controller.add(event); - } - - Completer _connectCompleter; - - /// Connect the client websocket - Future connect() async { - logger.info('connecting'); - if (wsConnectionStatus == ConnectionStatus.connecting) { - logger.warning('Already connecting'); - throw Exception('Already connecting'); - } - - if (wsConnectionStatus == ConnectionStatus.connected) { - logger.warning('Already connected'); - throw Exception('Already connected'); - } - - _wsConnectionStatus = ConnectionStatus.connecting; - - if (_originalChatPersistenceClient != null) { - _chatPersistenceClient = _originalChatPersistenceClient; - await _chatPersistenceClient.connect(state.user.id); - } - - _ws = WebSocket( - baseUrl: baseURL, - user: state.user, - connectParams: { - 'api_key': apiKey, - 'authorization': token, - 'stream-auth-type': _authType, - 'X-Stream-Client': _userAgent, - }, - connectPayload: { - 'user_id': state.user.id, - 'server_determines_connection_id': true, - }, - handler: handleEvent, - logger: _detachedLogger('🔌'), - ); - - _connectionStatusHandler = (ConnectionStatus status) async { - _wsConnectionStatus = status; - handleEvent( - Event( - type: EventType.connectionChanged, - online: status == ConnectionStatus.connected, - ), - ); - - if (status == ConnectionStatus.connected) { - handleEvent(Event( - type: EventType.connectionRecovered, - online: true, - )); - if (state.channels?.isNotEmpty == true) { - // ignore: unawaited_futures - queryChannelsOnline(filter: { - 'cid': { - '\$in': state.channels.keys.toList(), - }, - }).then( - (_) async { - await resync(); - }, - ); - } else { - _synced = false; - } - } - }; - - _connectionStatusSubscription = - _ws.connectionStatusStream.listen(_connectionStatusHandler); - - var event = await _chatPersistenceClient?.getConnectionInfo(); - - await _ws.connect().then((e) { - _chatPersistenceClient?.updateConnectionInfo(e); - event = e; - resync(); - }).catchError((err, stacktrace) { - logger.severe('error connecting ws', err, stacktrace); - if (err is Map) { - // ignore: only_throw_errors - throw err; - } - }); - - return event; - } - - /// Get the events missed while offline to sync the offline storage - Future resync([List cids]) async { - final lastSyncAt = await _chatPersistenceClient?.getLastSyncAt(); - - if (lastSyncAt == null) { - _synced = true; - return; - } - - cids ??= await _chatPersistenceClient?.getChannelCids(); - - if (cids?.isEmpty == true) { - return; - } - - try { - final rawRes = await post('/sync', data: { - 'channel_cids': cids, - 'last_sync_at': lastSyncAt.toUtc().toIso8601String(), - }); - logger.fine('rawRes: $rawRes'); - - final res = decode( - rawRes.data, - SyncResponse.fromJson, - ); - - res.events.sort((a, b) => a.createdAt.compareTo(b.createdAt)); - - res.events.forEach((element) { - logger - ..fine('element.type: ${element.type}') - ..fine('element.message.text: ${element.message?.text}'); - }); - - res.events.forEach(handleEvent); - - await _chatPersistenceClient?.updateLastSyncAt(DateTime.now()); - _synced = true; - } catch (error) { - logger.severe('Error during resync $error'); - } - } - - String _asMap(sort) => sort?.map((s) => s.toJson().toString())?.join(''); - - final _queryChannelsStreams = >>{}; - - /// Requests channels with a given query. - Stream> queryChannels({ - Map filter, - List> sort, - Map options, - PaginationParams paginationParams = const PaginationParams(), - int messageLimit, - bool waitForConnect = true, - }) async* { - final hash = base64.encode(utf8.encode( - '$filter${_asMap(sort)}$options${paginationParams?.toJson()}' - '$messageLimit', - )); - - if (_queryChannelsStreams.containsKey(hash)) { - yield await _queryChannelsStreams[hash]; - } else { - final channels = await queryChannelsOffline( - filter: filter, - sort: sort, - paginationParams: paginationParams, - ); - if (channels.isNotEmpty) yield channels; - - try { - final newQueryChannelsFuture = queryChannelsOnline( - filter: filter, - sort: sort, - options: options, - paginationParams: paginationParams, - messageLimit: messageLimit, - waitForConnect: waitForConnect, - ).whenComplete(() { - _queryChannelsStreams.remove(hash); - }); - - _queryChannelsStreams[hash] = newQueryChannelsFuture; - - yield await newQueryChannelsFuture; - } catch (_) { - if (channels.isEmpty) rethrow; - } - } - } - - /// Requests channels with a given query from the API. - Future> queryChannelsOnline({ - @required Map filter, - List> sort, - Map options, - int messageLimit, - PaginationParams paginationParams = const PaginationParams(), - bool waitForConnect = true, - }) async { - if (waitForConnect) { - if (_connectCompleter != null && !_connectCompleter.isCompleted) { - logger.info('awaiting connection completer'); - await _connectCompleter.future; - } - if (wsConnectionStatus != ConnectionStatus.connected) { - throw Exception( - 'You cannot use queryChannels without an active connection.' - ' Please call `connectUser` to connect the client.', - ); - } - } - - logger.info('Query channel start'); - final defaultOptions = { - 'state': true, - 'watch': true, - 'presence': false, - }; - - final payload = { - 'filter_conditions': filter, - 'sort': sort, - }; - - if (messageLimit != null) { - payload['message_limit'] = messageLimit; - } - - payload.addAll(defaultOptions); - - if (options != null) { - payload.addAll(options); - } - - if (paginationParams != null) { - payload.addAll(paginationParams.toJson()); - } - - final response = await get( - '/channels', - queryParameters: { - 'payload': jsonEncode(payload), - }, - ); - - final res = decode( - response.data, - QueryChannelsResponse.fromJson, - ); - - if ((res.channels ?? []).isEmpty && (paginationParams?.offset ?? 0) == 0) { - logger.warning( - ''' - We could not find any channel for this query. - Please make sure to take a look at the Flutter tutorial: https://getstream.io/chat/flutter/tutorial - If your application already has users and channels, you might need to adjust your query channel as explained in the docs https://getstream.io/chat/docs/query_channels/?language=dart''', - ); - return []; - } - - final channels = res.channels; - - final users = channels - .expand((it) => it.members) - .map((it) => it.user) - .toList(growable: false); - - state._updateUsers(users); - - logger.info('Got ${res.channels?.length} channels from api'); - - final updateData = _mapChannelStateToChannel(channels); - - await _chatPersistenceClient?.updateChannelQueries( - filter, - channels.map((c) => c.channel.cid).toList(), - clearQueryCache: - paginationParams?.offset == null || paginationParams.offset == 0, - ); - - state.channels = updateData.key; - return updateData.value; - } - - /// Requests channels with a given query from the Persistence client. - Future> queryChannelsOffline({ - @required Map filter, - @required List> sort, - PaginationParams paginationParams = const PaginationParams(), - }) async { - final offlineChannels = await _chatPersistenceClient?.getChannelStates( - filter: filter, - sort: sort, - paginationParams: paginationParams, - ); - final updatedData = _mapChannelStateToChannel(offlineChannels); - state.channels = updatedData.key; - return updatedData.value; - } - - MapEntry, List> _mapChannelStateToChannel( - List channelStates, - ) { - final channels = {...state.channels ?? {}}; - final newChannels = []; - if (channelStates != null) { - for (final channelState in channelStates) { - final channel = channels[channelState.channel.cid]; - if (channel != null) { - channel.state?.updateChannelState(channelState); - newChannels.add(channel); - } else { - final newChannel = Channel.fromState(this, channelState); - channels[newChannel.cid] = newChannel; - newChannels.add(newChannel); - } - } - } - return MapEntry(channels, newChannels); - } - - Object _parseError(DioError error) { - if (error.type == DioErrorType.RESPONSE) { - final apiError = - ApiError(error.response?.data, error.response?.statusCode); - logger.severe('apiError: ${apiError.toString()}'); - return apiError; - } - - return error; - } - - /// Handy method to make http GET request with error parsing. - Future> get( - String path, { - Map queryParameters, - }) async { - try { - final response = await httpClient.get( - path, - queryParameters: queryParameters, - ); - return response; - } on DioError catch (error) { - // ignore: only_throw_errors - throw _parseError(error); - } - } - - /// Handy method to make http POST request with error parsing. - Future> post( - String path, { - dynamic data, - ProgressCallback onSendProgress, - CancelToken cancelToken, - }) async { - try { - final response = await httpClient.post( - path, - data: data, - onSendProgress: onSendProgress, - cancelToken: cancelToken, - ); - return response; - } on DioError catch (error) { - // ignore: only_throw_errors - throw _parseError(error); - } - } - - /// Handy method to make http DELETE request with error parsing. - Future> delete( - String path, { - Map queryParameters, - CancelToken cancelToken, - }) async { - try { - final response = await httpClient.delete( - path, - queryParameters: queryParameters, - cancelToken: cancelToken, - ); - return response; - } on DioError catch (error) { - // ignore: only_throw_errors - throw _parseError(error); - } - } - - /// Handy method to make http PATCH request with error parsing. - Future> patch( - String path, { - Map queryParameters, - dynamic data, - }) async { - try { - final response = await httpClient.patch( - path, - queryParameters: queryParameters, - data: data, - ); - return response; - } on DioError catch (error) { - // ignore: only_throw_errors - throw _parseError(error); - } - } - - /// Handy method to make http PUT request with error parsing. - Future> put( - String path, { - Map queryParameters, - dynamic data, - }) async { - try { - final response = await httpClient.put( - path, - queryParameters: queryParameters, - data: data, - ); - return response; - } on DioError catch (error) { - // ignore: only_throw_errors - throw _parseError(error); - } - } - - /// Used to log errors and stacktrace in case of bad json deserialization - T decode(String j, DecoderFunction decoderFunction) { - try { - if (j == null) { - return null; - } - return decoderFunction(json.decode(j)); - } catch (error, stacktrace) { - logger.severe('Error decoding response', error, stacktrace); - rethrow; - } - } - - String get _authType => _anonymous ? 'anonymous' : 'jwt'; - - String get _userAgent => 'stream-chat-dart-client-${CurrentPlatform.name}-' - '${PACKAGE_VERSION.split('+')[0]}'; - - Map get _commonQueryParams => { - 'user_id': state.user?.id, - 'api_key': apiKey, - 'connection_id': _connectionId, - }; - - /// Set the current user with an anonymous id, this triggers a connection to - /// the API. It returns a [Future] that resolves when the connection is setup. - @Deprecated( - 'Use `connectAnonymousUser` instead. Will be removed in Future releases') - Future setAnonymousUser() => connectAnonymousUser(); - - /// Connects the current user with an anonymous id, this triggers a connection - /// to the API. It returns a [Future] that resolves when the connection is - /// setup. - Future connectAnonymousUser() async { - if (_connectCompleter != null && !_connectCompleter.isCompleted) { - logger.warning('Already connecting'); - throw Exception('Already connecting'); - } - - _connectCompleter = Completer(); - - _anonymous = true; - final uuid = Uuid(); - state.user = OwnUser(id: uuid.v4()); - - return connect().then((event) { - _connectCompleter.complete(event); - return event; - }).catchError((e, s) { - _connectCompleter.completeError(e, s); - throw e; - }); - } - - /// Set the current user as guest, this triggers a connection to the API. - /// It returns a [Future] that resolves when the connection is setup. - @Deprecated( - 'Use `connectGuestUser` instead. Will be removed in Future releases') - Future setGuestUser(User user) => connectGuestUser(user); - - /// Connects the current user as guest, this triggers a connection to the API. - /// It returns a [Future] that resolves when the connection is setup. - Future connectGuestUser(User user) async { - _anonymous = true; - final response = await post('/guest', data: {'user': user.toJson()}) - .then((res) => decode( - res.data, ConnectGuestUserResponse.fromJson)) - .whenComplete(() => _anonymous = false); - return connectUser( - response.user, - response.accessToken, - ); - } - - /// Closes the websocket connection and resets the client - /// If [flushChatPersistence] is true the client deletes all offline - /// user's data. If [clearUser] is true the client unsets the current user - Future disconnect({ - bool flushChatPersistence = false, - bool clearUser = false, - }) async { - logger.info('Disconnecting flushOfflineStorage: $flushChatPersistence; ' - 'clearUser: $clearUser'); - - await _chatPersistenceClient?.disconnect(flush: flushChatPersistence); - _chatPersistenceClient = null; - - _connectCompleter = null; - - if (clearUser == true) { - state.dispose(); - state = ClientState(this); - } - - await _disconnect(); - } - - Future _disconnect() async { - logger.info('Client disconnecting'); - - await _ws?.disconnect(); - await _connectionStatusSubscription?.cancel(); - } - - /// Requests users with a given query. - Future queryUsers({ - Map filter, - List sort, - Map options, - PaginationParams pagination, - }) async { - final defaultOptions = { - 'presence': _hasConnectionId, - }; - - final payload = { - 'filter_conditions': filter ?? {}, - 'sort': sort, - }..addAll(defaultOptions); - - if (pagination != null) { - payload.addAll(pagination.toJson()); - } - - if (options != null) { - payload.addAll(options); - } - - final rawRes = await get( - '/users', - queryParameters: { - 'payload': jsonEncode(payload), - }, - ); - - final response = decode( - rawRes.data, - QueryUsersResponse.fromJson, - ); - - state?._updateUsers(response.users); - - return response; - } - - /// A message search. - Future search( - Map filters, { - String query, - List sort, - PaginationParams paginationParams, - Map messageFilters, - }) async { - assert(() { - if (filters == null || filters.isEmpty) { - throw ArgumentError('`filters` cannot be set as null or empty'); - } - if (query == null && messageFilters == null) { - throw ArgumentError('Provide at least `query` or `messageFilters`'); - } - if (query != null && messageFilters != null) { - throw ArgumentError( - "Can't provide both `query` and `messageFilters` at the same time", - ); - } - return true; - }(), 'Check incoming params.'); - - final payload = { - 'filter_conditions': filters, - 'message_filter_conditions': messageFilters, - 'query': query, - 'sort': sort, - if (paginationParams != null) ...paginationParams.toJson(), - }.nullProtected; - - final response = await get('/search', queryParameters: { - 'payload': json.encode(payload), - }); - - return decode( - response.data, SearchMessagesResponse.fromJson); - } - - /// Send a [file] to the [channelId] of type [channelType] - Future sendFile( - AttachmentFile file, - String channelId, - String channelType, { - ProgressCallback onSendProgress, - CancelToken cancelToken, - }) => - attachmentFileUploader.sendFile( - file, - channelId, - channelType, - onSendProgress: onSendProgress, - cancelToken: cancelToken, - ); - - /// Send a [image] to the [channelId] of type [channelType] - Future sendImage( - AttachmentFile image, - String channelId, - String channelType, { - ProgressCallback onSendProgress, - CancelToken cancelToken, - }) => - attachmentFileUploader.sendImage( - image, - channelId, - channelType, - onSendProgress: onSendProgress, - cancelToken: cancelToken, - ); - - /// Delete a file from this channel - Future deleteFile( - String url, - String channelId, - String channelType, { - CancelToken cancelToken, - }) => - attachmentFileUploader.deleteFile( - url, - channelId, - channelType, - cancelToken: cancelToken, - ); - - /// Delete an image from this channel - Future deleteImage( - String url, - String channelId, - String channelType, { - CancelToken cancelToken, - }) => - attachmentFileUploader.deleteImage( - url, - channelId, - channelType, - cancelToken: cancelToken, - ); - - /// Add a device for Push Notifications. - Future addDevice(String id, PushProvider pushProvider) async { - final response = await post('/devices', data: { - 'id': id, - 'push_provider': pushProvider.name, - }); - return decode(response.data, EmptyResponse.fromJson); - } - - /// Gets a list of user devices. - Future getDevices() async { - final response = await get('/devices'); - return decode( - response.data, ListDevicesResponse.fromJson); - } - - /// Remove a user's device. - Future removeDevice(String id) async { - final response = await delete('/devices', queryParameters: { - 'id': id, - }); - return decode(response.data, EmptyResponse.fromJson); - } - - /// Get a development token - String devToken(String userId) { - final payload = json.encode({'user_id': userId}); - final payloadBytes = utf8.encode(payload); - final payloadB64 = base64.encode(payloadBytes); - return 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.$payloadB64.devtoken'; - } - - /// Returns a channel client with the given type, id and custom data. - Channel channel( - String type, { - String id, - Map extraData, - }) { - if (type != null && - id != null && - state.channels?.containsKey('$type:$id') == true) { - return state.channels['$type:$id']; - } - - return Channel(this, type, id, extraData); - } - - /// Update or Create the given user object. - Future updateUser(User user) async => - updateUsers([user]); - - /// Batch update a list of users - Future updateUsers(List users) async { - final response = await post('/users', data: { - 'users': users.asMap().map((_, u) => MapEntry(u.id, u.toJson())), - }); - return decode( - response.data, - UpdateUsersResponse.fromJson, - ); - } - - /// Bans a user from all channels - Future banUser( - String targetUserID, [ - Map options = const {}, - ]) async { - final data = Map.from(options) - ..addAll({ - 'target_user_id': targetUserID, - }); - final response = await post( - '/moderation/ban', - data: data, - ); - return decode(response.data, EmptyResponse.fromJson); - } - - /// Remove global ban for a user - Future unbanUser( - String targetUserID, [ - Map options = const {}, - ]) async { - final data = Map.from(options) - ..addAll({ - 'target_user_id': targetUserID, - }); - final response = await delete( - '/moderation/ban', - queryParameters: data, - ); - return decode(response.data, EmptyResponse.fromJson); - } - - /// Shadow bans a user - Future shadowBan( - String targetID, [ - Map options = const {}, - ]) async => - banUser(targetID, { - 'shadow': true, - ...options, - }); - - /// Removes shadow ban from a user - Future removeShadowBan( - String targetID, [ - Map options = const {}, - ]) async => - unbanUser(targetID, { - 'shadow': true, - ...options, - }); - - /// Mutes a user - Future muteUser(String targetID) async { - final response = await post('/moderation/mute', data: { - 'target_id': targetID, - }); - return decode(response.data, EmptyResponse.fromJson); - } - - /// Unmutes a user - Future unmuteUser(String targetID) async { - final response = await post('/moderation/unmute', data: { - 'target_id': targetID, - }); - return decode(response.data, EmptyResponse.fromJson); - } - - /// Flag a message - Future flagMessage(String messageID) async { - final response = await post('/moderation/flag', data: { - 'target_message_id': messageID, - }); - return decode(response.data, EmptyResponse.fromJson); - } - - /// Unflag a message - Future unflagMessage(String messageId) async { - final response = await post('/moderation/unflag', data: { - 'target_message_id': messageId, - }); - return decode(response.data, EmptyResponse.fromJson); - } - - /// Flag a user - Future flagUser(String userId) async { - final response = await post('/moderation/flag', data: { - 'target_user_id': userId, - }); - return decode(response.data, EmptyResponse.fromJson); - } - - /// Unflag a message - Future unflagUser(String userId) async { - final response = await post('/moderation/unflag', data: { - 'target_user_id': userId, - }); - return decode(response.data, EmptyResponse.fromJson); - } - - /// Mark all channels for this user as read - Future markAllRead() async { - final response = await post('/channels/read'); - return decode(response.data, EmptyResponse.fromJson); - } - - /// Sends the message to the given channel - Future sendMessage( - Message message, String channelId, String channelType) async { - final response = await post( - '/channels/$channelType/$channelId/message', - data: {'message': message.toJson()}, - ); - return decode(response.data, SendMessageResponse.fromJson); - } - - /// Update the given message - Future updateMessage(Message message) async { - final response = await post( - '/messages/${message.id}', - data: {'message': message.toJson()}, - ); - return decode(response.data, UpdateMessageResponse.fromJson); - } - - /// Deletes the given message - Future deleteMessage(Message message) async { - final response = await delete('/messages/${message.id}'); - return decode(response.data, EmptyResponse.fromJson); - } - - /// Get a message by id - Future getMessage(String messageId) async { - final response = await get('/messages/$messageId'); - return decode(response.data, GetMessageResponse.fromJson); - } - - /// Pins provided message - Future pinMessage( - Message message, - Object timeoutOrExpirationDate, - ) { - assert(() { - if (timeoutOrExpirationDate is! DateTime && - timeoutOrExpirationDate is! num && - timeoutOrExpirationDate != null) { - throw ArgumentError('Invalid timeout or Expiration date'); - } - return true; - }(), 'Check whether time out is valid'); - - DateTime pinExpires; - if (timeoutOrExpirationDate is DateTime) { - pinExpires = timeoutOrExpirationDate.toUtc(); - } else if (timeoutOrExpirationDate is num) { - pinExpires = DateTime.now().add( - Duration(seconds: timeoutOrExpirationDate.toInt()), - ); - } - return updateMessage( - message.copyWith(pinned: true, pinExpires: pinExpires), - ); - } - - /// Unpins provided message - Future unpinMessage(Message message) => - updateMessage(message.copyWith(pinned: false)); -} - -/// The class that handles the state of the channel listening to the events -class ClientState { - /// Creates a new instance listening to events and updating the state - ClientState(this._client) { - _subscriptions.addAll([ - _client - .on() - .where((event) => event.me != null) - .map((e) => e.me) - .listen((user) { - _userController.add(user); - if (user.totalUnreadCount != null) { - _totalUnreadCountController.add(user.totalUnreadCount); - } - - if (user.unreadChannels != null) { - _unreadChannelsController.add(user.unreadChannels); - } - }), - _client - .on() - .where((event) => event.unreadChannels != null) - .map((e) => e.unreadChannels) - .listen(_unreadChannelsController.add), - _client - .on() - .where((event) => event.totalUnreadCount != null) - .map((e) => e.totalUnreadCount) - .listen(_totalUnreadCountController.add), - ]); - - _listenChannelDeleted(); - - _listenChannelHidden(); - - _listenUserUpdated(); - } - - final _subscriptions = []; - - /// Used internally for optimistic update of unread count - set totalUnreadCount(int unreadCount) { - _totalUnreadCountController?.add(unreadCount ?? 0); - } - - void _listenChannelHidden() { - _subscriptions.add(_client.on(EventType.channelHidden).listen((event) { - _client.chatPersistenceClient?.deleteChannels([event.cid]); - if (channels != null) { - channels = channels..removeWhere((cid, ch) => cid == event.cid); - } - })); - } - - void _listenUserUpdated() { - _subscriptions.add(_client.on(EventType.userUpdated).listen((event) { - if (event.user.id == user.id) { - user = OwnUser.fromJson(event.user.toJson()); - } - _updateUser(event.user); - })); - } - - void _listenChannelDeleted() { - _subscriptions.add(_client - .on( - EventType.channelDeleted, - EventType.notificationRemovedFromChannel, - EventType.notificationChannelDeleted, - ) - .listen((Event event) async { - final eventChannel = event.channel; - await _client.chatPersistenceClient?.deleteChannels([eventChannel.cid]); - if (channels != null) { - channels = channels..remove(eventChannel.cid); - } - })); - } - - final StreamChatClient _client; - - /// Update user information - set user(OwnUser user) { - _userController.add(user); - } - - void _updateUsers(List userList) { - final newUsers = { - ...users ?? {}, - for (var user in userList) user.id: user, - }; - _usersController.add(newUsers); - } - - void _updateUser(User user) => _updateUsers([user]); - - /// The current user - OwnUser get user => _userController.value; - - /// The current user as a stream - Stream get userStream => _userController.stream; - - /// The current user - Map get users => _usersController.value; - - /// The current user as a stream - Stream> get usersStream => _usersController.stream; - - /// The current unread channels count - int get unreadChannels => _unreadChannelsController.value; - - /// The current unread channels count as a stream - Stream get unreadChannelsStream => _unreadChannelsController.stream; - - /// The current total unread messages count - int get totalUnreadCount => _totalUnreadCountController.value; - - /// The current total unread messages count as a stream - Stream get totalUnreadCountStream => _totalUnreadCountController.stream; - - /// The current list of channels in memory as a stream - Stream> get channelsStream => _channelsController.stream; - - /// The current list of channels in memory - Map get channels => _channelsController.value; - - set channels(Map v) { - _channelsController.add(v); - } - - final BehaviorSubject> _channelsController = - BehaviorSubject.seeded({}); - final BehaviorSubject _userController = BehaviorSubject(); - final BehaviorSubject> _usersController = - BehaviorSubject.seeded({}); - final BehaviorSubject _unreadChannelsController = BehaviorSubject(); - final BehaviorSubject _totalUnreadCountController = BehaviorSubject(); - - /// Call this method to dispose this object - void dispose() { - _subscriptions.forEach((s) => s.cancel()); - _userController.close(); - _unreadChannelsController.close(); - _totalUnreadCountController.close(); - channels.values.forEach((c) => c.dispose()); - _channelsController.close(); - } -} diff --git a/packages/stream_chat/lib/src/api/channel.dart b/packages/stream_chat/lib/src/client/channel.dart similarity index 60% rename from packages/stream_chat/lib/src/api/channel.dart rename to packages/stream_chat/lib/src/client/channel.dart index b424f6f1..94a07f34 100644 --- a/packages/stream_chat/lib/src/api/channel.dart +++ b/packages/stream_chat/lib/src/client/channel.dart @@ -1,16 +1,18 @@ import 'dart:async'; -import 'dart:convert'; import 'dart:math'; +import 'package:collection/collection.dart' + show IterableExtension, ListEquality; import 'package:dio/dio.dart'; -import 'package:logging/logging.dart'; +import 'package:rate_limiter/rate_limiter.dart'; import 'package:rxdart/rxdart.dart'; -import 'package:stream_chat/src/api/retry_queue.dart'; +import 'package:stream_chat/src/client/retry_queue.dart'; +import 'package:stream_chat/src/core/error/error.dart'; +import 'package:stream_chat/src/core/models/attachment_file.dart'; +import 'package:stream_chat/src/core/models/channel_state.dart'; +import 'package:stream_chat/src/core/models/user.dart'; +import 'package:stream_chat/src/core/util/utils.dart'; import 'package:stream_chat/src/event_type.dart'; -import 'package:stream_chat/src/extensions/rate_limit.dart'; -import 'package:stream_chat/src/models/attachment_file.dart'; -import 'package:stream_chat/src/models/channel_state.dart'; -import 'package:stream_chat/src/models/user.dart'; import 'package:stream_chat/stream_chat.dart'; /// This a the class that manages a specific channel. @@ -18,52 +20,61 @@ class Channel { /// Create a channel client instance. Channel( this._client, - this.type, - this._id, - this._extraData, - ) : _cid = _id != null ? '$type:$_id' : null { + this._type, + this._id, { + Map? extraData, + }) : _cid = _id != null ? '$_type:$_id' : null, + _extraData = extraData ?? {} { _client.logger.info('New Channel instance not initialized created'); } /// Create a channel client instance from a [ChannelState] object - Channel.fromState(this._client, ChannelState channelState) { - _cid = channelState.channel.cid; - _id = channelState.channel.id; - type = channelState.channel.type; - + Channel.fromState(this._client, ChannelState channelState) + : assert( + channelState.channel != null, + 'No channel found inside channel state', + ), + _id = channelState.channel!.id, + _type = channelState.channel!.type, + _cid = channelState.channel!.cid, + _extraData = channelState.channel!.extraData { state = ChannelClientState(this, channelState); _initializedCompleter.complete(true); _client.logger.info('New Channel instance initialized created'); } /// This client state - ChannelClientState state; + ChannelClientState? state; /// The channel type - String type; + final String _type; - String _id; - String _cid; - Map _extraData; + String? _id; + String? _cid; + final Map _extraData; - set extraData(Map extraData) { + set extraData(Map extraData) { if (_initializedCompleter.isCompleted) { - throw Exception( - 'Once the channel is initialized you should use channel.update ' - 'to update channel data'); + throw StateError( + 'Once the channel is initialized you should use channel.update ' + 'to update channel data', + ); } - _extraData = extraData; + _extraData.addAll(extraData); } /// Returns true if the channel is muted bool get isMuted => _client.state.user?.channelMutes - ?.any((element) => element.channel.cid == cid) == + .any((element) => element.channel.cid == cid) == true; /// Returns true if the channel is muted as a stream - Stream get isMutedStream => _client.state.userStream?.map((event) => - event.channelMutes?.any((element) => element.channel.cid == cid) == true); + Stream? get isMutedStream => _client.state.userStream + .map((event) => + event!.channelMutes.any((element) => element.channel.cid == cid) == + true) + .distinct(); /// True if the channel is a group bool get isGroup => memberCount != 2; @@ -72,92 +83,145 @@ class Channel { bool get isDistinct => id?.startsWith('!members') == true; /// Channel configuration - ChannelConfig get config => state?._channelState?.channel?.config; + ChannelConfig? get config { + _checkInitialized(); + return state?._channelState.channel?.config; + } /// Channel configuration as a stream - Stream get configStream => - state?.channelStateStream?.map((cs) => cs.channel?.config); + Stream? get configStream { + _checkInitialized(); + return state?.channelStateStream.map((cs) => cs.channel?.config); + } /// Channel user creator - User get createdBy => state?._channelState?.channel?.createdBy; + User? get createdBy { + _checkInitialized(); + return state?._channelState.channel?.createdBy; + } /// Channel user creator as a stream - Stream get createdByStream => - state?.channelStateStream?.map((cs) => cs.channel?.createdBy); + Stream? get createdByStream { + _checkInitialized(); + return state?.channelStateStream.map((cs) => cs.channel?.createdBy); + } /// Channel frozen status - bool get frozen => state?._channelState?.channel?.frozen; + bool? get frozen { + _checkInitialized(); + return state?._channelState.channel?.frozen; + } /// Channel frozen status as a stream - Stream get frozenStream => - state?.channelStateStream?.map((cs) => cs.channel?.frozen); + Stream? get frozenStream { + _checkInitialized(); + return state?.channelStateStream.map((cs) => cs.channel?.frozen); + } /// Channel creation date - DateTime get createdAt => state?._channelState?.channel?.createdAt; + DateTime? get createdAt { + _checkInitialized(); + return state?._channelState.channel?.createdAt; + } /// Channel creation date as a stream - Stream get createdAtStream => - state?.channelStateStream?.map((cs) => cs.channel?.createdAt); + Stream? get createdAtStream { + _checkInitialized(); + return state?.channelStateStream.map((cs) => cs.channel?.createdAt); + } /// Channel last message date - DateTime get lastMessageAt => state?._channelState?.channel?.lastMessageAt; + DateTime? get lastMessageAt { + _checkInitialized(); + + return state?._channelState.channel?.lastMessageAt; + } /// Channel last message date as a stream - Stream get lastMessageAtStream => - state?.channelStateStream?.map((cs) => cs.channel?.lastMessageAt); + Stream? get lastMessageAtStream { + _checkInitialized(); + + return state?.channelStateStream.map((cs) => cs.channel?.lastMessageAt); + } /// Channel updated date - DateTime get updatedAt => state?._channelState?.channel?.updatedAt; + DateTime? get updatedAt { + _checkInitialized(); + + return state?._channelState.channel?.updatedAt; + } /// Channel updated date as a stream - Stream get updatedAtStream => - state?.channelStateStream?.map((cs) => cs.channel?.updatedAt); + Stream? get updatedAtStream { + _checkInitialized(); + + return state?.channelStateStream.map((cs) => cs.channel?.updatedAt); + } /// Channel deletion date - DateTime get deletedAt => state?._channelState?.channel?.deletedAt; + DateTime? get deletedAt { + _checkInitialized(); + + return state?._channelState.channel?.deletedAt; + } /// Channel deletion date as a stream - Stream get deletedAtStream => - state?.channelStateStream?.map((cs) => cs.channel?.deletedAt); + Stream? get deletedAtStream { + _checkInitialized(); + + return state?.channelStateStream.map((cs) => cs.channel?.deletedAt); + } /// Channel member count - int get memberCount => state?._channelState?.channel?.memberCount; + int? get memberCount { + _checkInitialized(); + + return state?._channelState.channel?.memberCount; + } /// Channel member count as a stream - Stream get memberCountStream => - state?.channelStateStream?.map((cs) => cs.channel?.memberCount); + Stream? get memberCountStream { + _checkInitialized(); + + return state?.channelStateStream.map((cs) => cs.channel?.memberCount); + } /// Channel id - String get id => state?._channelState?.channel?.id ?? _id; + String? get id => state?._channelState.channel?.id ?? _id; - /// Channel id as a stream - Stream get idStream => - state?.channelStateStream?.map((cs) => cs.channel?.id ?? _id); + /// Channel type + String get type => state?._channelState.channel?.type ?? _type; /// Channel cid - String get cid => state?._channelState?.channel?.cid ?? _cid; + String? get cid => state?._channelState.channel?.cid ?? _cid; /// Channel team - String get team => state?._channelState?.channel?.team; - - /// Channel cid as a stream - Stream get cidStream => - state?.channelStateStream?.map((cs) => cs.channel?.cid ?? _cid); + String? get team { + _checkInitialized(); + return state?._channelState.channel?.team; + } /// Channel extra data - Map get extraData => - state?._channelState?.channel?.extraData ?? _extraData; + Map get extraData { + var data = state?._channelState.channel?.extraData; + if (data == null || data.isEmpty) { + data = _extraData; + } + return data; + } /// Channel extra data as a stream - Stream> get extraDataStream => - state?.channelStateStream?.map((cs) => cs.channel?.extraData); + Stream> get extraDataStream { + _checkInitialized(); + return state!.channelStateStream.map( + (cs) => cs.channel?.extraData ?? _extraData, + ); + } /// The main Stream chat client StreamChatClient get client => _client; final StreamChatClient _client; - String get _channelURL => '/channels/$type/$id'; - final Completer _initializedCompleter = Completer(); /// True if this is initialized @@ -174,16 +238,18 @@ class Channel { /// Optionally, provide a [reason] for the cancellation. void cancelAttachmentUpload( String attachmentId, { - String reason, + String? reason, }) { final cancelToken = _cancelableAttachmentUploadRequest[attachmentId]; if (cancelToken == null) { - throw Exception( - "Upload request for this Attachment hasn't started yet or else " + throw const StreamChatError( + "Upload request for this Attachment hasn't started yet or maybe " 'Already completed', ); } - if (cancelToken.isCancelled) throw Exception('Already cancelled'); + if (cancelToken.isCancelled) { + throw const StreamChatError('Upload request already cancelled'); + } cancelToken.cancel(reason); } @@ -195,13 +261,15 @@ class Channel { String messageId, Iterable attachmentIds, ) { - final message = state.messages.firstWhere( + final message = [ + ...state!.messages, + ...state!.threads.values.expand((messages) => messages), + ].firstWhereOrNull( (it) => it.id == messageId, - orElse: () => null, ); if (message == null) { - throw Exception('Error, Message not found'); + throw const StreamChatError('Error, Message not found'); } final attachments = message.attachments.where((it) { @@ -248,13 +316,13 @@ class Channel { Future future; if (isImage) { future = sendImage( - it.file, + it.file!, onSendProgress: onSendProgress, cancelToken: cancelToken, ).then((it) => it.file); } else { future = sendFile( - it.file, + it.file!, onSendProgress: onSendProgress, cancelToken: cancelToken, ).then((it) => it.file); @@ -283,7 +351,7 @@ class Channel { it.copyWith(uploadState: UploadState.failed(error: e.toString())), ); }).whenComplete(() { - throttledUpdateAttachment?.cancel(); + throttledUpdateAttachment.cancel(); _cancelableAttachmentUploadRequest.remove(it.id); }); })).whenComplete(() { @@ -294,51 +362,45 @@ class Channel { } /// Send a [message] to this channel. + /// If [skipPush] is true the message will not send a push notification /// Waits for a [_messageAttachmentsUploadCompleter] to complete /// before actually sending the message. - Future sendMessage(Message message) async { + Future sendMessage( + Message message, { + bool skipPush = false, + }) async { + _checkInitialized(); // Cancelling previous completer in case it's called again in the process // Eg. Updating the message while the previous call is in progress. _messageAttachmentsUploadCompleter .remove(message.id) ?.completeError('Message Cancelled'); - final quotedMessage = state?.messages?.firstWhere( - (m) => m.id == message?.quotedMessageId, - orElse: () => null, + final quotedMessage = state!.messages.firstWhereOrNull( + (m) => m.id == message.quotedMessageId, ); // ignore: parameter_assignments message = message.copyWith( - createdAt: message.createdAt ?? DateTime.now(), + createdAt: message.createdAt, user: _client.state.user, quotedMessage: quotedMessage, status: MessageSendingStatus.sending, - attachments: message.attachments?.map( + attachments: message.attachments.map( (it) { if (it.uploadState.isSuccess) return it; return it.copyWith(uploadState: const UploadState.preparing()); }, - )?.toList(), + ).toList(), ); - if (message.parentId != null && message.id == null) { - final parentMessage = - state.messages.firstWhere((m) => m.id == message.parentId); - - state?.addMessage(parentMessage.copyWith( - replyCount: parentMessage.replyCount + 1, - )); - } - - state?.addMessage(message); + state!.addMessage(message); try { - if (message.attachments?.any((it) => !it.uploadState.isSuccess) == true) { + if (message.attachments.any((it) => !it.uploadState.isSuccess) == true) { final attachmentsUploadCompleter = Completer(); _messageAttachmentsUploadCompleter[message.id] = attachmentsUploadCompleter; - // ignore: unawaited_futures _uploadAttachments( message.id, message.attachments.map((it) => it.id), @@ -348,12 +410,17 @@ class Channel { message = await attachmentsUploadCompleter.future; } - final response = await _client.sendMessage(message, id, type); - state?.addMessage(response.message); + final response = await _client.sendMessage( + message, + id!, + type, + skipPush: skipPush, + ); + state!.addMessage(response.message); return response; - } catch (error) { - if (error is DioError && error.type != DioErrorType.RESPONSE) { - state?.retryQueue?.add([message]); + } catch (e) { + if (e is StreamChatNetworkError && e.isRetriable) { + state!._retryQueue.add([message]); } rethrow; } @@ -363,6 +430,8 @@ class Channel { /// Waits for a [_messageAttachmentsUploadCompleter] to complete /// before actually updating the message. Future updateMessage(Message message) async { + final originalMessage = message; + // Cancelling previous completer in case it's called again in the process // Eg. Updating the message while the previous call is in progress. _messageAttachmentsUploadCompleter @@ -372,24 +441,23 @@ class Channel { // ignore: parameter_assignments message = message.copyWith( status: MessageSendingStatus.updating, - updatedAt: message.updatedAt ?? DateTime.now(), - attachments: message.attachments?.map( + updatedAt: message.updatedAt, + attachments: message.attachments.map( (it) { if (it.uploadState.isSuccess) return it; return it.copyWith(uploadState: const UploadState.preparing()); }, - )?.toList(), + ).toList(), ); state?.addMessage(message); try { - if (message.attachments?.any((it) => !it.uploadState.isSuccess) == true) { + if (message.attachments.any((it) => !it.uploadState.isSuccess)) { final attachmentsUploadCompleter = Completer(); _messageAttachmentsUploadCompleter[message.id] = attachmentsUploadCompleter; - // ignore: unawaited_futures _uploadAttachments( message.id, message.attachments.map((it) => it.id), @@ -400,13 +468,51 @@ class Channel { } final response = await _client.updateMessage(message); - state?.addMessage(response?.message?.copyWith( + + final m = response.message.copyWith( ownReactions: message.ownReactions, - )); + ); + + state?.addMessage(m); + return response; - } catch (error) { - if (error is DioError && error.type != DioErrorType.RESPONSE) { - state?.retryQueue?.add([message]); + } catch (e) { + if (e is StreamChatNetworkError) { + if (e.isRetriable) { + state!._retryQueue.add([message]); + } else { + state?.addMessage(originalMessage); + } + } + rethrow; + } + } + + /// Partially updates the [message] in this channel. + /// Use [set] to define values to be set + /// Use [unset] to define values to be unset + Future partialUpdateMessage( + Message message, { + Map? set, + List? unset, + }) async { + try { + final response = await _client.partialUpdateMessage( + message.id, + set: set, + unset: unset, + ); + + final updatedMessage = response.message.copyWith( + ownReactions: message.ownReactions, + ); + + state?.addMessage(updatedMessage); + + return response; + } catch (e) { + if (e is StreamChatNetworkError && e.isRetriable) { + state!._retryQueue.add([message]); } rethrow; } @@ -417,7 +523,7 @@ class Channel { // Directly deleting the local messages which are not yet sent to server if (message.status == MessageSendingStatus.sending || message.status == MessageSendingStatus.failed) { - state.addMessage(message.copyWith( + state!.addMessage(message.copyWith( type: 'deleted', status: MessageSendingStatus.sent, )); @@ -440,14 +546,14 @@ class Channel { state?.addMessage(message); - final response = await _client.deleteMessage(message); + final response = await _client.deleteMessage(message.id); state?.addMessage(message.copyWith(status: MessageSendingStatus.sent)); return response; - } catch (error) { - if (error is DioError && error.type != DioErrorType.RESPONSE) { - state?.retryQueue?.add([message]); + } catch (e) { + if (e is StreamChatNetworkError && e.isRetriable) { + state!._retryQueue.add([message]); } rethrow; } @@ -455,19 +561,19 @@ class Channel { /// Pins provided message Future pinMessage( - Message message, - Object timeoutOrExpirationDate, - ) { + Message message, { + Object? /*num|DateTime*/ timeoutOrExpirationDate, + }) { assert(() { if (timeoutOrExpirationDate is! DateTime && - timeoutOrExpirationDate is! num && - timeoutOrExpirationDate != null) { + timeoutOrExpirationDate != null && + timeoutOrExpirationDate is! num) { throw ArgumentError('Invalid timeout or Expiration date'); } return true; - }(), 'Check for invalid token or expiration date'); + }(), 'Check for invalid timeout or expiration date'); - DateTime pinExpires; + DateTime? pinExpires; if (timeoutOrExpirationDate is DateTime) { pinExpires = timeoutOrExpirationDate; } else if (timeoutOrExpirationDate is num) { @@ -475,86 +581,105 @@ class Channel { Duration(seconds: timeoutOrExpirationDate.toInt()), ); } - return updateMessage( - message.copyWith( - pinned: true, - pinExpires: pinExpires, - ), + return partialUpdateMessage( + message, + set: { + 'pinned': true, + 'pin_expires': pinExpires?.toUtc().toIso8601String(), + }, ); } /// Unpins provided message Future unpinMessage(Message message) => - updateMessage(message.copyWith(pinned: false)); + partialUpdateMessage( + message, + set: { + 'pinned': false, + }, + ); /// Send a file to this channel Future sendFile( AttachmentFile file, { - ProgressCallback onSendProgress, - CancelToken cancelToken, - }) => - _client.sendFile( - file, - id, - type, - onSendProgress: onSendProgress, - cancelToken: cancelToken, - ); + ProgressCallback? onSendProgress, + CancelToken? cancelToken, + }) { + _checkInitialized(); + return _client.sendFile( + file, + id!, + type, + onSendProgress: onSendProgress, + cancelToken: cancelToken, + ); + } /// Send an image to this channel Future sendImage( AttachmentFile file, { - ProgressCallback onSendProgress, - CancelToken cancelToken, - }) => - _client.sendImage( - file, - id, - type, - onSendProgress: onSendProgress, - cancelToken: cancelToken, - ); + ProgressCallback? onSendProgress, + CancelToken? cancelToken, + }) { + _checkInitialized(); + return _client.sendImage( + file, + id!, + type, + onSendProgress: onSendProgress, + cancelToken: cancelToken, + ); + } /// A message search. Future search({ - String query, - Map messageFilters, - List sort, - PaginationParams paginationParams, - }) => - _client.search( - { - 'cid': { - r'$in': [cid], - }, - }, - sort: sort, - query: query, - paginationParams: paginationParams, - messageFilters: messageFilters, - ); + String? query, + Filter? messageFilters, + List? sort, + PaginationParams? paginationParams, + }) { + _checkInitialized(); + return _client.search( + Filter.in_('cid', [cid!]), + sort: sort, + query: query, + paginationParams: paginationParams, + messageFilters: messageFilters, + ); + } /// Delete a file from this channel Future deleteFile( String url, { - CancelToken cancelToken, - }) => - _client.deleteFile(url, id, type, cancelToken: cancelToken); + CancelToken? cancelToken, + }) { + _checkInitialized(); + return _client.deleteFile( + url, + id!, + type, + cancelToken: cancelToken, + ); + } /// Delete an image from this channel Future deleteImage( String url, { - CancelToken cancelToken, - }) => - _client.deleteImage(url, id, type, cancelToken: cancelToken); + CancelToken? cancelToken, + }) { + _checkInitialized(); + return _client.deleteImage( + url, + id!, + type, + cancelToken: cancelToken, + ); + } /// Send an event on this channel Future sendEvent(Event event) { _checkInitialized(); - return _client.post( - '$_channelURL/event', - data: {'event': event.toJson()}, - ).then((res) => _client.decode(res.data, EmptyResponse.fromJson)); + return _client.sendEvent(id!, type, event); } /// Send a reaction to this channel @@ -562,16 +687,17 @@ class Channel { Future sendReaction( Message message, String type, { - Map extraData = const {}, + Map extraData = const {}, bool enforceUnique = false, }) async { + _checkInitialized(); final messageId = message.id; final now = DateTime.now(); final user = _client.state.user; final latestReactions = [...message.latestReactions ?? []]; if (enforceUnique) { - latestReactions.removeWhere((it) => it.userId == user.id); + latestReactions.removeWhere((it) => it.userId == user!.id); } final newReaction = Reaction( @@ -586,10 +712,10 @@ class Channel { // Inserting at the 0th index as it's the latest reaction latestReactions.insert(0, newReaction); final ownReactions = [...latestReactions] - ..removeWhere((it) => it.userId != user.id); + ..removeWhere((it) => it.userId != user!.id); final newMessage = message.copyWith( - reactionCounts: {...message?.reactionCounts ?? {}} + reactionCounts: {...message.reactionCounts ?? {}} ..update(type, (value) { if (enforceUnique) return value; return value + 1; @@ -605,21 +731,13 @@ class Channel { state?.addMessage(newMessage); - final data = Map.from(extraData) - ..addAll({ - 'type': type, - }); - try { - final res = await _client.post( - '/messages/$messageId/reaction', - data: { - 'reaction': data, - 'enforce_unique': enforceUnique, - }, + final reactionResp = await _client.sendReaction( + messageId, + type, + extraData: extraData, + enforceUnique: enforceUnique, ); - final reactionResp = - _client.decode(res.data, SendReactionResponse.fromJson); return reactionResp; } catch (_) { // Reset the message if the update fails @@ -649,8 +767,8 @@ class Channel { r.type == reaction.type && r.messageId == reaction.messageId); - final ownReactions = [...latestReactions ?? []] - ..removeWhere((it) => it.userId != user.id); + final ownReactions = [...latestReactions] + ..removeWhere((it) => it.userId != user!.id); final newMessage = message.copyWith( reactionCounts: reactionCounts..removeWhere((_, value) => value == 0), @@ -662,9 +780,11 @@ class Channel { state?.addMessage(newMessage); try { - final res = await client - .delete('/messages/${message.id}/reaction/${reaction.type}'); - return _client.decode(res.data, EmptyResponse.fromJson); + final deleteResponse = await _client.deleteReaction( + message.id, + reaction.type, + ); + return deleteResponse; } catch (_) { // Reset the message if the update fails state?.addMessage(message); @@ -674,84 +794,76 @@ class Channel { /// Edit the channel custom data Future update( - Map channelData, [ - Message updateMessage, + Map channelData, [ + Message? updateMessage, ]) async { - final response = await _client.post(_channelURL, data: { - if (updateMessage != null) - 'message': updateMessage.copyWith(updatedAt: DateTime.now()).toJson(), - 'data': channelData, - }); - return _client.decode(response.data, UpdateChannelResponse.fromJson); + _checkInitialized(); + return _client.updateChannel( + id!, + type, + channelData, + message: updateMessage, + ); } /// Edit the channel custom data - Future updatePartial( - Map channelData) async { - final response = await _client.patch(_channelURL, data: channelData); - return _client.decode(response.data, PartialUpdateChannelResponse.fromJson); + Future updatePartial({ + Map? set, + List? unset, + }) async { + _checkInitialized(); + return _client.updateChannelPartial(id!, type, set: set, unset: unset); } /// Delete this channel. Messages are permanently removed. Future delete() async { - final response = await _client.delete(_channelURL); - return _client.decode(response.data, EmptyResponse.fromJson); + _checkInitialized(); + return _client.deleteChannel(id!, type); } /// Removes all messages from the channel Future truncate() async { - final response = await _client.post('$_channelURL/truncate'); - return _client.decode(response.data, EmptyResponse.fromJson); + _checkInitialized(); + return _client.truncateChannel(id!, type); } /// Accept invitation to the channel - Future acceptInvite([Message message]) async { - final res = await _client.post(_channelURL, - data: {'accept_invite': true, 'message': message?.toJson()}); - return _client.decode(res.data, AcceptInviteResponse.fromJson); + Future acceptInvite([Message? message]) async { + _checkInitialized(); + return _client.acceptChannelInvite(id!, type, message: message); } /// Reject invitation to the channel - Future rejectInvite([Message message]) async { - final res = await _client.post(_channelURL, - data: {'reject_invite': true, 'message': message?.toJson()}); - return _client.decode(res.data, RejectInviteResponse.fromJson); + Future rejectInvite([Message? message]) async { + _checkInitialized(); + return _client.rejectChannelInvite(id!, type, message: message); } /// Add members to the channel Future addMembers( List memberIds, [ - Message message, + Message? message, ]) async { - final res = await _client.post(_channelURL, data: { - 'add_members': memberIds, - 'message': message?.toJson(), - }); - return _client.decode(res.data, AddMembersResponse.fromJson); + _checkInitialized(); + return _client.addChannelMembers(id!, type, memberIds, message: message); } /// Invite members to the channel Future inviteMembers( List memberIds, [ - Message message, + Message? message, ]) async { - final res = await _client.post(_channelURL, data: { - 'invites': memberIds, - 'message': message?.toJson(), - }); - return _client.decode(res.data, InviteMembersResponse.fromJson); + _checkInitialized(); + return _client.inviteChannelMembers(id!, type, memberIds, message: message); } /// Remove members from the channel Future removeMembers( List memberIds, [ - Message message, + Message? message, ]) async { - final res = await _client.post(_channelURL, data: { - 'remove_members': memberIds, - 'message': message?.toJson(), - }); - return _client.decode(res.data, RemoveMembersResponse.fromJson); + _checkInitialized(); + return _client.removeChannelMembers(id!, type, memberIds, message: message); } /// Send action for a specific message of this channel @@ -760,75 +872,64 @@ class Channel { Map formData, ) async { _checkInitialized(); - final messageId = message.id; - final response = await _client.post('/messages/$messageId/action', data: { - 'id': id, - 'type': type, - 'form_data': formData, - 'message_id': messageId, - }); - - final res = _client.decode(response.data, SendActionResponse.fromJson); + final res = await _client.sendAction(id!, type, messageId, formData); + // update the passed message with response message if (res.message != null) { - state.addMessage(res.message); + state!.addMessage(res.message!); } else { - final oldIndex = state.messages?.indexWhere((m) => m.id == messageId); + // remove the passed message if response does + // not contain message + final oldIndex = state!.messages.indexWhere((m) => m.id == messageId); - Message oldMessage; - if (oldIndex != null && oldIndex != -1) { - oldMessage = state.messages[oldIndex]; - state.updateChannelState(state._channelState.copyWith( - messages: state.messages..remove(oldMessage), + // remove regular message if present + if (oldIndex != -1) { + final oldMessage = state!.messages[oldIndex]; + state!.updateChannelState(state!._channelState.copyWith( + messages: state?.messages?..remove(oldMessage), + channel: state?._channelState.channel, )); } else { - oldMessage = state.threads.values + // remove thread message if present + // also reduces total reply count + final oldMessage = state!.threads.values .expand((messages) => messages) - .firstWhere((m) => m.id == messageId, orElse: () => null); + .firstWhereOrNull((m) => m.id == messageId); if (oldMessage?.parentId != null) { - final parentMessage = state.messages.firstWhere( - (element) => element.id == oldMessage.parentId, - orElse: () => null, + final parentMessage = state!.messages.firstWhereOrNull( + (element) => element.id == oldMessage!.parentId, ); if (parentMessage != null) { - state.addMessage(parentMessage.copyWith( - replyCount: parentMessage.replyCount - 1)); + state!.addMessage(parentMessage.copyWith( + replyCount: parentMessage.replyCount! - 1)); } - state.updateThreadInfo(oldMessage.parentId, - state.threads[oldMessage.parentId]..remove(oldMessage)); + state!.updateThreadInfo(oldMessage!.parentId!, + state!.threads[oldMessage.parentId!]!..remove(oldMessage)); } } - await _client.chatPersistenceClient?.deleteMessageById(messageId); } - return res; } - /// Mark all channel messages as read - Future markRead() async { + /// Mark all messages as read + /// Optionally provide a [messageId] if you want to mark a + /// particular message as read + Future markRead({String? messageId}) async { _checkInitialized(); client.state.totalUnreadCount = - max(0, (client.state.totalUnreadCount ?? 0) - (state.unreadCount ?? 0)); - state._unreadCountController.add(0); - final response = await _client.post('$_channelURL/read', data: {}); - return _client.decode(response.data, EmptyResponse.fromJson); + max(0, (client.state.totalUnreadCount) - (state!.unreadCount)); + state!.unreadCount = 0; + return _client.markChannelRead(id!, type, messageId: messageId); } /// Loads the initial channel state and watches for changes - Future watch([Map options = const {}]) async { - final watchOptions = Map.from({ - 'state': true, - 'watch': true, - 'presence': false, - }) - ..addAll(options); - + Future watch() async { ChannelState response; try { - response = await query(options: watchOptions); + response = await query(watch: true); } catch (error, stackTrace) { if (!_initializedCompleter.isCompleted) { _initializedCompleter.completeError(error, stackTrace); @@ -845,7 +946,10 @@ class Channel { void _initState(ChannelState channelState) { state = ChannelClientState(this, channelState); - client.state.channels[cid] = this; + + if (cid != null) { + client.state.channels = {cid!: this}; + } if (!_initializedCompleter.isCompleted) { _initializedCompleter.complete(true); } @@ -853,19 +957,16 @@ class Channel { /// Stop watching the channel Future stopWatching() async { - final response = await _client.post( - '$_channelURL/stop-watching', - data: {}, - ); - return _client.decode(response?.data, EmptyResponse.fromJson); + _checkInitialized(); + return _client.stopChannelWatching(id!, type); } /// List the message replies for a parent message /// Set [preferOffline] to true to avoid the api call if the data is already /// in the offline storage Future getReplies( - String parentId, - PaginationParams options, { + String parentId, { + PaginationParams? options, bool preferOffline = false, }) async { final cachedReplies = await _client.chatPersistenceClient?.getReplies( @@ -878,48 +979,32 @@ class Channel { return QueryRepliesResponse()..messages = cachedReplies; } } - - final response = await _client.get('/messages/$parentId/replies', - queryParameters: options.toJson()); - - final repliesResponse = _client.decode( - response.data, - QueryRepliesResponse.fromJson, + final repliesResponse = await _client.getReplies( + parentId, + options: options, ); - state?.updateThreadInfo(parentId, repliesResponse.messages); - return repliesResponse; } /// List the reactions for a message in the channel Future getReactions( - String messageID, - PaginationParams options, - ) async { - final response = await _client.get( - '/messages/$messageID/reactions', - queryParameters: options.toJson(), - ); - return _client.decode( - response.data, QueryReactionsResponse.fromJson); - } + String messageId, { + PaginationParams? pagination, + }) => + _client.getReactions( + messageId, + pagination: pagination, + ); /// Retrieves a list of messages by ID Future getMessagesById( - List messageIDs) async { - final response = await _client.get( - '$_channelURL/messages', - queryParameters: {'ids': messageIDs.join(',')}, - ); - - final res = _client.decode( - response.data, - GetMessagesByIdResponse.fromJson, - ); - - state?.updateChannelState(ChannelState(messages: res.messages)); - + List messageIDs, + ) async { + _checkInitialized(); + final res = await _client.getMessagesById(id!, type, messageIDs); + final messages = res.messages; + state?.updateChannelState(ChannelState(messages: messages)); return res; } @@ -927,94 +1012,66 @@ class Channel { Future translateMessage( String messageId, String language, - ) async { - final response = await _client.post( - '/messages/$messageId/translate', - data: { - 'language': language, - }, - ); - return _client.decode( - response.data, - TranslateMessageResponse.fromJson, - ); - } + ) => + _client.translateMessage( + messageId, + language, + ); /// Creates a new channel - Future create() async => query(options: { - 'watch': false, - 'state': false, - 'presence': false, - }); + Future create() async => query(state: false); /// Query the API, get messages, members or other channel fields /// Set [preferOffline] to true to avoid the api call if the data is already /// in the offline storage Future query({ - Map options = const {}, - PaginationParams messagesPagination, - PaginationParams membersPagination, - PaginationParams watchersPagination, + bool state = true, + bool watch = false, + bool presence = false, + PaginationParams? messagesPagination, + PaginationParams? membersPagination, + PaginationParams? watchersPagination, bool preferOffline = false, }) async { - var path = '/channels/$type'; - if (id != null) { - path = '$path/$id'; - } - path = '$path/query'; - - final payload = Map.from({ - 'state': true, - }) - ..addAll(options); - - if (_extraData != null) { - payload['data'] = _extraData; - } - - if (messagesPagination != null) { - payload['messages'] = messagesPagination.toJson(); - } - if (membersPagination != null) { - payload['members'] = membersPagination.toJson(); - } - if (watchersPagination != null) { - payload['watchers'] = watchersPagination.toJson(); - } - if (preferOffline && cid != null) { - final updatedState = - await _client.chatPersistenceClient?.getChannelStateByCid( - cid, - messagePagination: messagesPagination, - ); + final updatedState = await _client.chatPersistenceClient + ?.getChannelStateByCid(cid!, messagePagination: messagesPagination); if (updatedState != null && updatedState.messages.isNotEmpty) { - if (state == null) { + if (this.state == null) { _initState(updatedState); } else { - state?.updateChannelState(updatedState); + this.state?.updateChannelState(updatedState); } return updatedState; } } try { - final response = await _client.post(path, data: payload); - final updatedState = _client.decode(response.data, ChannelState.fromJson); + final updatedState = await _client.queryChannel( + type, + channelId: id, + channelData: _extraData, + state: state, + watch: watch, + presence: presence, + messagesPagination: messagesPagination, + membersPagination: membersPagination, + watchersPagination: watchersPagination, + ); if (_id == null) { - _id = updatedState.channel.id; - _cid = updatedState.channel.cid; + _id = updatedState.channel!.id; + _cid = updatedState.channel!.cid; } - state?.updateChannelState(updatedState); + this.state?.updateChannelState(updatedState); return updatedState; } catch (e) { if (!_client.persistenceEnabled) { rethrow; } - return _client.chatPersistenceClient?.getChannelStateByCid( - cid, + return _client.chatPersistenceClient!.getChannelStateByCid( + cid!, messagePagination: messagesPagination, ); } @@ -1022,48 +1079,29 @@ class Channel { /// Query channel members Future queryMembers({ - Map filter, - List sort, - PaginationParams pagination, - }) async { - final payload = { - 'sort': sort, - 'filter_conditions': filter, - 'type': type, - }; - - if (pagination != null) { - payload.addAll(pagination.toJson()); - } - - if (id != null) { - payload['id'] = id; - } else if (state?.members?.isNotEmpty == true) { - payload['members'] = state.members; - } - - final rawRes = await _client.get('/members', queryParameters: { - 'payload': jsonEncode(payload), - }); - final response = _client.decode(rawRes.data, QueryMembersResponse.fromJson); - return response; - } + Filter? filter, + List? sort, + PaginationParams? pagination, + }) => + _client.queryMembers( + type, + channelId: id, + filter: filter, + members: state?.members, + sort: sort, + pagination: pagination, + ); /// Mutes the channel - Future mute({Duration expiration}) async { - final response = await _client.post('/moderation/mute/channel', data: { - 'channel_cid': cid, - if (expiration != null) 'expiration': expiration.inMilliseconds, - }); - return _client.decode(response.data, EmptyResponse.fromJson); + Future mute({Duration? expiration}) { + _checkInitialized(); + return _client.muteChannel(cid!, expiration: expiration); } /// Unmutes the channel - Future unmute() async { - final response = await _client.post('/moderation/unmute/channel', data: { - 'channel_cid': cid, - }); - return _client.decode(response.data, EmptyResponse.fromJson); + Future unmute() { + _checkInitialized(); + return _client.unmuteChannel(cid!); } /// Bans a user from the channel @@ -1117,32 +1155,35 @@ class Channel { /// will be removed for the user Future hide({bool clearHistory = false}) async { _checkInitialized(); - final response = await _client - .post('$_channelURL/hide', data: {'clear_history': clearHistory}); - + final response = await _client.hideChannel( + id!, + type, + clearHistory: clearHistory, + ); if (clearHistory == true) { - state.truncate(); - await _client.chatPersistenceClient?.deleteMessageByCid(_cid); + state!.truncate(); + final cid = _cid; + if (cid != null) { + await _client.chatPersistenceClient?.deleteMessageByCid(cid); + } } - - return _client.decode(response.data, EmptyResponse.fromJson); + return response; } /// Removes the hidden status for the channel Future show() async { _checkInitialized(); - final response = await _client.post('$_channelURL/show'); - return _client.decode(response.data, EmptyResponse.fromJson); + return _client.showChannel(id!, type); } /// Stream of [Event] coming from websocket connection specific for the /// channel. Pass an eventType as parameter in order to filter just a type /// of event Stream on([ - String eventType, - String eventType2, - String eventType3, - String eventType4, + String? eventType, + String? eventType2, + String? eventType3, + String? eventType4, ]) => _client .on( @@ -1153,11 +1194,11 @@ class Channel { ) .where((e) => e.cid == cid); - DateTime _lastTypingEvent; + DateTime? _lastTypingEvent; /// First of the [EventType.typingStart] and [EventType.typingStop] events /// based on the users keystrokes. Call this on every keystroke. - Future keyStroke([String parentId]) async { + Future keyStroke([String? parentId]) async { if (config?.typingEvents == false) { return; } @@ -1166,7 +1207,7 @@ class Channel { final now = DateTime.now(); if (_lastTypingEvent == null || - now.difference(_lastTypingEvent).inSeconds >= 2) { + now.difference(_lastTypingEvent!).inSeconds >= 2) { _lastTypingEvent = now; await sendEvent(Event( type: EventType.typingStart, @@ -1176,7 +1217,7 @@ class Channel { } /// Sets last typing to null and sends the typing.stop event - Future stopTyping([String parentId]) async { + Future stopTyping([String? parentId]) async { if (config?.typingEvents == false) { return; } @@ -1191,15 +1232,15 @@ class Channel { /// Call this method to dispose the channel client void dispose() { - state.dispose(); + state?.dispose(); } void _checkInitialized() { - if (!_initializedCompleter.isCompleted) { - throw Exception( - "Channel $cid hasn't been initialized yet. Make sure to call .watch()" - ' or to instantiate the client using [Channel.fromState]'); - } + assert( + _initializedCompleter.isCompleted, + "Channel $cid hasn't been initialized yet. Make sure to call .watch()" + ' or to instantiate the client using [Channel.fromState]', + ); } } @@ -1211,12 +1252,14 @@ class ChannelClientState { ChannelState channelState, //ignore: unnecessary_parenthesis ) : _debouncedUpdatePersistenceChannelState = ((ChannelState state) => - _channel?._client?.chatPersistenceClient + _channel._client.chatPersistenceClient ?.updateChannelState(state)) .debounced(const Duration(seconds: 1)) { - retryQueue = RetryQueue( + _retryQueue = RetryQueue( channel: _channel, - logger: Logger('RETRY QUEUE ${_channel.cid}'), + logger: _channel.client.detachedLogger( + '⟳ (${generateHash([_channel.cid])})', + ), ); _checkExpiredAttachmentMessages(channelState); @@ -1252,13 +1295,13 @@ class ChannelClientState { _startCleaningPinnedMessages(); _channel._client.chatPersistenceClient - ?.getChannelThreads(_channel.cid) - ?.then((threads) { + ?.getChannelThreads(_channel.cid!) + .then((threads) { _threads = threads; - })?.then((_) { + }).then((_) { _channel._client.chatPersistenceClient - ?.getChannelStateByCid(_channel.cid) - ?.then((state) { + ?.getChannelStateByCid(_channel.cid!) + .then((state) { // Replacing the persistence state members with the latest // `channelState.members` as they may have changes over the time. updateChannelState(state.copyWith(members: channelState.members)); @@ -1270,21 +1313,20 @@ class ChannelClientState { final _subscriptions = []; void _computeInitialUnread() { - final userRead = channelState?.read?.firstWhere( - (r) => r.user.id == _channel._client.state?.user?.id, - orElse: () => null, + final userRead = channelState.read.firstWhereOrNull( + (r) => r.user.id == _channel._client.state.user?.id, ); if (userRead != null) { - _unreadCountController.add(userRead.unreadMessages ?? 0); + unreadCount = userRead.unreadMessages; } } void _checkExpiredAttachmentMessages(ChannelState channelState) { final expiredAttachmentMessagesId = channelState.messages - ?.where((m) => + .where((m) => !_updatedMessagesIds.contains(m.id) && - m.attachments?.isNotEmpty == true && - m.attachments?.any((e) { + m.attachments.isNotEmpty == true && + m.attachments.any((e) { final url = e.imageUrl ?? e.assetUrl; if (url == null || !url.contains('')) { return false; @@ -1295,13 +1337,13 @@ class ChannelClientState { return false; } final expiration = - DateTime.parse(uri.queryParameters['Expires']); + DateTime.parse(uri.queryParameters['Expires']!); return expiration.isBefore(DateTime.now()); }) == true) - ?.map((e) => e.id) - ?.toList(); - if (expiredAttachmentMessagesId?.isNotEmpty == true) { + .map((e) => e.id) + .toList(); + if (expiredAttachmentMessagesId.isNotEmpty == true) { _channel.getMessagesById(expiredAttachmentMessagesId); _updatedMessagesIds.addAll(expiredAttachmentMessagesId); } @@ -1313,7 +1355,7 @@ class ChannelClientState { updateChannelState(channelState.copyWith( members: [ ...channelState.members, - member, + member!, ], )); })); @@ -1324,14 +1366,14 @@ class ChannelClientState { final user = e.user; updateChannelState(channelState.copyWith( members: List.from( - channelState.members..removeWhere((m) => m.userId == user.id)), + channelState.members..removeWhere((m) => m.userId == user!.id)), )); })); } void _listenChannelUpdated() { _subscriptions.add(_channel.on(EventType.channelUpdated).listen((Event e) { - final channel = e.channel; + final channel = e.channel!; updateChannelState(channelState.copyWith( channel: channel, members: channel.members, @@ -1343,7 +1385,7 @@ class ChannelClientState { _subscriptions.add(_channel .on(EventType.channelTruncated, EventType.notificationChannelTruncated) .listen((event) async { - final channel = event.channel; + final channel = event.channel!; await _channel._client.chatPersistenceClient ?.deleteMessageByCid(channel.cid); truncate(); @@ -1365,7 +1407,7 @@ class ChannelClientState { BehaviorSubject.seeded(true); /// The retry queue associated to this channel - RetryQueue retryQueue; + late final RetryQueue _retryQueue; /// Retry failed message Future retryFailedMessages() async { @@ -1373,7 +1415,6 @@ class ChannelClientState { [...messages, ...threads.values.expand((v) => v)] .where( (message) => - message.status != null && message.status != MessageSendingStatus.sent && message.createdAt.isBefore( DateTime.now().subtract( @@ -1385,14 +1426,14 @@ class ChannelClientState { ) .toList(); - retryQueue.add(failedMessages); + _retryQueue.add(failedMessages); } void _listenReactionDeleted() { _subscriptions.add(_channel.on(EventType.reactionDeleted).listen((event) { - final userId = _channel.client.state.user.id; - final message = event.message.copyWith( - ownReactions: [...event.message.latestReactions] + final userId = _channel.client.state.user!.id; + final message = event.message!.copyWith( + ownReactions: [...event.message!.latestReactions!] ..removeWhere((it) => it.userId != userId), ); addMessage(message); @@ -1401,9 +1442,9 @@ class ChannelClientState { void _listenReactions() { _subscriptions.add(_channel.on(EventType.reactionNew).listen((event) { - final userId = _channel.client.state.user.id; - final message = event.message.copyWith( - ownReactions: [...event.message.latestReactions] + final userId = _channel.client.state.user!.id; + final message = event.message!.copyWith( + ownReactions: [...event.message!.latestReactions!] ..removeWhere((it) => it.userId != userId), ); addMessage(message); @@ -1417,9 +1458,9 @@ class ChannelClientState { EventType.reactionUpdated, ) .listen((event) { - final userId = _channel.client.state.user.id; - final message = event.message.copyWith( - ownReactions: [...event.message.latestReactions] + final userId = _channel.client.state.user!.id; + final message = event.message!.copyWith( + ownReactions: [...event.message!.latestReactions!] ..removeWhere((it) => it.userId != userId), ); addMessage(message); @@ -1427,7 +1468,7 @@ class ChannelClientState { if (message.pinned == true) { _channelState = _channelState.copyWith( pinnedMessages: [ - ..._channelState.pinnedMessages ?? [], + ..._channelState.pinnedMessages, message, ], ); @@ -1437,7 +1478,7 @@ class ChannelClientState { void _listenMessageDeleted() { _subscriptions.add(_channel.on(EventType.messageDeleted).listen((event) { - final message = event.message; + final message = event.message!; addMessage(message); })); } @@ -1449,14 +1490,14 @@ class ChannelClientState { EventType.notificationMessageNew, ) .listen((event) { - final message = event.message; + final message = event.message!; if (isUpToDate || (message.parentId != null && message.showInChannel != true)) { addMessage(message); } if (_countMessageAsUnread(message)) { - _unreadCountController.add(_unreadCountController.value + 1); + unreadCount += 1; } })); } @@ -1467,7 +1508,7 @@ class ChannelClientState { final newMessages = List.from(_channelState.messages); final oldIndex = newMessages.indexWhere((m) => m.id == message.id); if (oldIndex != -1) { - Message m; + Message? m; if (message.quotedMessageId != null && message.quotedMessage == null) { final oldMessage = newMessages[oldIndex]; m = message.copyWith( @@ -1481,19 +1522,19 @@ class ChannelClientState { _channelState = _channelState.copyWith( messages: newMessages, - channel: _channelState.channel.copyWith( + channel: _channelState.channel?.copyWith( lastMessageAt: message.createdAt, ), ); } if (message.parentId != null) { - updateThreadInfo(message.parentId, [message]); + updateThreadInfo(message.parentId!, [message]); } } void _listenReadEvents() { - if (_channel.config?.readEvents == false) { + if (_channelState.channel?.config.readEvents == false) { return; } @@ -1505,18 +1546,19 @@ class ChannelClientState { ) .listen( (event) { - final readList = List.from(_channelState?.read ?? []); + final readList = List.from(_channelState.read); final userReadIndex = - read?.indexWhere((r) => r.user.id == event.user.id); + read?.indexWhere((r) => r.user.id == event.user!.id); if (userReadIndex != null && userReadIndex != -1) { final userRead = readList.removeAt(userReadIndex); - if (userRead.user?.id == _channel._client.state.user.id) { - _unreadCountController.add(0); + if (userRead.user.id == _channel._client.state.user!.id) { + unreadCount = 0; } readList.add(Read( - user: event.user, + user: event.user!, lastRead: event.createdAt, + unreadMessages: event.totalUnreadCount ?? 0, )); _channelState = _channelState.copyWith(read: readList); } @@ -1529,44 +1571,45 @@ class ChannelClientState { List get messages => _channelState.messages; /// Channel message list as a stream - Stream> get messagesStream => - channelStateStream.map((cs) => cs.messages); + Stream?> get messagesStream => channelStateStream + .map((cs) => cs.messages) + .distinct(const ListEquality().equals); /// Channel pinned message list - List get pinnedMessages => _channelState.pinnedMessages?.toList(); + List? get pinnedMessages => _channelState.pinnedMessages.toList(); /// Channel pinned message list as a stream - Stream> get pinnedMessagesStream => - channelStateStream.map((cs) => cs.pinnedMessages?.toList()); + Stream?> get pinnedMessagesStream => + channelStateStream.map((cs) => cs.pinnedMessages.toList()); /// Get channel last message - Message get lastMessage => _channelState.messages?.isNotEmpty == true + Message? get lastMessage => _channelState.messages.isNotEmpty == true ? _channelState.messages.last : null; /// Get channel last message - Stream get lastMessageStream => messagesStream - .map((event) => event?.isNotEmpty == true ? event.last : null); + Stream get lastMessageStream => messagesStream + .map((event) => event?.isNotEmpty == true ? event!.last : null); /// Channel members list List get members => _channelState.members - .map((e) => e.copyWith(user: _channel.client.state.users[e.user.id])) + .map((e) => e.copyWith(user: _channel.client.state.users[e.user!.id])) .toList(); /// Channel members list as a stream Stream> get membersStream => CombineLatestStream.combine2< - List, Map, List>( + List?, Map, List>( channelStateStream.map((cs) => cs.members), _channel.client.state.usersStream, (members, users) => - members.map((e) => e.copyWith(user: users[e.user.id])).toList(), - ); + members!.map((e) => e!.copyWith(user: users[e.user!.id])).toList(), + ).distinct(const ListEquality().equals); /// Channel watcher count - int get watcherCount => _channelState.watcherCount; + int? get watcherCount => _channelState.watcherCount; /// Channel watcher count as a stream - Stream get watcherCountStream => + Stream get watcherCountStream => channelStateStream.map((cs) => cs.watcherCount); /// Channel watchers list @@ -1575,37 +1618,38 @@ class ChannelClientState { .toList(); /// Channel watchers list as a stream - Stream> get watchersStream => - CombineLatestStream.combine2, Map, List>( + Stream> get watchersStream => CombineLatestStream.combine2< + List?, Map, List>( channelStateStream.map((cs) => cs.watchers), _channel.client.state.usersStream, - (watchers, users) => watchers.map((e) => users[e.id] ?? e).toList(), + (watchers, users) => watchers!.map((e) => users[e.id] ?? e).toList(), ); /// Channel read list - List get read => _channelState.read; + List? get read => _channelState.read; /// Channel read list as a stream - Stream> get readStream => channelStateStream.map((cs) => cs.read); + Stream?> get readStream => channelStateStream.map((cs) => cs.read); final BehaviorSubject _unreadCountController = BehaviorSubject.seeded(0); + set unreadCount(int value) => _unreadCountController.add(value); + /// Unread count getter as a stream - Stream get unreadCountStream => _unreadCountController.stream; + Stream get unreadCountStream => _unreadCountController.stream.distinct(); /// Unread count getter int get unreadCount => _unreadCountController.value; bool _countMessageAsUnread(Message message) { - final userId = _channel.client.state?.user?.id; - final userIsMuted = _channel.client.state?.user?.mutes?.firstWhere( - (m) => m.user?.id == message.user.id, - orElse: () => null, + final userId = _channel.client.state.user?.id; + final userIsMuted = _channel.client.state.user?.mutes.firstWhereOrNull( + (m) => m.user.id == message.user?.id, ) != null; return message.silent != true && message.shadowed != true && - message.user.id != userId && + message.user?.id != userId && !userIsMuted; } @@ -1618,12 +1662,12 @@ class ChannelClientState { ...newThreads[parentId] ?.where( (newMessage) => !messages.any((m) => m.id == newMessage.id)) - ?.toList() ?? + .toList() ?? [], ...messages, ]; - newThreads[parentId].sort(_sortByCreatedAt); + newThreads[parentId]!.sort(_sortByCreatedAt); } else { newThreads[parentId] = messages; } @@ -1643,40 +1687,37 @@ class ChannelClientState { /// Update channelState with updated information void updateChannelState(ChannelState updatedState) { final newMessages = [ - ...updatedState?.messages ?? [], - ..._channelState?.messages - ?.where((m) => - updatedState.messages - ?.any((newMessage) => newMessage.id == m.id) != - true) - ?.toList() ?? - [], + ...updatedState.messages, + ..._channelState.messages + .where((m) => + updatedState.messages + .any((newMessage) => newMessage.id == m.id) != + true) + .toList(), ]..sort(_sortByCreatedAt); final newWatchers = [ - ...updatedState?.watchers ?? [], - ..._channelState?.watchers - ?.where((w) => - updatedState.watchers - ?.any((newWatcher) => newWatcher.id == w.id) != - true) - ?.toList() ?? - [], + ...updatedState.watchers, + ..._channelState.watchers + .where((w) => + updatedState.watchers + .any((newWatcher) => newWatcher.id == w.id) != + true) + .toList(), ]; final newMembers = [ - ...updatedState?.members ?? [], + ...updatedState.members, ]; final newReads = [ - ...updatedState?.read ?? [], - ..._channelState?.read - ?.where((r) => - updatedState.read - ?.any((newRead) => newRead.user.id == r.user.id) != - true) - ?.toList() ?? - [], + ...updatedState.read, + ..._channelState.read + .where((r) => + updatedState.read + .any((newRead) => newRead.user.id == r.user.id) != + true) + .toList(), ]; _checkExpiredAttachmentMessages(updatedState); @@ -1692,17 +1733,8 @@ class ChannelClientState { ); } - int _sortByCreatedAt(a, b) { - if (a.createdAt == null) { - return 1; - } - - if (b.createdAt == null) { - return -1; - } - - return a.createdAt.compareTo(b.createdAt); - } + int _sortByCreatedAt(Message a, Message b) => + a.createdAt.compareTo(b.createdAt); /// The channel state related to this client ChannelState get _channelState => _channelStateController.value; @@ -1712,17 +1744,18 @@ class ChannelClientState { /// The channel state related to this client ChannelState get channelState => _channelStateController.value; - BehaviorSubject _channelStateController; + late BehaviorSubject _channelStateController; final Debounce _debouncedUpdatePersistenceChannelState; set _channelState(ChannelState v) { _channelStateController.add(v); - _debouncedUpdatePersistenceChannelState?.call([v]); + _debouncedUpdatePersistenceChannelState.call([v]); } /// The channel threads related to this channel - Map> get threads => _threadsController.value; + Map> get threads => + _threadsController.value.map((key, value) => MapEntry(key, value)); /// The channel threads related to this channel as a stream Stream>> get threadsStream => @@ -1731,26 +1764,28 @@ class ChannelClientState { BehaviorSubject.seeded({}); set _threads(Map> v) { - _channel._client.chatPersistenceClient?.updateMessages( - _channel.cid, + _channel.client.chatPersistenceClient?.updateMessages( + _channel.cid!, v.values.expand((v) => v).toList(), ); _threadsController.add(v); } /// Channel related typing users last value - List get typingEvents => _typingEventsController.value; + Map get typingEvents => _typingEventsController.value; /// Channel related typing users stream - Stream> get typingEventsStream => _typingEventsController.stream; - final BehaviorSubject> _typingEventsController = - BehaviorSubject.seeded([]); + Stream> get typingEventsStream => + _typingEventsController.stream; + + final BehaviorSubject> _typingEventsController = + BehaviorSubject.seeded({}); final Channel _channel; - final Map _typings = {}; + final Map _typings = {}; void _listenTypingEvents() { - if (_channel.config?.typingEvents == false) { + if (_channelState.channel?.config.typingEvents == false) { return; } @@ -1758,9 +1793,12 @@ class ChannelClientState { ..add( _channel.on(EventType.typingStart).listen( (event) { - if (event.user.id != _channel.client.state.user.id) { - _typings[event.user] = DateTime.now(); - _typingEventsController.add(_typings.keys.toList()); + if (event.user != null) { + final user = event.user!; + if (user.id != _channel.client.state.user?.id) { + _typings[user] = event; + _typingEventsController.add(_typings); + } } }, ), @@ -1768,9 +1806,12 @@ class ChannelClientState { ..add( _channel.on(EventType.typingStop).listen( (event) { - if (event.user.id != _channel.client.state.user.id) { - _typings.remove(event.user); - _typingEventsController.add(_typings.keys.toList()); + if (event.user != null) { + final user = event.user!; + if (user.id != _channel.client.state.user?.id) { + _typings.remove(event.user); + _typingEventsController.add(_typings); + } } }, ), @@ -1780,12 +1821,12 @@ class ChannelClientState { .on() .where((event) => event.user != null && - members?.any((m) => m.userId == event.user.id) == true) + members.any((m) => m.userId == event.user!.id) == true) .listen( (event) { final newMembers = List.from(members); final oldMemberIndex = - newMembers.indexWhere((m) => m.userId == event.user.id); + newMembers.indexWhere((m) => m.userId == event.user!.id); if (oldMemberIndex > -1) { final oldMember = newMembers.removeAt(oldMemberIndex); updateChannelState(ChannelState( @@ -1802,10 +1843,10 @@ class ChannelClientState { ); } - Timer _cleaningTimer; + Timer? _cleaningTimer; void _startCleaning() { - if (_channel.config?.typingEvents == false) { + if (_channelState.channel?.config.typingEvents == false) { return; } @@ -1813,7 +1854,7 @@ class ChannelClientState { final now = DateTime.now(); if (_channel._lastTypingEvent != null && - now.difference(_channel._lastTypingEvent).inSeconds > 1) { + now.difference(_channel._lastTypingEvent!).inSeconds > 1) { _channel.stopTyping(); } @@ -1821,15 +1862,14 @@ class ChannelClientState { }); } - Timer _pinnedMessagesTimer; + late Timer _pinnedMessagesTimer; void _startCleaningPinnedMessages() { _pinnedMessagesTimer = Timer.periodic(const Duration(seconds: 30), (_) { final now = DateTime.now(); var expiredMessages = channelState.pinnedMessages - ?.where((m) => m.pinExpires?.isBefore(now) == true) - ?.toList() ?? - []; + .where((m) => m.pinExpires?.isBefore(now) == true) + .toList(); if (expiredMessages.isNotEmpty) { expiredMessages = expiredMessages .map((m) => m.copyWith( @@ -1839,7 +1879,7 @@ class ChannelClientState { .toList(); updateChannelState(_channelState.copyWith( - pinnedMessages: pinnedMessages.where(_pinIsValid()).toList(), + pinnedMessages: pinnedMessages!.where(_pinIsValid()).toList(), messages: expiredMessages, )); } @@ -1848,13 +1888,14 @@ class ChannelClientState { void _clean() { final now = DateTime.now(); - _typings.forEach((user, lastTypingEvent) { - if (now.difference(lastTypingEvent).inSeconds > 7) { + _typings.forEach((user, event) { + if (now.difference(event.createdAt).inSeconds > 7) { _channel.client.handleEvent( Event( type: EventType.typingStop, user: user, cid: _channel.cid, + parentId: event.parentId, ), ); } @@ -1863,14 +1904,14 @@ class ChannelClientState { /// Call this method to dispose this object void dispose() { - _debouncedUpdatePersistenceChannelState?.cancel(); + _debouncedUpdatePersistenceChannelState.cancel(); _unreadCountController.close(); - retryQueue.dispose(); + _retryQueue.dispose(); _subscriptions.forEach((s) => s.cancel()); _channelStateController.close(); _isUpToDateController.close(); _threadsController.close(); - _cleaningTimer.cancel(); + _cleaningTimer?.cancel(); _pinnedMessagesTimer.cancel(); _typingEventsController.close(); } @@ -1878,5 +1919,5 @@ class ChannelClientState { bool Function(Message) _pinIsValid() { final now = DateTime.now(); - return (Message m) => m.pinExpires.isAfter(now); + return (Message m) => m.pinExpires!.isAfter(now); } diff --git a/packages/stream_chat/lib/src/client/client.dart b/packages/stream_chat/lib/src/client/client.dart new file mode 100644 index 00000000..050d36f1 --- /dev/null +++ b/packages/stream_chat/lib/src/client/client.dart @@ -0,0 +1,1480 @@ +// ignore_for_file: unnecessary_getters_setters + +import 'dart:async'; + +import 'package:dio/dio.dart'; +import 'package:logging/logging.dart'; +import 'package:rxdart/rxdart.dart'; +import 'package:stream_chat/src/client/channel.dart'; +import 'package:stream_chat/src/client/retry_policy.dart'; +import 'package:stream_chat/src/core/api/attachment_file_uploader.dart'; +import 'package:stream_chat/src/core/api/requests.dart'; +import 'package:stream_chat/src/core/api/responses.dart'; +import 'package:stream_chat/src/core/api/stream_chat_api.dart'; +import 'package:stream_chat/src/core/error/error.dart'; +import 'package:stream_chat/src/core/http/connection_id_manager.dart'; +import 'package:stream_chat/src/core/http/stream_http_client.dart'; +import 'package:stream_chat/src/core/http/token.dart'; +import 'package:stream_chat/src/core/http/token_manager.dart'; +import 'package:stream_chat/src/core/models/attachment_file.dart'; +import 'package:stream_chat/src/core/models/channel_model.dart'; +import 'package:stream_chat/src/core/models/channel_state.dart'; +import 'package:stream_chat/src/core/models/event.dart'; +import 'package:stream_chat/src/core/models/filter.dart'; +import 'package:stream_chat/src/core/models/member.dart'; +import 'package:stream_chat/src/core/models/message.dart'; +import 'package:stream_chat/src/core/models/own_user.dart'; +import 'package:stream_chat/src/core/models/user.dart'; +import 'package:stream_chat/src/core/util/utils.dart'; +import 'package:stream_chat/src/db/chat_persistence_client.dart'; +import 'package:stream_chat/src/event_type.dart'; +import 'package:stream_chat/src/location.dart'; +import 'package:stream_chat/src/ws/connection_status.dart'; +import 'package:stream_chat/src/ws/websocket.dart'; + +/// Handler function used for logging records. Function requires a single +/// [LogRecord] as the only parameter. +typedef LogHandlerFunction = void Function(LogRecord record); + +final _levelEmojiMapper = { + Level.INFO: 'ℹ️', + Level.WARNING: '⚠️', + Level.SEVERE: '🚨', +}; + +/// The official Dart client for Stream Chat, +/// a service for building chat applications. +/// This library can be used on any Dart project and on both mobile and web apps +/// with Flutter. +/// +/// You can sign up for a Stream account at https://getstream.io/chat/ +/// +/// The Chat client will manage API call, event handling and manage the +/// websocket connection to Stream Chat servers. +/// +/// ```dart +/// final client = StreamChatClient("stream-chat-api-key"); +/// ``` +class StreamChatClient { + /// Create a client instance with default options. + /// You should only create the client once and re-use it across your + /// application. + StreamChatClient( + String apiKey, { + this.logLevel = Level.WARNING, + LogHandlerFunction? logHandlerFunction, + RetryPolicy? retryPolicy, + Location? location, + @Deprecated('Use location to change baseUrl instead') String? baseURL, + Duration connectTimeout = const Duration(seconds: 6), + Duration receiveTimeout = const Duration(seconds: 6), + StreamChatApi? chatApi, + WebSocket? ws, + AttachmentFileUploader? attachmentFileUploader, + }) { + this.logHandlerFunction = logHandlerFunction ?? _defaultLogHandler; + logger.info('Initiating new StreamChatClient'); + + final options = StreamHttpClientOptions( + baseUrl: baseURL, + location: location, + connectTimeout: connectTimeout, + receiveTimeout: receiveTimeout, + ); + + _chatApi = chatApi ?? + StreamChatApi( + apiKey, + options: options, + tokenManager: _tokenManager, + connectionIdManager: _connectionIdManager, + attachmentFileUploader: attachmentFileUploader, + logger: detachedLogger('🕸️'), + ); + + _ws = ws ?? + WebSocket( + apiKey: apiKey, + baseUrl: options.baseUrl, + tokenManager: _tokenManager, + handler: handleEvent, + logger: detachedLogger('🔌'), + ); + + _retryPolicy = retryPolicy ?? + RetryPolicy( + shouldRetry: (_, attempt, __) => attempt < 5, + retryTimeout: (_, attempt, __) => Duration(seconds: attempt), + ); + + state = ClientState(this); + } + + late final StreamChatApi _chatApi; + late final WebSocket _ws; + + /// This client state + late ClientState state; + + final _tokenManager = TokenManager(); + final _connectionIdManager = ConnectionIdManager(); + + set chatPersistenceClient(ChatPersistenceClient? value) { + _originalChatPersistenceClient = value; + } + + ChatPersistenceClient? _originalChatPersistenceClient; + + /// Chat persistence client + ChatPersistenceClient? get chatPersistenceClient => _chatPersistenceClient; + + ChatPersistenceClient? _chatPersistenceClient; + + /// Whether the chat persistence is available or not + bool get persistenceEnabled => _chatPersistenceClient != null; + + late final RetryPolicy _retryPolicy; + + /// sync state of the channels present inside state, defaults to false + bool _synced = false; + + /// the last dateTime at the which all the channels were synced + DateTime? _lastSyncedAt; + + /// The retry policy options getter + RetryPolicy get retryPolicy => _retryPolicy; + + /// By default the Chat client will write all messages with level Warn or + /// Error to stdout. + /// + /// During development you might want to enable more logging information, + /// you can change the default log level when constructing the client. + /// + /// ```dart + /// final client = StreamChatClient("stream-chat-api-key", + /// logLevel: Level.INFO); + /// ``` + final Level logLevel; + + /// Client specific logger instance. + /// Refer to the class [Logger] to learn more about the specific + /// implementation. + late final Logger logger = detachedLogger('📡'); + + /// A function that has a parameter of type [LogRecord]. + /// This is called on every new log record. + /// By default the client will use the handler returned by + /// [_getDefaultLogHandler]. + /// Setting it you can handle the log messages directly instead of have them + /// written to stdout, + /// this is very convenient if you use an error tracking tool or if you want + /// to centralize your logs into one facility. + /// + /// ```dart + /// myLogHandlerFunction = (LogRecord record) { + /// // do something with the record (ie. send it to Sentry or Fabric) + /// } + /// + /// final client = StreamChatClient("stream-chat-api-key", + /// logHandlerFunction: myLogHandlerFunction); + ///``` + late LogHandlerFunction logHandlerFunction; + + StreamSubscription? _connectionStatusSubscription; + + final _eventController = BehaviorSubject(); + + /// Stream of [Event] coming from [_ws] connection + /// Listen to this or use the [on] method to filter specific event types + Stream get eventStream => _eventController.stream; + + final _wsConnectionStatusController = + BehaviorSubject.seeded(ConnectionStatus.disconnected); + + set _wsConnectionStatus(ConnectionStatus status) => + _wsConnectionStatusController.add(status); + + /// The current status value of the [_ws] connection + ConnectionStatus get wsConnectionStatus => + _wsConnectionStatusController.value; + + /// This notifies the connection status of the [_ws] connection. + /// Listen to this to get notified when the [_ws] tries to reconnect. + Stream get wsConnectionStatusStream => + _wsConnectionStatusController.stream.distinct(); + + LogHandlerFunction get _defaultLogHandler => (LogRecord record) { + print( + '${record.time} ' + '${_levelEmojiMapper[record.level] ?? record.level.name} ' + '${record.loggerName} ${record.message} ', + ); + if (record.error != null) print(record.error); + if (record.stackTrace != null) print(record.stackTrace); + }; + + /// + Logger detachedLogger(String name) => Logger.detached(name) + ..level = logLevel + ..onRecord.listen(logHandlerFunction); + + /// Connects the current user, this triggers a connection to the API. + /// It returns a [Future] that resolves when the connection is setup. + /// Pass [connectWebSocket]: false, if you want to connect to websocket + /// at a later stage or use the client in connection-less mode + Future connectUser( + User user, + String token, { + bool connectWebSocket = true, + }) => + _connectUser( + user, + token: Token.fromRawValue(token), + connectWebSocket: connectWebSocket, + ); + + /// Connects the current user using the [tokenProvider] to fetch the token. + /// It returns a [Future] that resolves when the connection is setup. + Future connectUserWithProvider( + User user, + TokenProvider tokenProvider, { + bool connectWebSocket = true, + }) => + _connectUser( + user, + provider: tokenProvider, + connectWebSocket: connectWebSocket, + ); + + /// Connects the current user with an anonymous id, this triggers a connection + /// to the API. It returns a [Future] that resolves when the connection is + /// setup. + Future connectAnonymousUser({ + bool connectWebSocket = true, + }) async { + final token = Token.anonymous(); + final user = OwnUser(id: token.userId); + return _connectUser( + user, + token: token, + connectWebSocket: connectWebSocket, + ); + } + + /// Connects the current user as guest, this triggers a connection to the API. + /// It returns a [Future] that resolves when the connection is setup. + Future connectGuestUser( + User user, { + bool connectWebSocket = true, + }) async { + final userId = user.id; + final anonymousToken = Token.anonymous(userId: userId); + + // setting anonymous token so that getGuestUser works + _tokenManager.setTokenOrProvider(userId, token: anonymousToken); + + final guestUser = await _chatApi.guest.getGuestUser(user); + + // resetting tokenManager after successful request + _tokenManager.reset(); + + final guestUserToken = Token.fromRawValue(guestUser.accessToken); + return _connectUser( + guestUser.user, + token: guestUserToken, + connectWebSocket: connectWebSocket, + ); + } + + Future _connectUser( + User user, { + Token? token, + TokenProvider? provider, + bool connectWebSocket = true, + }) async { + if (_ws.connectionCompleter?.isCompleted == false) { + throw const StreamChatError( + 'User already getting connected, try calling `disconnectUser` ' + 'before trying to connect again', + ); + } + + logger.info('setting user : ${user.id}'); + + await _tokenManager.setTokenOrProvider( + user.id, + token: token, + provider: provider, + ); + + final ownUser = OwnUser.fromUser(user); + state.user = ownUser; + + if (!connectWebSocket) { + return ownUser; + } + + try { + if (_originalChatPersistenceClient != null) { + _chatPersistenceClient = _originalChatPersistenceClient; + await _chatPersistenceClient!.connect(ownUser.id); + } + final res = await openConnection(); + return res; + } catch (e, stk) { + if (e is StreamWebSocketError && e.isRetriable) { + final event = await _chatPersistenceClient?.getConnectionInfo(); + if (event != null) return event.me?.merge(ownUser) ?? ownUser; + } + logger.severe('error connecting user : ${ownUser.id}', e, stk); + rethrow; + } + } + + /// Creates a new WebSocket connection with the current user. + Future openConnection() async { + assert( + state.user != null, + 'User is not set on client, ' + 'use `connectUser` or `connectAnonymousUser` instead', + ); + + final user = state.user!; + + logger.info('Opening web-socket connection for ${user.id}'); + + if (wsConnectionStatus == ConnectionStatus.connecting) { + throw StreamChatError('Connection already in progress for ${user.id}'); + } + + if (wsConnectionStatus == ConnectionStatus.connected) { + throw StreamChatError('Connection already available for ${user.id}'); + } + + _wsConnectionStatus = ConnectionStatus.connecting; + + // skipping `ws` seed connection status -> ConnectionStatus.disconnected + // otherwise `client.wsConnectionStatusStream` will emit in order + // 1. ConnectionStatus.disconnected -> client seed status + // 2. ConnectionStatus.connecting -> client connecting status + // 3. ConnectionStatus.disconnected -> ws seed status + _connectionStatusSubscription = + _ws.connectionStatusStream.skip(1).listen(_connectionStatusHandler); + + try { + final event = await _ws.connect(user); + return event.me?.merge(user) ?? user; + } catch (e, stk) { + logger.severe('error connecting ws', e, stk); + rethrow; + } + } + + /// Disconnects the [_ws] connection, + /// without removing the user set on client. + /// + /// This will not trigger default auto-retry mechanism for reconnection. + /// You need to call [openConnection] to reconnect to [_ws]. + void closeConnection() { + if (wsConnectionStatus == ConnectionStatus.disconnected) return; + + logger.info('Closing web-socket connection for ${state.user?.id}'); + _wsConnectionStatus = ConnectionStatus.disconnected; + + _connectionStatusSubscription?.cancel(); + _connectionStatusSubscription = null; + + _ws.disconnect(); + } + + void _handleHealthCheckEvent(Event event) { + final user = event.me; + if (user != null) state.user = user; + + final connectionId = event.connectionId; + if (connectionId != null) { + _connectionIdManager.setConnectionId(connectionId); + _chatPersistenceClient?.updateConnectionInfo(event); + } + } + + /// Method called to add a new event to the [_eventController]. + void handleEvent(Event event) { + if (event.type == EventType.healthCheck) { + return _handleHealthCheckEvent(event); + } + if (!event.isLocal && _synced) { + _lastSyncedAt = event.createdAt; + _chatPersistenceClient?.updateLastSyncAt(event.createdAt); + } + state.updateUser(event.user); + return _eventController.add(event); + } + + void _connectionStatusHandler(ConnectionStatus status) async { + final currentState = _wsConnectionStatus = status; + + handleEvent(Event( + type: EventType.connectionChanged, + online: status == ConnectionStatus.connected, + )); + + if (currentState == ConnectionStatus.connected) { + // connection recovered + final cids = state.channels.keys.toList(growable: false); + if (cids.isNotEmpty) { + await queryChannelsOnline( + filter: Filter.in_('cid', cids), + paginationParams: const PaginationParams(limit: 30), + ); + if (persistenceEnabled) { + await sync(cids: cids, lastSyncAt: _lastSyncedAt); + } + } + handleEvent(Event( + type: EventType.connectionRecovered, + online: true, + )); + } else { + _synced = false; + } + } + + /// Stream of [Event] coming from [_ws] connection + /// Pass an eventType as parameter in order to filter just a type of event + Stream on([ + String? eventType, + String? eventType2, + String? eventType3, + String? eventType4, + ]) { + if (eventType == null) return eventStream; + return eventStream.where((event) => + event.type == eventType || + event.type == eventType2 || + event.type == eventType3 || + event.type == eventType4); + } + + /// Get the events missed while offline to sync the offline storage + /// Will automatically fetch [cids] and [lastSyncedAt] if [persistenceEnabled] + Future sync({List? cids, DateTime? lastSyncAt}) async { + cids ??= await _chatPersistenceClient?.getChannelCids(); + if (cids == null || cids.isEmpty) { + _synced = true; + return; + } + + lastSyncAt ??= await _chatPersistenceClient?.getLastSyncAt(); + if (lastSyncAt == null) { + _synced = true; + return; + } + + try { + final res = await _chatApi.general.sync(cids, lastSyncAt); + final events = res.events + ..sort((a, b) => a.createdAt.compareTo(b.createdAt)); + + for (final event in events) { + logger.fine('event.type: ${event.type}'); + final messageText = event.message?.text; + if (messageText != null) { + logger.fine('event.message.text: $messageText'); + } + handleEvent(event); + } + + _synced = true; + final now = DateTime.now(); + _lastSyncedAt = now; + _chatPersistenceClient?.updateLastSyncAt(now); + } catch (e, stk) { + _synced = false; + logger.severe('Error during sync', e, stk); + } + } + + final _queryChannelsStreams = >>{}; + + /// Requests channels with a given query. + Stream> queryChannels({ + Filter? filter, + List>? sort, + bool state = true, + bool watch = true, + bool presence = false, + int? memberLimit, + int? messageLimit, + PaginationParams paginationParams = const PaginationParams(), + bool waitForConnect = true, + }) async* { + if (!_connectionIdManager.hasConnectionId) { + // ignore: parameter_assignments + watch = false; + } + + final hash = generateHash([ + filter, + sort, + state, + watch, + presence, + memberLimit, + messageLimit, + paginationParams, + ]); + + if (_queryChannelsStreams.containsKey(hash)) { + yield await _queryChannelsStreams[hash]!; + } else { + final channels = await queryChannelsOffline( + filter: filter, + sort: sort, + paginationParams: paginationParams, + ); + if (channels.isNotEmpty) yield channels; + + try { + final newQueryChannelsFuture = queryChannelsOnline( + filter: filter, + sort: sort, + state: state, + watch: watch, + presence: presence, + memberLimit: memberLimit, + messageLimit: messageLimit, + paginationParams: paginationParams, + waitForConnect: waitForConnect, + ).whenComplete(() { + _queryChannelsStreams.remove(hash); + }); + + _queryChannelsStreams[hash] = newQueryChannelsFuture; + + yield await newQueryChannelsFuture; + } catch (_) { + if (channels.isEmpty) rethrow; + } + } + } + + /// Requests channels with a given query from the API. + Future> queryChannelsOnline({ + Filter? filter, + List>? sort, + bool state = true, + bool watch = true, + bool presence = false, + int? memberLimit, + int? messageLimit, + bool waitForConnect = true, + PaginationParams paginationParams = const PaginationParams(), + }) async { + if (waitForConnect) { + if (_ws.connectionCompleter?.isCompleted == false) { + logger.info('awaiting connection completer'); + await _ws.connectionCompleter?.future; + } + if (wsConnectionStatus != ConnectionStatus.connected) { + throw const StreamChatError( + 'You cannot use queryChannels without an active connection. ' + 'Please call `connectUser` to connect the client.', + ); + } + } + + if (!_connectionIdManager.hasConnectionId) { + // ignore: parameter_assignments + watch = false; + } + + logger.info('Query channel start'); + final res = await _chatApi.channel.queryChannels( + filter: filter, + sort: sort, + state: state, + watch: watch, + presence: presence, + memberLimit: memberLimit, + messageLimit: messageLimit, + paginationParams: paginationParams, + ); + + if (res.channels.isEmpty && paginationParams.offset == 0) { + logger.warning(''' + We could not find any channel for this query. + Please make sure to take a look at the Flutter tutorial: https://getstream.io/chat/flutter/tutorial + If your application already has users and channels, you might need to adjust your query channel as explained in the docs https://getstream.io/chat/docs/query_channels/?language=dart + '''); + return []; + } + + final channels = res.channels; + + final users = channels + .expand((it) => it.members) + .map((it) => it.user) + .toList(growable: false); + + this.state.updateUsers(users); + + logger.info('Got ${res.channels.length} channels from api'); + + final updateData = _mapChannelStateToChannel(channels); + + await _chatPersistenceClient?.updateChannelQueries( + filter, + channels.map((c) => c.channel!.cid).toList(), + clearQueryCache: paginationParams.offset == 0, + ); + + this.state.channels = updateData.key; + return updateData.value; + } + + /// Requests channels with a given query from the Persistence client. + Future> queryChannelsOffline({ + Filter? filter, + List>? sort, + PaginationParams paginationParams = const PaginationParams(), + }) async { + final offlineChannels = (await _chatPersistenceClient?.getChannelStates( + filter: filter, + sort: sort, + paginationParams: paginationParams, + )) ?? + []; + final updatedData = _mapChannelStateToChannel(offlineChannels); + state.channels = updatedData.key; + return updatedData.value; + } + + MapEntry, List> _mapChannelStateToChannel( + List channelStates, + ) { + final channels = {...state.channels}; + final newChannels = []; + for (final channelState in channelStates) { + final channel = channels[channelState.channel!.cid]; + if (channel != null) { + channel.state?.updateChannelState(channelState); + newChannels.add(channel); + } else { + final newChannel = Channel.fromState(this, channelState); + if (newChannel.cid != null) { + channels[newChannel.cid!] = newChannel; + } + newChannels.add(newChannel); + } + } + return MapEntry(channels, newChannels); + } + + /// Requests users with a given query. + Future queryUsers({ + bool? presence, + Filter? filter, + List? sort, + PaginationParams? pagination, + }) async { + final response = await _chatApi.user.queryUsers( + presence: presence ?? _connectionIdManager.hasConnectionId, + filter: filter, + sort: sort, + pagination: pagination, + ); + state.updateUsers(response.users); + return response; + } + + /// A message search. + Future search( + Filter filter, { + String? query, + List? sort, + PaginationParams? paginationParams, + Filter? messageFilters, + }) => + _chatApi.general.searchMessages( + filter, + query: query, + sort: sort, + pagination: paginationParams, + messageFilters: messageFilters, + ); + + /// Send a [file] to the [channelId] of type [channelType] + Future sendFile( + AttachmentFile file, + String channelId, + String channelType, { + ProgressCallback? onSendProgress, + CancelToken? cancelToken, + }) => + _chatApi.fileUploader.sendFile( + file, + channelId, + channelType, + onSendProgress: onSendProgress, + cancelToken: cancelToken, + ); + + /// Send a [image] to the [channelId] of type [channelType] + Future sendImage( + AttachmentFile image, + String channelId, + String channelType, { + ProgressCallback? onSendProgress, + CancelToken? cancelToken, + }) => + _chatApi.fileUploader.sendImage( + image, + channelId, + channelType, + onSendProgress: onSendProgress, + cancelToken: cancelToken, + ); + + /// Delete a file from this channel + Future deleteFile( + String url, + String channelId, + String channelType, { + CancelToken? cancelToken, + }) => + _chatApi.fileUploader.deleteFile( + url, + channelId, + channelType, + cancelToken: cancelToken, + ); + + /// Delete an image from this channel + Future deleteImage( + String url, + String channelId, + String channelType, { + CancelToken? cancelToken, + }) => + _chatApi.fileUploader.deleteImage( + url, + channelId, + channelType, + cancelToken: cancelToken, + ); + + /// Replaces the [channelId] of type [ChannelType] data with [data] + Future updateChannel( + String channelId, + String channelType, + Map data, { + Message? message, + }) => + _chatApi.channel.updateChannel( + channelId, + channelType, + data, + message: message, + ); + + /// Updates the [channelId] of type [ChannelType] data with [data] + Future updateChannelPartial( + String channelId, + String channelType, { + Map? set, + List? unset, + }) => + _chatApi.channel.updateChannelPartial( + channelId, + channelType, + set: set, + unset: unset, + ); + + /// Add a device for Push Notifications. + Future addDevice(String id, PushProvider pushProvider) => + _chatApi.device.addDevice(id, pushProvider); + + /// Gets a list of user devices. + Future getDevices() => _chatApi.device.getDevices(); + + /// Remove a user's device. + Future removeDevice(String id) => + _chatApi.device.removeDevice(id); + + /// Get a development token + Token devToken(String userId) => Token.development(userId); + + /// Returns a channel client with the given type, id and custom data. + Channel channel( + String type, { + String? id, + Map? extraData, + }) { + if (id != null && state.channels.containsKey('$type:$id')) { + return state.channels['$type:$id']!; + } + return Channel(this, type, id, extraData: extraData); + } + + /// Creates a new channel + Future createChannel( + String channelType, { + String? channelId, + Map? channelData, + }) => + queryChannel( + channelType, + channelId: channelId, + state: false, + channelData: channelData, + ); + + /// watches the provided channel + /// Creates first if not yet created + Future watchChannel( + String channelType, { + String? channelId, + Map? channelData, + }) => + queryChannel( + channelType, + channelId: channelId, + watch: true, + channelData: channelData, + ); + + /// Query the API, get messages, members or other channel fields + /// Creates the channel first if not yet created + Future queryChannel( + String channelType, { + bool state = true, + bool watch = false, + bool presence = false, + String? channelId, + Map? channelData, + PaginationParams? messagesPagination, + PaginationParams? membersPagination, + PaginationParams? watchersPagination, + }) => + _chatApi.channel.queryChannel( + channelType, + channelId: channelId, + channelData: channelData, + state: state, + watch: watch, + presence: presence, + messagesPagination: messagesPagination, + membersPagination: membersPagination, + watchersPagination: watchersPagination, + ); + + /// Query channel members + Future queryMembers( + String channelType, { + Filter? filter, + String? channelId, + List? members, + List? sort, + PaginationParams? pagination, + }) => + _chatApi.general.queryMembers( + channelType, + channelId: channelId, + filter: filter, + members: members, + sort: sort, + pagination: pagination, + ); + + /// Hides the channel from [queryChannels] for the user + /// until a message is added If [clearHistory] is set to true - all messages + /// will be removed for the user + Future hideChannel( + String channelId, + String channelType, { + bool clearHistory = false, + }) => + _chatApi.channel.hideChannel( + channelId, + channelType, + clearHistory: clearHistory, + ); + + /// Removes the hidden status for the channel + Future showChannel( + String channelId, + String channelType, + ) => + _chatApi.channel.showChannel( + channelId, + channelType, + ); + + /// Delete this channel. Messages are permanently removed. + Future deleteChannel( + String channelId, + String channelType, + ) => + _chatApi.channel.deleteChannel( + channelId, + channelType, + ); + + /// Removes all messages from the channel + Future truncateChannel( + String channelId, + String channelType, + ) => + _chatApi.channel.truncateChannel( + channelId, + channelType, + ); + + /// Mutes the channel + Future muteChannel( + String channelCid, { + Duration? expiration, + }) => + _chatApi.moderation.muteChannel( + channelCid, + expiration: expiration, + ); + + /// Unmutes the channel + Future unmuteChannel(String channelCid) => + _chatApi.moderation.unmuteChannel(channelCid); + + /// Accept invitation to the channel + Future acceptChannelInvite( + String channelId, + String channelType, { + Message? message, + }) => + _chatApi.channel.acceptChannelInvite( + channelId, + channelType, + message: message, + ); + + /// Reject invitation to the channel + Future rejectChannelInvite( + String channelId, + String channelType, { + Message? message, + }) => + _chatApi.channel.rejectChannelInvite( + channelId, + channelType, + message: message, + ); + + /// Add members to the channel + Future addChannelMembers( + String channelId, + String channelType, + List memberIds, { + Message? message, + }) => + _chatApi.channel.addMembers( + channelId, + channelType, + memberIds, + message: message, + ); + + /// Remove members from the channel + Future removeChannelMembers( + String channelId, + String channelType, + List memberIds, { + Message? message, + }) => + _chatApi.channel.removeMembers( + channelId, + channelType, + memberIds, + message: message, + ); + + /// Invite members to the channel + Future inviteChannelMembers( + String channelId, + String channelType, + List memberIds, { + Message? message, + }) => + _chatApi.channel.inviteChannelMembers( + channelId, + channelType, + memberIds, + message: message, + ); + + /// Stop watching the channel + Future stopChannelWatching( + String channelId, + String channelType, + ) => + _chatApi.channel.stopWatching( + channelId, + channelType, + ); + + /// Send action for a specific message of this channel + Future sendAction( + String channelId, + String channelType, + String messageId, + Map formData, + ) => + _chatApi.message.sendAction( + channelId, + channelType, + messageId, + formData, + ); + + /// Mark [channelId] of type [channelType] all messages as read + /// Optionally provide a [messageId] if you want to mark a + /// particular message as read + Future markChannelRead( + String channelId, + String channelType, { + String? messageId, + }) => + _chatApi.channel.markRead( + channelId, + channelType, + messageId: messageId, + ); + + /// Update or Create the given user object. + Future updateUser(User user) => updateUsers([user]); + + /// Batch update a list of users + Future updateUsers(List users) => + _chatApi.user.updateUsers(users); + + /// Bans a user from all channels + Future banUser( + String targetUserId, [ + Map options = const {}, + ]) => + _chatApi.moderation.banUser( + targetUserId, + options: options, + ); + + /// Remove global ban for a user + Future unbanUser( + String targetUserId, [ + Map options = const {}, + ]) => + _chatApi.moderation.unbanUser( + targetUserId, + options: options, + ); + + /// Shadow bans a user + Future shadowBan( + String targetID, [ + Map options = const {}, + ]) => + banUser(targetID, { + 'shadow': true, + ...options, + }); + + /// Removes shadow ban from a user + Future removeShadowBan( + String targetID, [ + Map options = const {}, + ]) => + unbanUser(targetID, { + 'shadow': true, + ...options, + }); + + /// Mutes a user + Future muteUser(String userId) => + _chatApi.moderation.muteUser(userId); + + /// Unmutes a user + Future unmuteUser(String userId) => + _chatApi.moderation.unmuteUser(userId); + + /// Flag a message + Future flagMessage(String messageId) => + _chatApi.moderation.flagMessage(messageId); + + /// Unflag a message + Future unflagMessage(String messageId) => + _chatApi.moderation.unflagMessage(messageId); + + /// Flag a user + Future flagUser(String userId) => + _chatApi.moderation.flagUser(userId); + + /// Unflag a message + Future unflagUser(String userId) => + _chatApi.moderation.unflagUser(userId); + + /// Mark all channels for this user as read + Future markAllRead() => _chatApi.channel.markAllRead(); + + /// Send an event to a particular channel + Future sendEvent( + String channelId, + String channelType, + Event event, + ) => + _chatApi.channel.sendEvent( + channelId, + channelType, + event, + ); + + /// Send a [reactionType] for this [messageId] + /// Set [enforceUnique] to true to remove the existing user reaction + Future sendReaction( + String messageId, + String reactionType, { + Map extraData = const {}, + bool enforceUnique = false, + }) => + _chatApi.message.sendReaction( + messageId, + reactionType, + extraData: extraData, + enforceUnique: enforceUnique, + ); + + /// Delete a [reactionType] from this [messageId] + Future deleteReaction( + String messageId, + String reactionType, + ) => + _chatApi.message.deleteReaction( + messageId, + reactionType, + ); + + /// Sends the message to the given channel + Future sendMessage( + Message message, + String channelId, + String channelType, { + bool skipPush = false, + }) => + _chatApi.message.sendMessage( + channelId, + channelType, + message, + skipPush: skipPush, + ); + + /// Lists all the message replies for the [parentId] + Future getReplies( + String parentId, { + PaginationParams? options, + }) => + _chatApi.message.getReplies( + parentId, + options: options, + ); + + /// Get all the reactions for a [messageId] + Future getReactions( + String messageId, { + PaginationParams? pagination, + }) => + _chatApi.message.getReactions( + messageId, + pagination: pagination, + ); + + /// Update the given message + Future updateMessage(Message message) => + _chatApi.message.updateMessage(message); + + /// Partially update the given [messageId] + /// Use [set] to define values to be set + /// Use [unset] to define values to be unset + Future partialUpdateMessage( + String messageId, { + Map? set, + List? unset, + }) => + _chatApi.message.partialUpdateMessage( + messageId, + set: set, + unset: unset, + ); + + /// Deletes the given message + Future deleteMessage(String messageId) => + _chatApi.message.deleteMessage(messageId); + + /// Get a message by [messageId] + Future getMessage(String messageId) => + _chatApi.message.getMessage(messageId); + + /// Retrieves a list of messages by [messageIDs] + /// from the given [channelId] of type [channelType] + Future getMessagesById( + String channelId, + String channelType, + List messageIDs, + ) => + _chatApi.message.getMessagesById( + channelId, + channelType, + messageIDs, + ); + + /// Translates the [messageId] in provided [language] + Future translateMessage( + String messageId, + String language, + ) => + _chatApi.message.translateMessage( + messageId, + language, + ); + + /// Pins provided message + /// [timeoutOrExpirationDate] can either be a [DateTime] or a value in seconds + /// to be added to [DateTime.now] + Future pinMessage( + String messageId, { + Object? /*num|DateTime*/ timeoutOrExpirationDate, + }) { + assert(() { + if (timeoutOrExpirationDate is! DateTime && + timeoutOrExpirationDate != null && + timeoutOrExpirationDate is! num) { + throw ArgumentError('Invalid timeout or Expiration date'); + } + return true; + }(), 'Check for invalid timeout or expiration date'); + + DateTime? pinExpires; + if (timeoutOrExpirationDate is DateTime) { + pinExpires = timeoutOrExpirationDate; + } else if (timeoutOrExpirationDate is num) { + pinExpires = DateTime.now().add( + Duration(seconds: timeoutOrExpirationDate.toInt()), + ); + } + return partialUpdateMessage( + messageId, + set: { + 'pinned': true, + 'pin_expires': pinExpires?.toUtc().toIso8601String(), + }, + ); + } + + /// Unpins provided message + Future unpinMessage(String messageId) => + partialUpdateMessage( + messageId, + set: { + 'pinned': false, + }, + ); + + /// Closes the [_ws] connection and resets the [state] + /// If [flushChatPersistence] is true the client deletes all offline + /// user's data. + Future disconnectUser({bool flushChatPersistence = false}) async { + logger.info('Disconnecting user : ${state.user?.id}'); + + // resetting state + state.dispose(); + state = ClientState(this); + + // resetting credentials + _tokenManager.reset(); + _connectionIdManager.reset(); + + // disconnecting persistence client + await _chatPersistenceClient?.disconnect(flush: flushChatPersistence); + _chatPersistenceClient = null; + + // closing web-socket connection + closeConnection(); + } + + /// Call this function to dispose the client + Future dispose() async { + logger.info('Disposing new StreamChatClient'); + + // disposing state + state.dispose(); + + // disconnecting persistence client + await _chatPersistenceClient?.disconnect(); + + // closing web-socket connection + closeConnection(); + + await _eventController.close(); + await _wsConnectionStatusController.close(); + } +} + +/// The class that handles the state of the channel listening to the events +class ClientState { + /// Creates a new instance listening to events and updating the state + ClientState(this._client) { + _subscriptions.addAll([ + _client + .on() + .where((event) => event.me != null) + .map((e) => e.me) + .listen((user) { + _userController.add(user); + final totalUnreadCount = user?.totalUnreadCount; + if (totalUnreadCount != null) { + _totalUnreadCountController.add(totalUnreadCount); + } + + final unreadChannels = user?.unreadChannels; + if (unreadChannels != null) { + _unreadChannelsController.add(unreadChannels); + } + }), + _client + .on() + .map((event) => event.unreadChannels) + .whereType() + .listen(_unreadChannelsController.add), + _client + .on() + .map((event) => event.totalUnreadCount) + .whereType() + .listen(_totalUnreadCountController.add), + ]); + + _listenChannelDeleted(); + + _listenChannelHidden(); + + _listenUserUpdated(); + } + + final _subscriptions = []; + + /// Used internally for optimistic update of unread count + set totalUnreadCount(int unreadCount) { + _totalUnreadCountController.add(unreadCount); + } + + void _listenChannelHidden() { + _subscriptions.add(_client.on(EventType.channelHidden).listen((event) { + final cid = event.cid; + + if (cid != null) { + _client.chatPersistenceClient?.deleteChannels([cid]); + } + channels = channels..removeWhere((cid, ch) => cid == event.cid); + })); + } + + void _listenUserUpdated() { + _subscriptions.add(_client.on(EventType.userUpdated).listen((event) { + if (event.user!.id == user!.id) { + user = OwnUser.fromJson(event.user!.toJson()); + } + updateUser(event.user); + })); + } + + void _listenChannelDeleted() { + _subscriptions.add(_client + .on( + EventType.channelDeleted, + EventType.notificationRemovedFromChannel, + EventType.notificationChannelDeleted, + ) + .listen((Event event) async { + final eventChannel = event.channel!; + await _client.chatPersistenceClient?.deleteChannels([eventChannel.cid]); + channels = channels..remove(eventChannel.cid); + })); + } + + final StreamChatClient _client; + + /// Update user information + set user(OwnUser? user) { + _userController.add(user); + } + + /// Update all the [users] with the provided [userList] + void updateUsers(List userList) { + final newUsers = { + ...users, + for (var user in userList) + if (user != null) user.id: user, + }; + _usersController.add(newUsers); + } + + /// Update the passed [user] in state + void updateUser(User? user) => updateUsers([user]); + + /// The current user + OwnUser? get user => _userController.valueOrNull; + + /// The current user as a stream + Stream get userStream => _userController.stream; + + /// The current user + Map get users => _usersController.value; + + /// The current user as a stream + Stream> get usersStream => _usersController.stream; + + /// The current unread channels count + int get unreadChannels => _unreadChannelsController.value; + + /// The current unread channels count as a stream + Stream get unreadChannelsStream => _unreadChannelsController.stream; + + /// The current total unread messages count + int get totalUnreadCount => _totalUnreadCountController.value; + + /// The current total unread messages count as a stream + Stream get totalUnreadCountStream => _totalUnreadCountController.stream; + + /// The current list of channels in memory as a stream + Stream> get channelsStream => _channelsController.stream; + + /// The current list of channels in memory + Map get channels => _channelsController.value; + + set channels(Map channelMap) { + final newChannels = {...channels, ...channelMap}; + _channelsController.add(newChannels); + } + + final _channelsController = BehaviorSubject>.seeded({}); + final _userController = BehaviorSubject(); + final _usersController = BehaviorSubject>.seeded({}); + final _unreadChannelsController = BehaviorSubject.seeded(0); + final _totalUnreadCountController = BehaviorSubject.seeded(0); + + /// Call this method to dispose this object + void dispose() { + _subscriptions.forEach((s) => s.cancel()); + _userController.close(); + _unreadChannelsController.close(); + _totalUnreadCountController.close(); + channels.values.forEach((c) => c.dispose()); + _channelsController.close(); + } +} diff --git a/packages/stream_chat/lib/src/client/retry_policy.dart b/packages/stream_chat/lib/src/client/retry_policy.dart new file mode 100644 index 00000000..5b7812ab --- /dev/null +++ b/packages/stream_chat/lib/src/client/retry_policy.dart @@ -0,0 +1,37 @@ +import 'package:stream_chat/src/client/client.dart'; +import 'package:stream_chat/src/core/error/error.dart'; + +/// The retry options +/// When sending/updating/deleting a message any temporary error will trigger the retry policy +/// The retry policy exposes 2 methods +/// - shouldRetry: returns a boolean if the request should be retried +/// - retryTimeout: How many milliseconds to wait till the next attempt +/// +/// maxRetryAttempts is a hard limit on maximum retry attempts before giving up +class RetryPolicy { + /// Instantiate a new RetryPolicy + RetryPolicy({ + required this.shouldRetry, + required this.retryTimeout, + this.maxRetryAttempts = 6, + }); + + /// Hard limit on maximum retry attempts before giving up, defaults to 6 + /// Resets once connection recovers. + final int maxRetryAttempts; + + /// This function evaluates if we should retry the failure + final bool Function( + StreamChatClient client, + int attempt, + StreamChatError? error, + ) shouldRetry; + + /// In the case that we want to retry a failed request the retryTimeout + /// method is called to determine the timeout + final Duration Function( + StreamChatClient client, + int attempt, + StreamChatError? error, + ) retryTimeout; +} diff --git a/packages/stream_chat/lib/src/client/retry_queue.dart b/packages/stream_chat/lib/src/client/retry_queue.dart new file mode 100644 index 00000000..4c44433f --- /dev/null +++ b/packages/stream_chat/lib/src/client/retry_queue.dart @@ -0,0 +1,241 @@ +import 'dart:async'; + +import 'package:collection/collection.dart'; +import 'package:logging/logging.dart'; +import 'package:rxdart/rxdart.dart'; +import 'package:stream_chat/src/client/channel.dart'; +import 'package:stream_chat/src/client/retry_policy.dart'; +import 'package:stream_chat/src/core/error/error.dart'; +import 'package:stream_chat/src/event_type.dart'; +import 'package:stream_chat/src/core/models/message.dart'; +import 'package:stream_chat/stream_chat.dart'; + +/// The retry queue associated to a channel +class RetryQueue { + /// Instantiate a new RetryQueue object + RetryQueue({ + required this.channel, + this.logger, + }) : client = channel.client { + _retryPolicy = client.retryPolicy; + _listenConnectionRecovered(); + _listenFailedEvents(); + } + + /// The channel of this queue + final Channel channel; + + /// The client associated with this [channel] + final StreamChatClient client; + + /// The logger associated to this queue + final Logger? logger; + + late final RetryPolicy _retryPolicy; + + final _compositeSubscription = CompositeSubscription(); + + final _messageQueue = HeapPriorityQueue(_byDate); + bool _isRetrying = false; + + void _listenConnectionRecovered() { + client.on(EventType.connectionRecovered).listen((event) { + if (event.online == true) { + _startRetrying(); + } + }).addTo(_compositeSubscription); + } + + void _listenFailedEvents() { + channel.on().where((event) => event.message != null).listen((event) { + final message = event.message!; + final containsMessage = _messageQueue.containsMessage(message); + if (!containsMessage) return; + if (message.status == MessageSendingStatus.sent) { + logger?.info('Removing sent message from queue : ${message.id}'); + _messageQueue.removeMessage(message); + return; + } else { + if ([ + MessageSendingStatus.failed_update, + MessageSendingStatus.failed, + MessageSendingStatus.failed_delete, + ].contains(message.status)) { + logger?.info('Adding failed message from event : ${event.type}'); + add([message]); + } + } + }).addTo(_compositeSubscription); + } + + /// Add a list of messages + void add(List messages) { + if (messages.isEmpty) return; + if (_messageQueue.containsAllMessage(messages)) return; + + logger?.info('Adding ${messages.length} messages'); + final messageList = _messageQueue.toList(); + // we should not add message if already available in the queue + _messageQueue.addAll(messages.where( + (it) => !messageList.any((m) => m.id == it.id), + )); + _startRetrying(); + } + + Future _startRetrying() async { + if (_isRetrying) return; + _isRetrying = true; + + logger?.info('Started retrying failed messages'); + while (_messageQueue.isNotEmpty) { + logger?.info('${_messageQueue.length} messages remaining in the queue'); + final message = _messageQueue.first; + await _runAndRetry(message); + } + _isRetrying = false; + } + + Future _runAndRetry(Message message) async { + var attempt = 1; + + final maxAttempt = _retryPolicy.maxRetryAttempts; + // early return in case maxAttempt is less than 0 + if (attempt > maxAttempt) return; + + // ignore: literal_only_boolean_expressions + while (true) { + try { + logger?.info('Message (${message.id}) retry attempt $attempt'); + await _retryMessage(message); + logger?.info('Message (${message.id}) sent successfully'); + _messageQueue.removeMessage(message); + break; + } on StreamChatError catch (e) { + // retry logic + final maxAttempt = _retryPolicy.maxRetryAttempts; + if (attempt < maxAttempt) { + final shouldRetry = _retryPolicy.shouldRetry(client, attempt, e); + if (shouldRetry) { + final timeout = _retryPolicy.retryTimeout(client, attempt, e); + // temporary failure, continue + logger?.info( + 'API call failed (attempt $attempt), ' + 'retrying in ${timeout.inSeconds} seconds. Error was $e', + ); + await Future.delayed(timeout); + attempt += 1; + } else { + logger?.info( + 'API call failed (attempt $attempt). ' + 'Giving up for now, will retry when connection recovers. ' + 'Error was $e', + ); + _sendFailedEvent(message); + break; + } + } else { + logger?.info( + 'API call failed (attempt $attempt). ' + 'Exceeds maxRetryAttempt : $maxAttempt ' + 'Giving up for now, will retry when connection recovers. ' + 'Error was $e', + ); + _sendFailedEvent(message); + break; + } + } catch (e) { + logger?.info( + 'API call failed due to unknown error (attempt $attempt). ' + 'Giving up for now, will retry when connection recovers. ' + 'Error was $e', + ); + _sendFailedEvent(message); + break; + } + } + } + + void _sendFailedEvent(Message message) { + final newStatus = message.status == MessageSendingStatus.sending + ? MessageSendingStatus.failed + : message.status == MessageSendingStatus.updating + ? MessageSendingStatus.failed_update + : MessageSendingStatus.failed_delete; + channel.state?.addMessage(message.copyWith(status: newStatus)); + } + + Future _retryMessage(Message message) async { + if (message.status == MessageSendingStatus.failed_update || + message.status == MessageSendingStatus.updating) { + await channel.updateMessage(message); + } else if (message.status == MessageSendingStatus.failed || + message.status == MessageSendingStatus.sending) { + await channel.sendMessage(message); + } else if (message.status == MessageSendingStatus.failed_delete || + message.status == MessageSendingStatus.deleting) { + await channel.deleteMessage(message); + } + } + + /// Whether our [_messageQueue] has messages or not + bool get hasMessages => _messageQueue.isNotEmpty; + + /// Call this method to dispose this object + void dispose() { + _messageQueue.clear(); + _compositeSubscription.dispose(); + } + + static int _byDate(Message m1, Message m2) { + final date1 = _getMessageDate(m1); + final date2 = _getMessageDate(m2); + + if (date1 == null || date2 == null) { + return 0; + } + + return date1.compareTo(date2); + } + + static DateTime? _getMessageDate(Message m1) { + switch (m1.status) { + case MessageSendingStatus.failed_delete: + case MessageSendingStatus.deleting: + return m1.deletedAt; + + case MessageSendingStatus.failed: + case MessageSendingStatus.sending: + return m1.createdAt; + + case MessageSendingStatus.failed_update: + case MessageSendingStatus.updating: + return m1.updatedAt; + default: + return null; + } + } +} + +extension _MessageHeapPriorityQueue on HeapPriorityQueue { + void removeMessage(Message message) { + final list = toUnorderedList(); + final index = list.indexWhere((it) => it.id == message.id); + if (index == -1) return; + final element = list[index]; + remove(element); + } + + bool containsMessage(Message message) { + final list = toUnorderedList(); + final index = list.indexWhere((it) => it.id == message.id); + if (index == -1) return false; + return true; + } + + bool containsAllMessage(List messages) { + if (isEmpty) return false; + final list = toUnorderedList(); + final messageIds = messages.map((it) => it.id); + return list.every((it) => messageIds.contains(it.id)); + } +} diff --git a/packages/stream_chat/lib/src/attachment_file_uploader.dart b/packages/stream_chat/lib/src/core/api/attachment_file_uploader.dart similarity index 56% rename from packages/stream_chat/lib/src/attachment_file_uploader.dart rename to packages/stream_chat/lib/src/core/api/attachment_file_uploader.dart index 7d9e8c40..d6dc249f 100644 --- a/packages/stream_chat/lib/src/attachment_file_uploader.dart +++ b/packages/stream_chat/lib/src/core/api/attachment_file_uploader.dart @@ -1,8 +1,7 @@ import 'package:dio/dio.dart'; -import 'package:stream_chat/src/api/responses.dart'; -import 'package:stream_chat/src/client.dart'; -import 'package:stream_chat/src/models/attachment_file.dart'; -import 'package:stream_chat/src/extensions/string_extension.dart'; +import 'package:stream_chat/src/core/api/responses.dart'; +import 'package:stream_chat/src/core/http/stream_http_client.dart'; +import 'package:stream_chat/src/core/models/attachment_file.dart'; /// Class responsible for uploading images and files to a given channel abstract class AttachmentFileUploader { @@ -15,8 +14,8 @@ abstract class AttachmentFileUploader { AttachmentFile image, String channelId, String channelType, { - ProgressCallback onSendProgress, - CancelToken cancelToken, + ProgressCallback? onSendProgress, + CancelToken? cancelToken, }); /// Uploads a [file] to the given channel. @@ -28,8 +27,8 @@ abstract class AttachmentFileUploader { AttachmentFile file, String channelId, String channelType, { - ProgressCallback onSendProgress, - CancelToken cancelToken, + ProgressCallback? onSendProgress, + CancelToken? cancelToken, }); /// Deletes a image using its [url] from the given channel. @@ -40,7 +39,7 @@ abstract class AttachmentFileUploader { String url, String channelId, String channelType, { - CancelToken cancelToken, + CancelToken? cancelToken, }); /// Deletes a file using its [url] from the given channel. @@ -51,7 +50,7 @@ abstract class AttachmentFileUploader { String url, String channelId, String channelType, { - CancelToken cancelToken, + CancelToken? cancelToken, }); } @@ -60,43 +59,24 @@ class StreamAttachmentFileUploader implements AttachmentFileUploader { /// Creates a new [StreamAttachmentFileUploader] instance. const StreamAttachmentFileUploader(this._client); - final StreamChatClient _client; + final StreamHttpClient _client; @override Future sendImage( AttachmentFile file, String channelId, String channelType, { - ProgressCallback onSendProgress, - CancelToken cancelToken, + ProgressCallback? onSendProgress, + CancelToken? cancelToken, }) async { - final filename = file.path?.split('/')?.last ?? file.name; - final mimeType = filename.mimeType; - - MultipartFile multiPartFile; - if (file.path != null) { - multiPartFile = await MultipartFile.fromFile( - file.path, - filename: filename, - contentType: mimeType, - ); - } else if (file.bytes != null) { - multiPartFile = MultipartFile.fromBytes( - file.bytes, - filename: filename, - contentType: mimeType, - ); - } - - final response = await _client.post( + final multiPartFile = await file.toMultipartFile(); + final response = await _client.postFile( '/channels/$channelType/$channelId/image', - data: FormData.fromMap({ - 'file': multiPartFile, - }), + multiPartFile, onSendProgress: onSendProgress, cancelToken: cancelToken, ); - return _client.decode(response.data, SendImageResponse.fromJson); + return SendImageResponse.fromJson(response.data); } @override @@ -104,36 +84,17 @@ class StreamAttachmentFileUploader implements AttachmentFileUploader { AttachmentFile file, String channelId, String channelType, { - ProgressCallback onSendProgress, - CancelToken cancelToken, + ProgressCallback? onSendProgress, + CancelToken? cancelToken, }) async { - final filename = file.path?.split('/')?.last ?? file.name; - final mimeType = filename.mimeType; - - MultipartFile multiPartFile; - if (file.path != null) { - multiPartFile = await MultipartFile.fromFile( - file.path, - filename: filename, - contentType: mimeType, - ); - } else if (file.bytes != null) { - multiPartFile = MultipartFile.fromBytes( - file.bytes, - filename: filename, - contentType: mimeType, - ); - } - - final response = await _client.post( + final multiPartFile = await file.toMultipartFile(); + final response = await _client.postFile( '/channels/$channelType/$channelId/file', - data: FormData.fromMap({ - 'file': multiPartFile, - }), + multiPartFile, onSendProgress: onSendProgress, cancelToken: cancelToken, ); - return _client.decode(response.data, SendFileResponse.fromJson); + return SendFileResponse.fromJson(response.data); } @override @@ -141,14 +102,14 @@ class StreamAttachmentFileUploader implements AttachmentFileUploader { String url, String channelId, String channelType, { - CancelToken cancelToken, + CancelToken? cancelToken, }) async { final response = await _client.delete( '/channels/$channelType/$channelId/image', queryParameters: {'url': url}, cancelToken: cancelToken, ); - return _client.decode(response.data, EmptyResponse.fromJson); + return EmptyResponse.fromJson(response.data); } @override @@ -156,13 +117,13 @@ class StreamAttachmentFileUploader implements AttachmentFileUploader { String url, String channelId, String channelType, { - CancelToken cancelToken, + CancelToken? cancelToken, }) async { final response = await _client.delete( '/channels/$channelType/$channelId/file', queryParameters: {'url': url}, cancelToken: cancelToken, ); - return _client.decode(response.data, EmptyResponse.fromJson); + return EmptyResponse.fromJson(response.data); } } diff --git a/packages/stream_chat/lib/src/core/api/channel_api.dart b/packages/stream_chat/lib/src/core/api/channel_api.dart new file mode 100644 index 00000000..68f6c4f8 --- /dev/null +++ b/packages/stream_chat/lib/src/core/api/channel_api.dart @@ -0,0 +1,295 @@ +import 'dart:convert'; + +import 'package:stream_chat/src/core/api/requests.dart'; +import 'package:stream_chat/src/core/api/responses.dart'; +import 'package:stream_chat/src/core/http/stream_http_client.dart'; +import 'package:stream_chat/src/core/models/channel_model.dart'; +import 'package:stream_chat/src/core/models/channel_state.dart'; +import 'package:stream_chat/src/core/models/event.dart'; +import 'package:stream_chat/src/core/models/filter.dart'; +import 'package:stream_chat/src/core/models/message.dart'; + +/// Defines the api dedicated to channel operations +class ChannelApi { + /// Initialize a new channel api + ChannelApi(this._client); + + final StreamHttpClient _client; + + String _getChannelUrl(String channelId, String channelType) => + '/channels/$channelType/$channelId'; + + /// Query the API, get messages, members or other channel fields + Future queryChannel( + String channelType, { + bool state = true, + bool watch = false, + bool presence = false, + String? channelId, + Map? channelData, + PaginationParams? messagesPagination, + PaginationParams? membersPagination, + PaginationParams? watchersPagination, + }) async { + var channelPath = '/channels/$channelType'; + if (channelId != null) channelPath = '$channelPath/$channelId'; + final response = await _client.post( + '$channelPath/query', + data: { + 'state': state, + 'watch': watch, + 'presence': presence, + if (channelData != null) 'data': channelData, + if (messagesPagination != null) 'messages': messagesPagination, + if (membersPagination != null) 'members': membersPagination, + if (watchersPagination != null) 'watchers': watchersPagination, + }, + ); + return ChannelState.fromJson(response.data); + } + + /// Requests channels with a given query from the API. + Future queryChannels({ + Filter? filter, + List>? sort, + int? memberLimit, + int? messageLimit, + bool state = true, + bool watch = true, + bool presence = false, + PaginationParams paginationParams = const PaginationParams(), + }) async { + final response = await _client.get( + '/channels', + queryParameters: { + 'payload': jsonEncode({ + // default options + 'state': state, + 'watch': watch, + 'presence': presence, + + // passed options + if (sort != null) 'sort': sort, + if (filter != null) 'filter_conditions': filter, + if (memberLimit != null) 'member_limit': memberLimit, + if (messageLimit != null) 'message_limit': messageLimit, + + // pagination + ...paginationParams.toJson() + }), + }, + ); + return QueryChannelsResponse.fromJson(response.data); + } + + /// Mark all channels for this user as read + Future markAllRead() async { + final response = await _client.post('channels/read'); + return EmptyResponse.fromJson(response.data); + } + + /// Replaces the [channelId] of type [ChannelType] data with [data] + Future updateChannel( + String channelId, + String channelType, + Map data, { + Message? message, + }) async { + final response = await _client.post( + _getChannelUrl(channelId, channelType), + data: { + 'data': data, + if (message != null) + 'message': message.copyWith(updatedAt: DateTime.now()), + }, + ); + return UpdateChannelResponse.fromJson(response.data); + } + + /// Updates the [channelId] of type [ChannelType] data with [data] + Future updateChannelPartial( + String channelId, + String channelType, { + Map? set, + List? unset, + }) async { + final response = await _client.patch( + _getChannelUrl(channelId, channelType), + data: { + if (set != null) 'set': set, + if (unset != null) 'unset': unset, + }, + ); + return PartialUpdateChannelResponse.fromJson(response.data); + } + + /// Accept invitation to the channel + Future acceptChannelInvite( + String channelId, + String channelType, { + Message? message, + }) async { + final response = await _client.post( + _getChannelUrl(channelId, channelType), + data: { + 'accept_invite': true, + 'message': message, + }, + ); + return AcceptInviteResponse.fromJson(response.data); + } + + /// Reject invitation to the channel + Future rejectChannelInvite( + String channelId, + String channelType, { + Message? message, + }) async { + final response = await _client.post( + _getChannelUrl(channelId, channelType), + data: { + 'reject_invite': true, + 'message': message, + }, + ); + return RejectInviteResponse.fromJson(response.data); + } + + /// Invite members to the channel + Future inviteChannelMembers( + String channelId, + String channelType, + List memberIds, { + Message? message, + }) async { + final response = await _client.post( + _getChannelUrl(channelId, channelType), + data: { + 'invites': memberIds, + 'message': message, + }, + ); + return InviteMembersResponse.fromJson(response.data); + } + + /// Add members to the channel + Future addMembers( + String channelId, + String channelType, + List memberIds, { + Message? message, + }) async { + final response = await _client.post( + _getChannelUrl(channelId, channelType), + data: { + 'add_members': memberIds, + 'message': message, + }, + ); + return AddMembersResponse.fromJson(response.data); + } + + /// Remove members from the channel + Future removeMembers( + String channelId, + String channelType, + List memberIds, { + Message? message, + }) async { + final response = await _client.post( + _getChannelUrl(channelId, channelType), + data: { + 'remove_members': memberIds, + 'message': message, + }, + ); + return RemoveMembersResponse.fromJson(response.data); + } + + /// Send an event on this channel + Future sendEvent( + String channelId, + String channelType, + Event event, + ) async { + final response = await _client.post( + '${_getChannelUrl(channelId, channelType)}/event', + data: {'event': event}, + ); + return EmptyResponse.fromJson(response.data); + } + + /// Delete this channel. Messages are permanently removed. + Future deleteChannel( + String channelId, + String channelType, + ) async { + final response = await _client.delete( + _getChannelUrl(channelId, channelType), + ); + return EmptyResponse.fromJson(response.data); + } + + /// Removes all messages from the channel + Future truncateChannel( + String channelId, + String channelType, + ) async { + final response = await _client.post( + '${_getChannelUrl(channelId, channelType)}/truncate', + ); + return EmptyResponse.fromJson(response.data); + } + + /// Hides the channel from [StreamChatClient.queryChannels] for the user + /// until a message is added If [clearHistory] is set to true - all messages + /// will be removed for the user + Future hideChannel( + String channelId, + String channelType, { + bool clearHistory = false, + }) async { + final response = await _client.post( + '${_getChannelUrl(channelId, channelType)}/hide', + data: {'clear_history': clearHistory}, + ); + return EmptyResponse.fromJson(response.data); + } + + /// Removes the hidden status for the channel + Future showChannel( + String channelId, + String channelType, + ) async { + final response = await _client.post( + '${_getChannelUrl(channelId, channelType)}/show', + ); + return EmptyResponse.fromJson(response.data); + } + + /// Mark [channelId] of type [channelType] all messages as read + /// Optionally provide a [messageId] if you want to mark a + /// particular message as read + Future markRead( + String channelId, + String channelType, { + String? messageId, + }) async { + final response = await _client.post( + '${_getChannelUrl(channelId, channelType)}/read', + data: {if (messageId != null) 'message_id': messageId}, + ); + return EmptyResponse.fromJson(response.data); + } + + /// Stop watching the channel + Future stopWatching( + String channelId, + String channelType, + ) async { + final response = await _client.post( + '${_getChannelUrl(channelId, channelType)}/stop-watching', + ); + return EmptyResponse.fromJson(response.data); + } +} diff --git a/packages/stream_chat/lib/src/core/api/device_api.dart b/packages/stream_chat/lib/src/core/api/device_api.dart new file mode 100644 index 00000000..2d2b9d7b --- /dev/null +++ b/packages/stream_chat/lib/src/core/api/device_api.dart @@ -0,0 +1,60 @@ +import 'package:stream_chat/src/core/api/responses.dart'; +import 'package:stream_chat/src/core/http/stream_http_client.dart'; + +/// Provider used to send push notifications. +enum PushProvider { + /// Send notifications using Google's Firebase Cloud Messaging + firebase, + + /// Send notifications using Apple's Push Notification service + apn +} + +/// Helper extension for [PushProvider] +extension PushProviderX on PushProvider { + /// Returns the string notion for [PushProvider]. + String get name => { + PushProvider.apn: 'apn', + PushProvider.firebase: 'firebase', + }[this]!; +} + +/// Defines the api dedicated to device operations +class DeviceApi { + /// Initialize a new device api + DeviceApi(this._client); + + final StreamHttpClient _client; + + /// Add a device for Push Notifications. + Future addDevice( + String deviceId, + PushProvider pushProvider, + ) async { + final response = await _client.post( + '/devices', + data: { + 'id': deviceId, + 'push_provider': pushProvider.name, + }, + ); + return EmptyResponse.fromJson(response.data); + } + + /// Gets a list of user devices. + Future getDevices() async { + final response = await _client.get('/devices'); + return ListDevicesResponse.fromJson(response.data); + } + + /// Remove a user's device. + Future removeDevice( + String deviceId, + ) async { + final response = await _client.delete( + '/devices', + queryParameters: {'id': deviceId}, + ); + return EmptyResponse.fromJson(response.data); + } +} diff --git a/packages/stream_chat/lib/src/core/api/general_api.dart b/packages/stream_chat/lib/src/core/api/general_api.dart new file mode 100644 index 00000000..c6243148 --- /dev/null +++ b/packages/stream_chat/lib/src/core/api/general_api.dart @@ -0,0 +1,95 @@ +import 'dart:convert'; + +import 'package:stream_chat/src/core/api/requests.dart'; +import 'package:stream_chat/src/core/api/responses.dart'; +import 'package:stream_chat/src/core/http/stream_http_client.dart'; +import 'package:stream_chat/src/core/models/filter.dart'; +import 'package:stream_chat/src/core/models/member.dart'; + +/// Defines the api dedicated to general operations +class GeneralApi { + /// Initialize a new general api + GeneralApi(this._client); + + final StreamHttpClient _client; + + /// Get all the missed events + Future sync( + List cids, + DateTime lastSyncAt, + ) async { + final response = await _client.post( + '/sync', + data: { + 'channel_cids': cids, + 'last_sync_at': lastSyncAt.toUtc().toIso8601String(), + }, + ); + return SyncResponse.fromJson(response.data); + } + + /// A message search. + Future searchMessages( + Filter filter, { + String? query, + List? sort, + PaginationParams? pagination, + Filter? messageFilters, + }) async { + assert(() { + if (query == null && messageFilters == null) { + throw ArgumentError('Provide at least `query` or `messageFilters`'); + } + if (query != null && messageFilters != null) { + throw ArgumentError( + "Can't provide both `query` and `messageFilters` at the same time", + ); + } + return true; + }(), 'Check incoming params.'); + + final response = await _client.get( + '/search', + queryParameters: { + 'payload': jsonEncode({ + 'filter_conditions': filter, + if (sort != null) 'sort': sort, + if (query != null) 'query': query, + if (messageFilters != null) + 'message_filter_conditions': messageFilters, + if (pagination != null) ...pagination.toJson(), + }), + }, + ); + + return SearchMessagesResponse.fromJson(response.data); + } + + /// Query channel members + Future queryMembers( + String channelType, { + Filter? filter, + String? channelId, + List? members, + List? sort, + PaginationParams? pagination, + }) async { + final response = await _client.get( + '/members', + queryParameters: { + 'payload': jsonEncode({ + 'type': channelType, + 'filter_conditions': filter ?? {}, + if (channelId != null) + 'id': channelId + else if (members != null) + 'members': members, + if (sort != null) 'sort': sort, + if (pagination != null) ...pagination.toJson(), + }), + }, + ); + + return QueryMembersResponse.fromJson(response.data); + } +} diff --git a/packages/stream_chat/lib/src/core/api/guest_api.dart b/packages/stream_chat/lib/src/core/api/guest_api.dart new file mode 100644 index 00000000..b6727902 --- /dev/null +++ b/packages/stream_chat/lib/src/core/api/guest_api.dart @@ -0,0 +1,20 @@ +import 'package:stream_chat/src/core/api/responses.dart'; +import 'package:stream_chat/src/core/http/stream_http_client.dart'; +import 'package:stream_chat/src/core/models/user.dart'; + +/// Defines the api dedicated to guest users operations +class GuestApi { + /// Initialize a new guest api + GuestApi(this._client); + + final StreamHttpClient _client; + + /// Returns the information about guest user + Future getGuestUser(User user) async { + final response = await _client.post( + '/guest', + data: {'user': user}, + ); + return ConnectGuestUserResponse.fromJson(response.data); + } +} diff --git a/packages/stream_chat/lib/src/core/api/message_api.dart b/packages/stream_chat/lib/src/core/api/message_api.dart new file mode 100644 index 00000000..17269820 --- /dev/null +++ b/packages/stream_chat/lib/src/core/api/message_api.dart @@ -0,0 +1,182 @@ +import 'package:stream_chat/src/core/api/requests.dart'; +import 'package:stream_chat/src/core/api/responses.dart'; +import 'package:stream_chat/src/core/http/stream_http_client.dart'; +import 'package:stream_chat/src/core/models/message.dart'; + +/// Defines the api dedicated to messages operations +class MessageApi { + /// Initialize a new message api + MessageApi(this._client); + + final StreamHttpClient _client; + + /// Sends the [message] to the given [channelId] of given [channelType] + Future sendMessage( + String channelId, + String channelType, + Message message, { + bool skipPush = false, + }) async { + final response = await _client.post( + '/channels/$channelType/$channelId/message', + data: { + 'message': message, + 'skip_push': skipPush, + }, + ); + return SendMessageResponse.fromJson(response.data); + } + + /// Retrieves a list of messages by [messageIDs] + /// from the given [channelId] of type [channelType] + Future getMessagesById( + String channelId, + String channelType, + List messageIDs, + ) async { + final response = await _client.get( + '/channels/$channelType/$channelId/messages', + queryParameters: {'ids': messageIDs.join(',')}, + ); + return GetMessagesByIdResponse.fromJson(response.data); + } + + /// Get a message by [messageId] + Future getMessage(String messageId) async { + final response = await _client.get( + '/messages/$messageId', + ); + return GetMessageResponse.fromJson(response.data); + } + + /// Updates the given [message] + Future updateMessage( + Message message, + ) async { + final response = await _client.post( + '/messages/${message.id}', + data: {'message': message}, + ); + return UpdateMessageResponse.fromJson(response.data); + } + + /// Partially update the given [messageId] + /// Use [set] to define values to be set + /// Use [unset] to define values to be unset + Future partialUpdateMessage( + String messageId, { + Map? set, + List? unset, + }) async { + final response = await _client.put( + '/messages/$messageId', + data: { + if (set != null) 'set': set, + if (unset != null) 'unset': unset, + }, + ); + return UpdateMessageResponse.fromJson(response.data); + } + + /// Deletes the given [messageId] + Future deleteMessage( + String messageId, + ) async { + final response = await _client.delete( + '/messages/$messageId', + ); + return EmptyResponse.fromJson(response.data); + } + + /// Send action for a specific [messageId] + /// of the given [channelId] of given [channelType] + Future sendAction( + String channelId, + String channelType, + String messageId, + Map formData, + ) async { + final response = await _client.post( + '/messages/$messageId/action', + data: { + 'id': channelId, + 'type': channelType, + 'form_data': formData, + 'message_id': messageId, + }, + ); + return SendActionResponse.fromJson(response.data); + } + + /// Send a [reactionType] for this [messageId] + /// Set [enforceUnique] to true to remove the existing user reaction + Future sendReaction( + String messageId, + String reactionType, { + Map extraData = const {}, + bool enforceUnique = false, + }) async { + final reaction = Map.from(extraData) + ..addAll({'type': reactionType}); + + final response = await _client.post( + '/messages/$messageId/reaction', + data: { + 'reaction': reaction, + 'enforce_unique': enforceUnique, + }, + ); + return SendReactionResponse.fromJson(response.data); + } + + /// Delete a [reactionType] from this [messageId] + Future deleteReaction( + String messageId, + String reactionType, + ) async { + final response = await _client.delete( + '/messages/$messageId/reaction/$reactionType', + ); + return EmptyResponse.fromJson(response.data); + } + + /// Get all the reactions for a [messageId] + Future getReactions( + String messageId, { + PaginationParams? pagination, + }) async { + final response = await _client.get( + '/messages/$messageId/reactions', + queryParameters: { + if (pagination != null) ...pagination.toJson(), + }, + ); + return QueryReactionsResponse.fromJson(response.data); + } + + /// Translates the [messageId] in provided [language] + Future translateMessage( + String messageId, + String language, + ) async { + final response = await _client.post( + '/messages/$messageId/translate', + data: {'language': language}, + ); + return TranslateMessageResponse.fromJson(response.data); + } + + /// Lists all the message replies for the [parentId] + Future getReplies( + String parentId, { + PaginationParams? options, + }) async { + final response = await _client.get( + '/messages/$parentId/replies', + queryParameters: { + if (options != null) ...options.toJson(), + }, + ); + return QueryRepliesResponse.fromJson(response.data); + } +} diff --git a/packages/stream_chat/lib/src/core/api/moderation_api.dart b/packages/stream_chat/lib/src/core/api/moderation_api.dart new file mode 100644 index 00000000..533fbf63 --- /dev/null +++ b/packages/stream_chat/lib/src/core/api/moderation_api.dart @@ -0,0 +1,128 @@ +import 'package:stream_chat/src/core/api/responses.dart'; +import 'package:stream_chat/src/core/http/stream_http_client.dart'; + +/// Defines the api dedicated to moderation operations +class ModerationApi { + /// Initialize a new moderation api + ModerationApi(this._client); + + final StreamHttpClient _client; + + /// Mutes a user + Future muteUser(String userId) async { + final response = await _client.post( + '/moderation/mute', + data: {'target_id': userId}, + ); + return EmptyResponse.fromJson(response.data); + } + + /// Unmutes a user + Future unmuteUser(String userId) async { + final response = await _client.post( + '/moderation/unmute', + data: {'target_id': userId}, + ); + return EmptyResponse.fromJson(response.data); + } + + /// Mutes the channel + Future muteChannel( + String channelCid, { + Duration? expiration, + }) async { + final response = await _client.post( + '/moderation/mute/channel', + data: { + 'channel_cid': channelCid, + if (expiration != null) 'expiration': expiration.inMilliseconds, + }, + ); + return EmptyResponse.fromJson(response.data); + } + + /// Unmutes the channel + Future unmuteChannel( + String channelCid, + ) async { + final response = await _client.post( + '/moderation/unmute/channel', + data: {'channel_cid': channelCid}, + ); + return EmptyResponse.fromJson(response.data); + } + + /// Flag a message + Future flagMessage( + String messageId, + ) async { + final response = await _client.post( + '/moderation/flag', + data: {'target_message_id': messageId}, + ); + return EmptyResponse.fromJson(response.data); + } + + /// Unflag a message + Future unflagMessage( + String messageId, + ) async { + final response = await _client.post( + '/moderation/unflag', + data: {'target_message_id': messageId}, + ); + return EmptyResponse.fromJson(response.data); + } + + /// Flag a user + Future flagUser( + String userId, + ) async { + final response = await _client.post( + '/moderation/flag', + data: {'target_user_id': userId}, + ); + return EmptyResponse.fromJson(response.data); + } + + /// Unflag a user + Future unflagUser( + String userId, + ) async { + final response = await _client.post( + '/moderation/unflag', + data: {'target_user_id': userId}, + ); + return EmptyResponse.fromJson(response.data); + } + + /// Bans a user from all channels + Future banUser( + String targetUserId, { + Map? options, + }) async { + final response = await _client.post( + '/moderation/ban', + data: { + 'target_user_id': targetUserId, + if (options != null) ...options, + }, + ); + return EmptyResponse.fromJson(response.data); + } + + /// Remove global ban for a user + Future unbanUser( + String targetUserId, { + Map? options, + }) async { + final response = await _client.delete( + '/moderation/ban', + queryParameters: { + 'target_user_id': targetUserId, + if (options != null) ...options, + }, + ); + return EmptyResponse.fromJson(response.data); + } +} diff --git a/packages/stream_chat/lib/src/api/requests.dart b/packages/stream_chat/lib/src/core/api/requests.dart similarity index 69% rename from packages/stream_chat/lib/src/api/requests.dart rename to packages/stream_chat/lib/src/core/api/requests.dart index 51151fca..29112d72 100644 --- a/packages/stream_chat/lib/src/api/requests.dart +++ b/packages/stream_chat/lib/src/core/api/requests.dart @@ -1,9 +1,10 @@ +import 'package:equatable/equatable.dart'; import 'package:json_annotation/json_annotation.dart'; part 'requests.g.dart'; /// Sorting options -@JsonSerializable(createFactory: false) +@JsonSerializable(includeIfNull: false) class SortOption { /// Creates a new SortOption instance /// @@ -18,6 +19,10 @@ class SortOption { this.comparator, }); + /// Create a new instance from a json + factory SortOption.fromJson(Map json) => + _$SortOptionFromJson(json); + /// Ascending order // ignore: constant_identifier_names static const ASC = 1; @@ -34,15 +39,15 @@ class SortOption { /// Sorting field Comparator required for offline sorting @JsonKey(ignore: true) - final Comparator comparator; + final Comparator? comparator; /// Serialize model to json Map toJson() => _$SortOptionToJson(this); } /// Pagination options. -@JsonSerializable(createFactory: false, includeIfNull: false) -class PaginationParams { +@JsonSerializable(includeIfNull: false) +class PaginationParams extends Equatable { /// Creates a new PaginationParams instance /// /// For example: @@ -62,6 +67,10 @@ class PaginationParams { this.lessThanOrEqual, }); + /// Create a new instance from a json + factory PaginationParams.fromJson(Map json) => + _$PaginationParamsFromJson(json); + /// The amount of items requested from the APIs. final int limit; @@ -70,31 +79,31 @@ class PaginationParams { /// Filter on ids greater than the given value. @JsonKey(name: 'id_gt') - final String greaterThan; + final String? greaterThan; /// Filter on ids greater than or equal to the given value. @JsonKey(name: 'id_gte') - final String greaterThanOrEqual; + final String? greaterThanOrEqual; /// Filter on ids smaller than the given value. @JsonKey(name: 'id_lt') - final String lessThan; + final String? lessThan; /// Filter on ids smaller than or equal to the given value. @JsonKey(name: 'id_lte') - final String lessThanOrEqual; + final String? lessThanOrEqual; /// Serialize model to json Map toJson() => _$PaginationParamsToJson(this); /// Creates a copy of [PaginationParams] with specified attributes overridden. PaginationParams copyWith({ - int limit, - int offset, - String greaterThan, - String greaterThanOrEqual, - String lessThan, - String lessThanOrEqual, + int? limit, + int? offset, + String? greaterThan, + String? greaterThanOrEqual, + String? lessThan, + String? lessThanOrEqual, }) => PaginationParams( limit: limit ?? this.limit, @@ -106,23 +115,12 @@ class PaginationParams { ); @override - int get hashCode => - runtimeType.hashCode ^ - limit.hashCode ^ - offset.hashCode ^ - greaterThan.hashCode ^ - greaterThanOrEqual.hashCode ^ - lessThan.hashCode ^ - lessThanOrEqual.hashCode; - - @override - bool operator ==(covariant PaginationParams other) => - identical(this, other) || - runtimeType == other.runtimeType && - limit == other.limit && - offset == other.offset && - greaterThan == other.greaterThan && - greaterThanOrEqual == other.greaterThanOrEqual && - lessThan == other.lessThan && - lessThanOrEqual == other.lessThanOrEqual; + List get props => [ + limit, + offset, + greaterThan, + greaterThanOrEqual, + lessThan, + lessThanOrEqual, + ]; } diff --git a/packages/stream_chat/lib/src/api/requests.g.dart b/packages/stream_chat/lib/src/core/api/requests.g.dart similarity index 56% rename from packages/stream_chat/lib/src/api/requests.g.dart rename to packages/stream_chat/lib/src/core/api/requests.g.dart index f020c1ea..6cd935b8 100644 --- a/packages/stream_chat/lib/src/api/requests.g.dart +++ b/packages/stream_chat/lib/src/core/api/requests.g.dart @@ -6,14 +6,35 @@ part of 'requests.dart'; // JsonSerializableGenerator // ************************************************************************** +SortOption _$SortOptionFromJson(Map json) { + return SortOption( + json['field'] as String, + direction: json['direction'] as int, + ); +} + Map _$SortOptionToJson(SortOption instance) => { 'field': instance.field, 'direction': instance.direction, }; +PaginationParams _$PaginationParamsFromJson(Map json) { + return PaginationParams( + limit: json['limit'] as int, + offset: json['offset'] as int, + greaterThan: json['id_gt'] as String?, + greaterThanOrEqual: json['id_gte'] as String?, + lessThan: json['id_lt'] as String?, + lessThanOrEqual: json['id_lte'] as String?, + ); +} + Map _$PaginationParamsToJson(PaginationParams instance) { - final val = {}; + final val = { + 'limit': instance.limit, + 'offset': instance.offset, + }; void writeNotNull(String key, dynamic value) { if (value != null) { @@ -21,8 +42,6 @@ Map _$PaginationParamsToJson(PaginationParams instance) { } } - writeNotNull('limit', instance.limit); - writeNotNull('offset', instance.offset); writeNotNull('id_gt', instance.greaterThan); writeNotNull('id_gte', instance.greaterThanOrEqual); writeNotNull('id_lt', instance.lessThan); diff --git a/packages/stream_chat/lib/src/api/responses.dart b/packages/stream_chat/lib/src/core/api/responses.dart similarity index 74% rename from packages/stream_chat/lib/src/api/responses.dart rename to packages/stream_chat/lib/src/core/api/responses.dart index 431a57e7..e06a213a 100644 --- a/packages/stream_chat/lib/src/api/responses.dart +++ b/packages/stream_chat/lib/src/core/api/responses.dart @@ -1,26 +1,58 @@ import 'package:json_annotation/json_annotation.dart'; -import 'package:stream_chat/src/client.dart'; -import 'package:stream_chat/src/models/channel_model.dart'; -import 'package:stream_chat/src/models/channel_state.dart'; -import 'package:stream_chat/src/models/device.dart'; -import 'package:stream_chat/src/models/event.dart'; -import 'package:stream_chat/src/models/member.dart'; -import 'package:stream_chat/src/models/message.dart'; -import 'package:stream_chat/src/models/reaction.dart'; -import 'package:stream_chat/src/models/read.dart'; -import 'package:stream_chat/src/models/user.dart'; +import 'package:stream_chat/src/client/client.dart'; +import 'package:stream_chat/src/core/error/error.dart'; +import 'package:stream_chat/src/core/models/channel_model.dart'; +import 'package:stream_chat/src/core/models/channel_state.dart'; +import 'package:stream_chat/src/core/models/device.dart'; +import 'package:stream_chat/src/core/models/event.dart'; +import 'package:stream_chat/src/core/models/member.dart'; +import 'package:stream_chat/src/core/models/message.dart'; +import 'package:stream_chat/src/core/models/reaction.dart'; +import 'package:stream_chat/src/core/models/read.dart'; +import 'package:stream_chat/src/core/models/user.dart'; part 'responses.g.dart'; class _BaseResponse { - String duration; + String? duration; } -/// Model response for [StreamChatClient.resync] api call +/// Model response for [StreamChatNetworkError] data +@JsonSerializable() +class ErrorResponse extends _BaseResponse { + /// The http error code + int? code; + + /// The message associated to the error code + String? message; + + /// The backend error code + @JsonKey(name: 'StatusCode') + int? statusCode; + + /// A detailed message about the error + String? moreInfo; + + /// Create a new instance from a json + static ErrorResponse fromJson(Map json) => + _$ErrorResponseFromJson(json); + + /// Serialize to json + Map toJson() => _$ErrorResponseToJson(this); + + @override + String toString() => 'ErrorResponse(code: $code, ' + 'message: $message, ' + 'statusCode: $statusCode, ' + 'moreInfo: $moreInfo)'; +} + +/// Model response for [StreamChatClient.sync] api call @JsonSerializable(createToJson: false) class SyncResponse extends _BaseResponse { /// The list of events - List events; + @JsonKey(defaultValue: []) + late List events; /// Create a new instance from a json static SyncResponse fromJson(Map json) => @@ -31,7 +63,8 @@ class SyncResponse extends _BaseResponse { @JsonSerializable(createToJson: false) class QueryChannelsResponse extends _BaseResponse { /// List of channels state returned by the query - List channels; + @JsonKey(defaultValue: []) + late List channels; /// Create a new instance from a json static QueryChannelsResponse fromJson(Map json) => @@ -41,8 +74,8 @@ class QueryChannelsResponse extends _BaseResponse { /// Model response for [StreamChatClient.queryChannels] api call @JsonSerializable(createToJson: false) class TranslateMessageResponse extends _BaseResponse { - /// List of channels state returned by the query - TranslatedMessage message; + /// Translated message + late TranslatedMessage message; /// Create a new instance from a json static TranslateMessageResponse fromJson(Map json) => @@ -53,7 +86,8 @@ class TranslateMessageResponse extends _BaseResponse { @JsonSerializable(createToJson: false) class QueryMembersResponse extends _BaseResponse { /// List of channels state returned by the query - List members; + @JsonKey(defaultValue: []) + late List members; /// Create a new instance from a json static QueryMembersResponse fromJson(Map json) => @@ -64,7 +98,8 @@ class QueryMembersResponse extends _BaseResponse { @JsonSerializable(createToJson: false) class QueryUsersResponse extends _BaseResponse { /// List of users returned by the query - List users; + @JsonKey(defaultValue: []) + late List users; /// Create a new instance from a json static QueryUsersResponse fromJson(Map json) => @@ -75,7 +110,8 @@ class QueryUsersResponse extends _BaseResponse { @JsonSerializable(createToJson: false) class QueryReactionsResponse extends _BaseResponse { /// List of reactions returned by the query - List reactions; + @JsonKey(defaultValue: []) + late List reactions; /// Create a new instance from a json static QueryReactionsResponse fromJson(Map json) => @@ -86,7 +122,8 @@ class QueryReactionsResponse extends _BaseResponse { @JsonSerializable(createToJson: false) class QueryRepliesResponse extends _BaseResponse { /// List of messages returned by the api call - List messages; + @JsonKey(defaultValue: []) + late List messages; /// Create a new instance from a json static QueryRepliesResponse fromJson(Map json) => @@ -97,7 +134,8 @@ class QueryRepliesResponse extends _BaseResponse { @JsonSerializable(createToJson: false) class ListDevicesResponse extends _BaseResponse { /// List of user devices - List devices; + @JsonKey(defaultValue: []) + late List devices; /// Create a new instance from a json static ListDevicesResponse fromJson(Map json) => @@ -108,7 +146,7 @@ class ListDevicesResponse extends _BaseResponse { @JsonSerializable(createToJson: false) class SendFileResponse extends _BaseResponse { /// The url of the uploaded file - String file; + late String file; /// Create a new instance from a json static SendFileResponse fromJson(Map json) => @@ -119,7 +157,7 @@ class SendFileResponse extends _BaseResponse { @JsonSerializable(createToJson: false) class SendImageResponse extends _BaseResponse { /// The url of the uploaded file - String file; + late String file; /// Create a new instance from a json static SendImageResponse fromJson(Map json) => @@ -130,10 +168,10 @@ class SendImageResponse extends _BaseResponse { @JsonSerializable(createToJson: false) class SendReactionResponse extends _BaseResponse { /// Message returned by the api call - Message message; + late Message message; /// The reaction created by the api call - Reaction reaction; + late Reaction reaction; /// Create a new instance from a json static SendReactionResponse fromJson(Map json) => @@ -144,10 +182,10 @@ class SendReactionResponse extends _BaseResponse { @JsonSerializable(createToJson: false) class ConnectGuestUserResponse extends _BaseResponse { /// Guest user access token - String accessToken; + late String accessToken; /// Guest user - User user; + late User user; /// Create a new instance from a json static ConnectGuestUserResponse fromJson(Map json) => @@ -158,7 +196,8 @@ class ConnectGuestUserResponse extends _BaseResponse { @JsonSerializable(createToJson: false) class UpdateUsersResponse extends _BaseResponse { /// Updated users - Map users; + @JsonKey(defaultValue: {}) + late Map users; /// Create a new instance from a json static UpdateUsersResponse fromJson(Map json) => @@ -169,7 +208,7 @@ class UpdateUsersResponse extends _BaseResponse { @JsonSerializable(createToJson: false) class UpdateMessageResponse extends _BaseResponse { /// Message returned by the api call - Message message; + late Message message; /// Create a new instance from a json static UpdateMessageResponse fromJson(Map json) => @@ -180,7 +219,7 @@ class UpdateMessageResponse extends _BaseResponse { @JsonSerializable(createToJson: false) class SendMessageResponse extends _BaseResponse { /// Message returned by the api call - Message message; + late Message message; /// Create a new instance from a json static SendMessageResponse fromJson(Map json) => @@ -191,17 +230,17 @@ class SendMessageResponse extends _BaseResponse { @JsonSerializable(createToJson: false) class GetMessageResponse extends _BaseResponse { /// Message returned by the api call - Message message; + late Message message; /// Channel of the message - ChannelModel channel; + ChannelModel? channel; /// Create a new instance from a json static GetMessageResponse fromJson(Map json) { final res = _$GetMessageResponseFromJson(json); - final jsonChannel = res.message?.extraData?.remove('channel'); + final jsonChannel = res.message.extraData.remove('channel'); if (jsonChannel != null) { - res.channel = ChannelModel.fromJson(jsonChannel); + res.channel = ChannelModel.fromJson(jsonChannel as Map); } return res; } @@ -211,7 +250,8 @@ class GetMessageResponse extends _BaseResponse { @JsonSerializable(createToJson: false) class SearchMessagesResponse extends _BaseResponse { /// List of messages returned by the api call - List results; + @JsonKey(defaultValue: []) + late List results; /// Create a new instance from a json static SearchMessagesResponse fromJson(Map json) => @@ -222,7 +262,8 @@ class SearchMessagesResponse extends _BaseResponse { @JsonSerializable(createToJson: false) class GetMessagesByIdResponse extends _BaseResponse { /// Message returned by the api call - List messages; + @JsonKey(defaultValue: []) + late List messages; /// Create a new instance from a json static GetMessagesByIdResponse fromJson(Map json) => @@ -233,13 +274,13 @@ class GetMessagesByIdResponse extends _BaseResponse { @JsonSerializable(createToJson: false) class UpdateChannelResponse extends _BaseResponse { /// Updated channel - ChannelModel channel; + late ChannelModel channel; /// Channel members - List members; + List? members; /// Message returned by the api call - Message message; + Message? message; /// Create a new instance from a json static UpdateChannelResponse fromJson(Map json) => @@ -250,10 +291,10 @@ class UpdateChannelResponse extends _BaseResponse { @JsonSerializable(createToJson: false) class PartialUpdateChannelResponse extends _BaseResponse { /// Updated channel - ChannelModel channel; + late ChannelModel channel; /// Channel members - List members; + List? members; /// Create a new instance from a json static PartialUpdateChannelResponse fromJson(Map json) => @@ -264,13 +305,14 @@ class PartialUpdateChannelResponse extends _BaseResponse { @JsonSerializable(createToJson: false) class InviteMembersResponse extends _BaseResponse { /// Updated channel - ChannelModel channel; + late ChannelModel channel; /// Channel members - List members; + @JsonKey(defaultValue: []) + late List members; /// Message returned by the api call - Message message; + Message? message; /// Create a new instance from a json static InviteMembersResponse fromJson(Map json) => @@ -281,13 +323,14 @@ class InviteMembersResponse extends _BaseResponse { @JsonSerializable(createToJson: false) class RemoveMembersResponse extends _BaseResponse { /// Updated channel - ChannelModel channel; + late ChannelModel channel; /// Channel members - List members; + @JsonKey(defaultValue: []) + late List members; /// Message returned by the api call - Message message; + Message? message; /// Create a new instance from a json static RemoveMembersResponse fromJson(Map json) => @@ -298,7 +341,7 @@ class RemoveMembersResponse extends _BaseResponse { @JsonSerializable(createToJson: false) class SendActionResponse extends _BaseResponse { /// Message returned by the api call - Message message; + Message? message; /// Create a new instance from a json static SendActionResponse fromJson(Map json) => @@ -309,13 +352,14 @@ class SendActionResponse extends _BaseResponse { @JsonSerializable(createToJson: false) class AddMembersResponse extends _BaseResponse { /// Updated channel - ChannelModel channel; + late ChannelModel channel; /// Channel members - List members; + @JsonKey(defaultValue: []) + late List members; /// Message returned by the api call - Message message; + Message? message; /// Create a new instance from a json static AddMembersResponse fromJson(Map json) => @@ -326,13 +370,14 @@ class AddMembersResponse extends _BaseResponse { @JsonSerializable(createToJson: false) class AcceptInviteResponse extends _BaseResponse { /// Updated channel - ChannelModel channel; + late ChannelModel channel; /// Channel members - List members; + @JsonKey(defaultValue: []) + late List members; /// Message returned by the api call - Message message; + Message? message; /// Create a new instance from a json static AcceptInviteResponse fromJson(Map json) => @@ -343,13 +388,14 @@ class AcceptInviteResponse extends _BaseResponse { @JsonSerializable(createToJson: false) class RejectInviteResponse extends _BaseResponse { /// Updated channel - ChannelModel channel; + late ChannelModel channel; /// Channel members - List members; + @JsonKey(defaultValue: []) + late List members; /// Message returned by the api call - Message message; + Message? message; /// Create a new instance from a json static RejectInviteResponse fromJson(Map json) => @@ -368,19 +414,23 @@ class EmptyResponse extends _BaseResponse { @JsonSerializable(createToJson: false) class ChannelStateResponse extends _BaseResponse { /// Updated channel - ChannelModel channel; + late ChannelModel channel; /// List of messages returned by the api call - List messages; + @JsonKey(defaultValue: []) + late List messages; /// Channel members - List members; + @JsonKey(defaultValue: []) + late List members; /// Number of users watching the channel - int watcherCount; + @JsonKey(defaultValue: 0) + late int watcherCount; /// List of read states - List read; + @JsonKey(defaultValue: []) + late List read; /// Create a new instance from a json static ChannelStateResponse fromJson(Map json) => diff --git a/packages/stream_chat/lib/src/core/api/responses.g.dart b/packages/stream_chat/lib/src/core/api/responses.g.dart new file mode 100644 index 00000000..deba55f0 --- /dev/null +++ b/packages/stream_chat/lib/src/core/api/responses.g.dart @@ -0,0 +1,297 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'responses.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +ErrorResponse _$ErrorResponseFromJson(Map json) { + return ErrorResponse() + ..duration = json['duration'] as String? + ..code = json['code'] as int? + ..message = json['message'] as String? + ..statusCode = json['StatusCode'] as int? + ..moreInfo = json['more_info'] as String?; +} + +Map _$ErrorResponseToJson(ErrorResponse instance) => + { + 'duration': instance.duration, + 'code': instance.code, + 'message': instance.message, + 'StatusCode': instance.statusCode, + 'more_info': instance.moreInfo, + }; + +SyncResponse _$SyncResponseFromJson(Map json) { + return SyncResponse() + ..duration = json['duration'] as String? + ..events = (json['events'] as List?) + ?.map((e) => Event.fromJson(e as Map)) + .toList() ?? + []; +} + +QueryChannelsResponse _$QueryChannelsResponseFromJson( + Map json) { + return QueryChannelsResponse() + ..duration = json['duration'] as String? + ..channels = (json['channels'] as List?) + ?.map((e) => ChannelState.fromJson(e as Map)) + .toList() ?? + []; +} + +TranslateMessageResponse _$TranslateMessageResponseFromJson( + Map json) { + return TranslateMessageResponse() + ..duration = json['duration'] as String? + ..message = + TranslatedMessage.fromJson(json['message'] as Map); +} + +QueryMembersResponse _$QueryMembersResponseFromJson(Map json) { + return QueryMembersResponse() + ..duration = json['duration'] as String? + ..members = (json['members'] as List?) + ?.map((e) => Member.fromJson(e as Map)) + .toList() ?? + []; +} + +QueryUsersResponse _$QueryUsersResponseFromJson(Map json) { + return QueryUsersResponse() + ..duration = json['duration'] as String? + ..users = (json['users'] as List?) + ?.map((e) => User.fromJson(e as Map)) + .toList() ?? + []; +} + +QueryReactionsResponse _$QueryReactionsResponseFromJson( + Map json) { + return QueryReactionsResponse() + ..duration = json['duration'] as String? + ..reactions = (json['reactions'] as List?) + ?.map((e) => Reaction.fromJson(e as Map)) + .toList() ?? + []; +} + +QueryRepliesResponse _$QueryRepliesResponseFromJson(Map json) { + return QueryRepliesResponse() + ..duration = json['duration'] as String? + ..messages = (json['messages'] as List?) + ?.map((e) => Message.fromJson(e as Map)) + .toList() ?? + []; +} + +ListDevicesResponse _$ListDevicesResponseFromJson(Map json) { + return ListDevicesResponse() + ..duration = json['duration'] as String? + ..devices = (json['devices'] as List?) + ?.map((e) => Device.fromJson(e as Map)) + .toList() ?? + []; +} + +SendFileResponse _$SendFileResponseFromJson(Map json) { + return SendFileResponse() + ..duration = json['duration'] as String? + ..file = json['file'] as String; +} + +SendImageResponse _$SendImageResponseFromJson(Map json) { + return SendImageResponse() + ..duration = json['duration'] as String? + ..file = json['file'] as String; +} + +SendReactionResponse _$SendReactionResponseFromJson(Map json) { + return SendReactionResponse() + ..duration = json['duration'] as String? + ..message = Message.fromJson(json['message'] as Map) + ..reaction = Reaction.fromJson(json['reaction'] as Map); +} + +ConnectGuestUserResponse _$ConnectGuestUserResponseFromJson( + Map json) { + return ConnectGuestUserResponse() + ..duration = json['duration'] as String? + ..accessToken = json['access_token'] as String + ..user = User.fromJson(json['user'] as Map); +} + +UpdateUsersResponse _$UpdateUsersResponseFromJson(Map json) { + return UpdateUsersResponse() + ..duration = json['duration'] as String? + ..users = (json['users'] as Map?)?.map( + (k, e) => MapEntry(k, User.fromJson(e as Map)), + ) ?? + {}; +} + +UpdateMessageResponse _$UpdateMessageResponseFromJson( + Map json) { + return UpdateMessageResponse() + ..duration = json['duration'] as String? + ..message = Message.fromJson(json['message'] as Map); +} + +SendMessageResponse _$SendMessageResponseFromJson(Map json) { + return SendMessageResponse() + ..duration = json['duration'] as String? + ..message = Message.fromJson(json['message'] as Map); +} + +GetMessageResponse _$GetMessageResponseFromJson(Map json) { + return GetMessageResponse() + ..duration = json['duration'] as String? + ..message = Message.fromJson(json['message'] as Map) + ..channel = json['channel'] == null + ? null + : ChannelModel.fromJson(json['channel'] as Map); +} + +SearchMessagesResponse _$SearchMessagesResponseFromJson( + Map json) { + return SearchMessagesResponse() + ..duration = json['duration'] as String? + ..results = (json['results'] as List?) + ?.map((e) => GetMessageResponse.fromJson(e as Map)) + .toList() ?? + []; +} + +GetMessagesByIdResponse _$GetMessagesByIdResponseFromJson( + Map json) { + return GetMessagesByIdResponse() + ..duration = json['duration'] as String? + ..messages = (json['messages'] as List?) + ?.map((e) => Message.fromJson(e as Map)) + .toList() ?? + []; +} + +UpdateChannelResponse _$UpdateChannelResponseFromJson( + Map json) { + return UpdateChannelResponse() + ..duration = json['duration'] as String? + ..channel = ChannelModel.fromJson(json['channel'] as Map) + ..members = (json['members'] as List?) + ?.map((e) => Member.fromJson(e as Map)) + .toList() + ..message = json['message'] == null + ? null + : Message.fromJson(json['message'] as Map); +} + +PartialUpdateChannelResponse _$PartialUpdateChannelResponseFromJson( + Map json) { + return PartialUpdateChannelResponse() + ..duration = json['duration'] as String? + ..channel = ChannelModel.fromJson(json['channel'] as Map) + ..members = (json['members'] as List?) + ?.map((e) => Member.fromJson(e as Map)) + .toList(); +} + +InviteMembersResponse _$InviteMembersResponseFromJson( + Map json) { + return InviteMembersResponse() + ..duration = json['duration'] as String? + ..channel = ChannelModel.fromJson(json['channel'] as Map) + ..members = (json['members'] as List?) + ?.map((e) => Member.fromJson(e as Map)) + .toList() ?? + [] + ..message = json['message'] == null + ? null + : Message.fromJson(json['message'] as Map); +} + +RemoveMembersResponse _$RemoveMembersResponseFromJson( + Map json) { + return RemoveMembersResponse() + ..duration = json['duration'] as String? + ..channel = ChannelModel.fromJson(json['channel'] as Map) + ..members = (json['members'] as List?) + ?.map((e) => Member.fromJson(e as Map)) + .toList() ?? + [] + ..message = json['message'] == null + ? null + : Message.fromJson(json['message'] as Map); +} + +SendActionResponse _$SendActionResponseFromJson(Map json) { + return SendActionResponse() + ..duration = json['duration'] as String? + ..message = json['message'] == null + ? null + : Message.fromJson(json['message'] as Map); +} + +AddMembersResponse _$AddMembersResponseFromJson(Map json) { + return AddMembersResponse() + ..duration = json['duration'] as String? + ..channel = ChannelModel.fromJson(json['channel'] as Map) + ..members = (json['members'] as List?) + ?.map((e) => Member.fromJson(e as Map)) + .toList() ?? + [] + ..message = json['message'] == null + ? null + : Message.fromJson(json['message'] as Map); +} + +AcceptInviteResponse _$AcceptInviteResponseFromJson(Map json) { + return AcceptInviteResponse() + ..duration = json['duration'] as String? + ..channel = ChannelModel.fromJson(json['channel'] as Map) + ..members = (json['members'] as List?) + ?.map((e) => Member.fromJson(e as Map)) + .toList() ?? + [] + ..message = json['message'] == null + ? null + : Message.fromJson(json['message'] as Map); +} + +RejectInviteResponse _$RejectInviteResponseFromJson(Map json) { + return RejectInviteResponse() + ..duration = json['duration'] as String? + ..channel = ChannelModel.fromJson(json['channel'] as Map) + ..members = (json['members'] as List?) + ?.map((e) => Member.fromJson(e as Map)) + .toList() ?? + [] + ..message = json['message'] == null + ? null + : Message.fromJson(json['message'] as Map); +} + +EmptyResponse _$EmptyResponseFromJson(Map json) { + return EmptyResponse()..duration = json['duration'] as String?; +} + +ChannelStateResponse _$ChannelStateResponseFromJson(Map json) { + return ChannelStateResponse() + ..duration = json['duration'] as String? + ..channel = ChannelModel.fromJson(json['channel'] as Map) + ..messages = (json['messages'] as List?) + ?.map((e) => Message.fromJson(e as Map)) + .toList() ?? + [] + ..members = (json['members'] as List?) + ?.map((e) => Member.fromJson(e as Map)) + .toList() ?? + [] + ..watcherCount = json['watcher_count'] as int? ?? 0 + ..read = (json['read'] as List?) + ?.map((e) => Read.fromJson(e as Map)) + .toList() ?? + []; +} diff --git a/packages/stream_chat/lib/src/core/api/stream_chat_api.dart b/packages/stream_chat/lib/src/core/api/stream_chat_api.dart new file mode 100644 index 00000000..bcf041c1 --- /dev/null +++ b/packages/stream_chat/lib/src/core/api/stream_chat_api.dart @@ -0,0 +1,79 @@ +import 'package:logging/logging.dart'; +import 'package:stream_chat/src/core/api/attachment_file_uploader.dart'; +import 'package:stream_chat/src/core/api/channel_api.dart'; +import 'package:stream_chat/src/core/api/device_api.dart'; +import 'package:stream_chat/src/core/api/general_api.dart'; +import 'package:stream_chat/src/core/api/guest_api.dart'; +import 'package:stream_chat/src/core/api/message_api.dart'; +import 'package:stream_chat/src/core/api/moderation_api.dart'; +import 'package:stream_chat/src/core/api/user_api.dart'; +import 'package:stream_chat/src/core/http/connection_id_manager.dart'; +import 'package:stream_chat/src/core/http/stream_http_client.dart'; +import 'package:stream_chat/src/core/http/token_manager.dart'; + +export 'device_api.dart' show PushProvider; + +/// ApiClient that wraps every other specific api +class StreamChatApi { + /// Initialize a new stream chat api + StreamChatApi( + String apiKey, { + StreamHttpClient? client, + StreamHttpClientOptions? options, + TokenManager? tokenManager, + ConnectionIdManager? connectionIdManager, + AttachmentFileUploader? attachmentFileUploader, + Logger? logger, + }) : _fileUploader = attachmentFileUploader, + _client = client ?? + StreamHttpClient( + apiKey, + options: options, + tokenManager: tokenManager, + connectionIdManager: connectionIdManager, + logger: logger, + ); + + final StreamHttpClient _client; + + UserApi? _user; + + /// Api dedicated to users operations + UserApi get user => _user ??= UserApi(_client); + + GuestApi? _guest; + + /// Api dedicated to guest operations + GuestApi get guest => _guest ??= GuestApi(_client); + + MessageApi? _message; + + /// Api dedicated to message operations + MessageApi get message => _message ??= MessageApi(_client); + + ChannelApi? _channel; + + /// Api dedicated to channel operations + ChannelApi get channel => _channel ??= ChannelApi(_client); + + DeviceApi? _device; + + /// Api dedicated to device operations + DeviceApi get device => _device ??= DeviceApi(_client); + + ModerationApi? _moderation; + + /// Api dedicated to moderation operations + ModerationApi get moderation => _moderation ??= ModerationApi(_client); + + GeneralApi? _general; + + /// Api dedicated to general operations + GeneralApi get general => _general ??= GeneralApi(_client); + + AttachmentFileUploader? _fileUploader; + + /// Class responsible for uploading images and files to a given channel + AttachmentFileUploader get fileUploader => + _fileUploader ??= StreamAttachmentFileUploader(_client); +} diff --git a/packages/stream_chat/lib/src/core/api/user_api.dart b/packages/stream_chat/lib/src/core/api/user_api.dart new file mode 100644 index 00000000..61159731 --- /dev/null +++ b/packages/stream_chat/lib/src/core/api/user_api.dart @@ -0,0 +1,49 @@ +import 'dart:convert'; + +import 'package:stream_chat/src/core/api/requests.dart'; +import 'package:stream_chat/src/core/api/responses.dart'; +import 'package:stream_chat/src/core/http/stream_http_client.dart'; +import 'package:stream_chat/src/core/models/filter.dart'; +import 'package:stream_chat/src/core/models/user.dart'; + +/// Defines the api dedicated to users operations +class UserApi { + /// Initialize a new user api + UserApi(this._client); + + final StreamHttpClient _client; + + /// Requests users with a given query. + Future queryUsers({ + bool presence = false, + Filter? filter, + List? sort, + PaginationParams? pagination, + }) async { + final response = await _client.get( + '/users', + queryParameters: { + 'payload': jsonEncode({ + 'presence': presence, + if (sort != null) 'sort': sort, + if (filter != null) 'filter_conditions': filter, + if (pagination != null) ...pagination.toJson(), + }), + }, + ); + return QueryUsersResponse.fromJson(response.data); + } + + /// Batch update a list of users + Future updateUsers( + List users, + ) async { + final response = await _client.post( + '/users', + data: { + 'users': {for (final user in users) user.id: user}, + }, + ); + return UpdateUsersResponse.fromJson(response.data); + } +} diff --git a/packages/stream_chat/lib/src/core/error/chat_error_code.dart b/packages/stream_chat/lib/src/core/error/chat_error_code.dart new file mode 100644 index 00000000..6391428d --- /dev/null +++ b/packages/stream_chat/lib/src/core/error/chat_error_code.dart @@ -0,0 +1,166 @@ +// ignore_for_file: lines_longer_than_80_chars + +import 'package:collection/collection.dart'; + +/// Complete list of errors that are returned by the API +/// together with the description and API code. +enum ChatErrorCode { + // Client errors + + /// Unauthenticated, token not defined + undefinedToken, + + // Bad Request + + /// Wrong data/parameter is sent to the API + inputError, + + /// Duplicate username is sent while enforce_unique_usernames is enabled + duplicateUsername, + + /// Message is too long + messageTooLong, + + /// Event is not supported + eventNotSupported, + + /// The feature is currently disabled + /// on the dashboard (i.e. Reactions & Replies) + channelFeatureNotSupported, + + /// Multiple Levels Reply is not supported + /// the API only supports 1 level deep reply threads + multipleNestling, + + /// Custom Command handler returned an error + customCommandEndpointCall, + + /// App config does not have custom_action_handler_url + customCommandEndpointMissing, + + // Unauthorised + + /// Unauthenticated, problem with authentication + authenticationError, + + /// Unauthenticated, token expired + tokenExpired, + + /// Unauthenticated, token date incorrect + tokenBeforeIssuedAt, + + /// Unauthenticated, token not valid yet + tokenNotValid, + + /// Unauthenticated, token signature invalid + tokenSignatureInvalid, + + /// Access Key invalid + accessKeyError, + + // Forbidden + + /// Unauthorised / forbidden to make request + notAllowed, + + /// App suspended + appSuspended, + + /// User tried to post a message during the cooldown period + cooldownError, + + // Miscellaneous + + /// Resource not found + doesNotExist, + + /// Request timed out + requestTimeout, + + /// Payload too big + payloadTooBig, + + /// Too many requests in a certain time frame + rateLimitError, + + /// Request headers are too large + maximumHeaderSizeExceeded, + + /// Something goes wrong in the system + internalSystemError, + + /// No access to requested channels + noAccessToChannels +} + +const _errorCodeWithDescription = { + ChatErrorCode.undefinedToken: + MapEntry(1000, 'Unauthorised, token not defined'), + ChatErrorCode.inputError: + MapEntry(4, 'Wrong data/parameter is sent to the API'), + ChatErrorCode.duplicateUsername: MapEntry(6, + 'Duplicate username is sent while enforce_unique_usernames is enabled'), + ChatErrorCode.messageTooLong: MapEntry(20, 'Message is too long'), + ChatErrorCode.eventNotSupported: MapEntry(18, 'Event is not supported'), + ChatErrorCode.channelFeatureNotSupported: MapEntry(19, + 'The feature is currently disabled on the dashboard (i.e. Reactions & Replies)'), + ChatErrorCode.multipleNestling: MapEntry(21, + 'Multiple Levels Reply is not supported - the API only supports 1 level deep reply threads'), + ChatErrorCode.customCommandEndpointCall: + MapEntry(45, 'Custom Command handler returned an error'), + ChatErrorCode.customCommandEndpointMissing: + MapEntry(44, 'App config does not have custom_action_handler_url'), + ChatErrorCode.authenticationError: + MapEntry(5, 'Unauthenticated, problem with authentication'), + ChatErrorCode.tokenExpired: MapEntry(40, 'Unauthenticated, token expired'), + ChatErrorCode.tokenBeforeIssuedAt: + MapEntry(42, 'Unauthenticated, token date incorrect'), + ChatErrorCode.tokenNotValid: + MapEntry(41, 'Unauthenticated, token not valid yet'), + ChatErrorCode.tokenSignatureInvalid: + MapEntry(43, 'Unauthenticated, token signature invalid'), + ChatErrorCode.accessKeyError: MapEntry(2, 'Access Key invalid'), + ChatErrorCode.notAllowed: + MapEntry(17, 'Unauthorised / forbidden to make request'), + ChatErrorCode.appSuspended: MapEntry(99, 'App suspended'), + ChatErrorCode.cooldownError: + MapEntry(60, 'User tried to post a message during the cooldown period'), + ChatErrorCode.doesNotExist: MapEntry(16, 'Resource not found'), + ChatErrorCode.requestTimeout: MapEntry(23, 'Request timed out'), + ChatErrorCode.payloadTooBig: MapEntry(22, 'Payload too big'), + ChatErrorCode.rateLimitError: + MapEntry(9, 'Too many requests in a certain time frame'), + ChatErrorCode.maximumHeaderSizeExceeded: + MapEntry(24, 'Request headers are too large'), + ChatErrorCode.internalSystemError: + MapEntry(-1, 'Something goes wrong in the system'), + ChatErrorCode.noAccessToChannels: + MapEntry(70, 'No access to requested channels'), +}; + +const _authenticationErrors = [ + ChatErrorCode.undefinedToken, + ChatErrorCode.authenticationError, + ChatErrorCode.tokenExpired, + ChatErrorCode.tokenBeforeIssuedAt, + ChatErrorCode.tokenNotValid, + ChatErrorCode.tokenSignatureInvalid, + ChatErrorCode.accessKeyError, + ChatErrorCode.noAccessToChannels, +]; + +/// +ChatErrorCode? chatErrorCodeFromCode(int code) => _errorCodeWithDescription.keys + .firstWhereOrNull((key) => _errorCodeWithDescription[key]!.key == code); + +/// +extension ChatErrorCodeX on ChatErrorCode { + /// + String get message => _errorCodeWithDescription[this]!.value; + + /// + int get code => _errorCodeWithDescription[this]!.key; + + /// + bool get isAuthenticationError => _authenticationErrors.contains(this); +} diff --git a/packages/stream_chat/lib/src/core/error/error.dart b/packages/stream_chat/lib/src/core/error/error.dart new file mode 100644 index 00000000..ac4a1e0d --- /dev/null +++ b/packages/stream_chat/lib/src/core/error/error.dart @@ -0,0 +1,2 @@ +export 'chat_error_code.dart'; +export 'stream_chat_error.dart'; diff --git a/packages/stream_chat/lib/src/core/error/stream_chat_error.dart b/packages/stream_chat/lib/src/core/error/stream_chat_error.dart new file mode 100644 index 00000000..13ccef2a --- /dev/null +++ b/packages/stream_chat/lib/src/core/error/stream_chat_error.dart @@ -0,0 +1,141 @@ +import 'package:equatable/equatable.dart'; +import 'package:stream_chat/src/core/error/chat_error_code.dart'; +import 'package:stream_chat/stream_chat.dart'; +import 'package:web_socket_channel/web_socket_channel.dart'; + +/// +class StreamChatError with EquatableMixin implements Exception { + /// + const StreamChatError(this.message); + + /// Error message + final String message; + + @override + List get props => [message]; + + @override + String toString() => 'StreamChatError(message: $message)'; +} + +/// +class StreamWebSocketError extends StreamChatError { + /// + const StreamWebSocketError( + String message, { + this.data, + }) : super(message); + + /// + factory StreamWebSocketError.fromStreamError(Map error) { + final data = ErrorResponse.fromJson(error); + final message = data.message ?? ''; + return StreamWebSocketError(message, data: data); + } + + /// + factory StreamWebSocketError.fromWebSocketChannelError( + WebSocketChannelException error) { + final message = error.message ?? ''; + return StreamWebSocketError(message); + } + + /// + int? get code => data?.code; + + /// + ChatErrorCode? get errorCode { + final code = this.code; + if (code == null) return null; + return chatErrorCodeFromCode(code); + } + + /// Response body. please refer to [ErrorResponse]. + final ErrorResponse? data; + + /// + bool get isRetriable => data == null; + + @override + List get props => [...super.props, code]; + + @override + String toString() { + var params = 'message: $message'; + if (data != null) params += ', data: $data'; + return 'WebSocketError($params)'; + } +} + +/// +class StreamChatNetworkError extends StreamChatError { + /// + StreamChatNetworkError( + ChatErrorCode errorCode, { + int? statusCode, + this.data, + }) : code = errorCode.code, + statusCode = statusCode ?? data?.statusCode, + super(errorCode.message); + + /// + StreamChatNetworkError.raw({ + required this.code, + required String message, + this.statusCode, + this.data, + }) : super(message); + + /// + factory StreamChatNetworkError.fromDioError(DioError error) { + final response = error.response; + ErrorResponse? errorResponse; + final data = response?.data; + if (data != null) { + errorResponse = ErrorResponse.fromJson(data); + } + return StreamChatNetworkError.raw( + code: errorResponse?.code ?? -1, + message: + errorResponse?.message ?? response?.statusMessage ?? error.message, + statusCode: errorResponse?.statusCode ?? response?.statusCode, + data: errorResponse, + )..stackTrace = error.stackTrace; + } + + /// Error code + final int code; + + /// HTTP status code + final int? statusCode; + + /// Response body. please refer to [ErrorResponse]. + final ErrorResponse? data; + + StackTrace? _stackTrace; + + /// + set stackTrace(StackTrace? stack) => _stackTrace = stack; + + /// + ChatErrorCode? get errorCode => chatErrorCodeFromCode(code); + + /// + bool get isRetriable => data == null; + + @override + List get props => [...super.props, code, statusCode]; + + @override + String toString({bool printStackTrace = false}) { + var params = 'code: $code, message: $message'; + if (statusCode != null) params += ', statusCode: $statusCode'; + if (data != null) params += ', data: $data'; + var msg = 'StreamChatNetworkError($params)'; + + if (printStackTrace && _stackTrace != null) { + msg += '\n$_stackTrace'; + } + return msg; + } +} diff --git a/packages/stream_chat/lib/src/core/http/connection_id_manager.dart b/packages/stream_chat/lib/src/core/http/connection_id_manager.dart new file mode 100644 index 00000000..59dcb1b6 --- /dev/null +++ b/packages/stream_chat/lib/src/core/http/connection_id_manager.dart @@ -0,0 +1,27 @@ +// ignore_for_file: use_setters_to_change_properties + +/// Handles the connection id of the websocket connection +class ConnectionIdManager { + /// Initialize a new connection id manager + ConnectionIdManager({ + String? connectionId, + }) : _connectionId = connectionId; + + String? _connectionId; + + /// Get the current connection id + String? get connectionId => _connectionId; + + /// True if there is a connection id + bool get hasConnectionId => _connectionId != null; + + /// Set the connection id + void setConnectionId(String connectionId) { + _connectionId = connectionId; + } + + /// Clear the connection id + void reset() { + _connectionId = null; + } +} diff --git a/packages/stream_chat/lib/src/core/http/interceptor/auth_interceptor.dart b/packages/stream_chat/lib/src/core/http/interceptor/auth_interceptor.dart new file mode 100644 index 00000000..9947eefa --- /dev/null +++ b/packages/stream_chat/lib/src/core/http/interceptor/auth_interceptor.dart @@ -0,0 +1,91 @@ +import 'package:dio/dio.dart'; +import 'package:stream_chat/src/core/api/responses.dart'; +import 'package:stream_chat/src/core/error/error.dart'; +import 'package:stream_chat/src/core/http/stream_chat_dio_error.dart'; +import 'package:stream_chat/src/core/http/stream_http_client.dart'; +import 'package:stream_chat/src/core/http/token.dart'; +import 'package:stream_chat/src/core/http/token_manager.dart'; + +/// Authentication interceptor that refreshes the token if +/// an auth error is received +class AuthInterceptor extends Interceptor { + /// Initialize a new auth interceptor + AuthInterceptor(this._client, this._tokenManager); + + final StreamHttpClient _client; + + /// The token manager used in the client + final TokenManager _tokenManager; + + @override + Future onRequest( + RequestOptions options, + RequestInterceptorHandler handler, + ) async { + late Token token; + try { + token = await _tokenManager.loadToken(); + } catch (_) { + final error = StreamChatNetworkError(ChatErrorCode.undefinedToken); + final dioError = StreamChatDioError( + error: error, + requestOptions: options, + ); + return handler.reject(dioError, true); + } + final params = {'user_id': token.userId}; + final headers = { + 'Authorization': token.rawValue, + 'stream-auth-type': token.authType.raw, + }; + options..queryParameters.addAll(params)..headers.addAll(headers); + return handler.next(options); + } + + @override + void onError( + DioError err, + ErrorInterceptorHandler handler, + ) async { + ErrorResponse? error; + final data = err.response?.data; + if (data != null) error = ErrorResponse.fromJson(data); + if (error?.code == ChatErrorCode.tokenExpired.code) { + if (_tokenManager.isStatic) return handler.next(err); + _client.lock(); + await _tokenManager.loadToken(refresh: true); + _client.unlock(); + try { + final options = err.requestOptions; + final response = await _client.request( + options.path, + cancelToken: options.cancelToken, + data: options.data, + onReceiveProgress: options.onReceiveProgress, + onSendProgress: options.onSendProgress, + queryParameters: options.queryParameters, + options: Options( + method: options.method, + sendTimeout: options.sendTimeout, + receiveTimeout: options.receiveTimeout, + extra: options.extra, + headers: options.headers, + responseType: options.responseType, + contentType: options.contentType, + validateStatus: options.validateStatus, + receiveDataWhenStatusError: options.receiveDataWhenStatusError, + followRedirects: options.followRedirects, + maxRedirects: options.maxRedirects, + requestEncoder: options.requestEncoder, + responseDecoder: options.responseDecoder, + listFormat: options.listFormat, + ), + ); + return handler.resolve(response); + } on DioError catch (error) { + return handler.next(error); + } + } + return handler.next(err); + } +} diff --git a/packages/stream_chat/lib/src/core/http/interceptor/connection_id_interceptor.dart b/packages/stream_chat/lib/src/core/http/interceptor/connection_id_interceptor.dart new file mode 100644 index 00000000..59e1c4bc --- /dev/null +++ b/packages/stream_chat/lib/src/core/http/interceptor/connection_id_interceptor.dart @@ -0,0 +1,24 @@ +import 'package:dio/dio.dart'; +import 'package:stream_chat/src/core/http/connection_id_manager.dart'; + +/// Interceptor that injects the connection id in the request params +class ConnectionIdInterceptor extends Interceptor { + /// + ConnectionIdInterceptor(this.connectionIdManager); + + /// + final ConnectionIdManager connectionIdManager; + + @override + void onRequest( + RequestOptions options, + RequestInterceptorHandler handler, + ) async { + if (connectionIdManager.hasConnectionId) { + options.queryParameters.addAll({ + 'connection_id': connectionIdManager.connectionId, + }); + } + handler.next(options); + } +} diff --git a/packages/stream_chat/lib/src/core/http/interceptor/logging_interceptor.dart b/packages/stream_chat/lib/src/core/http/interceptor/logging_interceptor.dart new file mode 100644 index 00000000..f78b46ea --- /dev/null +++ b/packages/stream_chat/lib/src/core/http/interceptor/logging_interceptor.dart @@ -0,0 +1,332 @@ +// ignore_for_file: lines_longer_than_80_chars +// coverage:ignore-file + +import 'dart:math' as math; + +import 'package:dio/dio.dart'; + +/// Step where we're logging +enum InterceptStep { + /// Request + request, + + /// Response + response, + + /// Error + error, +} + +/// Function used to print the log +typedef LogPrint = void Function(InterceptStep step, Object object); + +void _defaultLogPrint(InterceptStep step, Object object) => print(object); + +/// Interceptor dedicated to logging +class LoggingInterceptor extends Interceptor { + /// Initialize a new logging interceptor + LoggingInterceptor({ + this.request = true, + this.requestHeader = false, + this.requestBody = true, + this.responseHeader = false, + this.responseBody = true, + this.error = true, + this.maxWidth = 120, + this.compact = true, + this.logPrint = _defaultLogPrint, + }); + + /// Print request [Options] + final bool request; + + /// Print request header [Options.headers] + final bool requestHeader; + + /// Print request data [Options.data] + final bool requestBody; + + /// Print [Response.data] + final bool responseBody; + + /// Print [Response.headers] + final bool responseHeader; + + /// Print error message + final bool error; + + /// InitialTab count to logPrint json response + static const int initialTab = 1; + + /// 1 tab length + static const String tabStep = ' '; + + /// Print compact json response + final bool compact; + + /// Width size per logPrint + final int maxWidth; + + /// Log printer; defaults logPrint log to console. + /// In flutter, you'd better use debugPrint. + /// you can also write log in a file. + void Function(InterceptStep step, Object object) logPrint; + + @override + void onRequest(RequestOptions options, RequestInterceptorHandler handler) { + if (request) { + _printRequestHeader(_logPrintRequest, options); + } + if (requestHeader) { + _printMapAsTable( + _logPrintRequest, + options.queryParameters, + header: 'Query Parameters', + ); + final requestHeaders = {...options.headers}; + requestHeaders['contentType'] = options.contentType?.toString(); + requestHeaders['responseType'] = options.responseType.toString(); + requestHeaders['followRedirects'] = options.followRedirects; + requestHeaders['connectTimeout'] = options.connectTimeout; + requestHeaders['receiveTimeout'] = options.receiveTimeout; + _printMapAsTable(_logPrintRequest, requestHeaders, header: 'Headers'); + _printMapAsTable(_logPrintRequest, options.extra, header: 'Extras'); + } + if (requestBody && options.method != 'GET') { + final dynamic data = options.data; + if (data != null) { + if (data is Map) { + _printMapAsTable( + _logPrintRequest, + options.data as Map?, + header: 'Body', + ); + } else if (data is FormData) { + final formDataMap = {} + ..addEntries(data.fields) + ..addEntries(data.files); + _printMapAsTable(_logPrintRequest, formDataMap, + header: 'Form data | ${data.boundary}'); + } else { + _printBlock(_logPrintRequest, data.toString()); + } + } + } + super.onRequest(options, handler); + } + + @override + void onError(DioError err, ErrorInterceptorHandler handler) { + if (error) { + if (err.type == DioErrorType.response) { + final uri = err.response?.requestOptions.uri; + _printBoxed( + _logPrintError, + header: + 'DioError ║ Status: ${err.response?.statusCode} ${err.response?.statusMessage}', + text: uri.toString(), + ); + if (err.response != null && err.response?.data != null) { + _logPrintError('╔ ${err.type.toString()}'); + _printResponse(_logPrintError, err.response!); + } + _printLine(_logPrintError, '╚'); + _logPrintError(''); + } else { + _printBoxed( + _logPrintError, + header: 'DioError ║ ${err.type}', + text: err.message, + ); + _printRequestHeader(_logPrintError, err.requestOptions); + } + } + super.onError(err, handler); + } + + @override + void onResponse(Response response, ResponseInterceptorHandler handler) { + _printResponseHeader(_logPrintResponse, response); + if (responseHeader) { + final responseHeaders = {}; + response.headers + .forEach((k, list) => responseHeaders[k] = list.toString()); + _printMapAsTable(_logPrintResponse, responseHeaders, header: 'Headers'); + } + + if (responseBody) { + _logPrintResponse('╔ Body'); + _logPrintResponse('║'); + _printResponse(_logPrintResponse, response); + _logPrintResponse('║'); + _printLine(_logPrintResponse, '╚'); + } + super.onResponse(response, handler); + } + + void _printBoxed( + void Function(Object) logPrint, { + String? header, + String? text, + }) { + logPrint(''); + logPrint('╔╣ $header'); + logPrint('║ $text'); + _printLine(logPrint, '╚'); + } + + void _printResponse(void Function(Object) logPrint, Response response) { + if (response.data != null) { + if (response.data is Map) { + _printPrettyMap(logPrint, response.data as Map); + } else if (response.data is List) { + logPrint('║${_indent()}['); + _printList(logPrint, response.data as List); + logPrint('║${_indent()}['); + } else { + _printBlock(logPrint, response.data.toString()); + } + } + } + + void _printResponseHeader(void Function(Object) logPrint, Response response) { + final uri = response.requestOptions.uri; + final method = response.requestOptions.method; + _printBoxed( + logPrint, + header: + 'Response ║ $method ║ Status: ${response.statusCode} ${response.statusMessage}', + text: uri.toString(), + ); + } + + void _printRequestHeader( + void Function(Object) logPrint, RequestOptions options) { + final uri = options.uri; + final method = options.method; + _printBoxed(logPrint, header: 'Request ║ $method ', text: uri.toString()); + } + + void _printLine(void Function(Object) logPrint, + [String pre = '', String suf = '╝']) => + logPrint('$pre${'═' * maxWidth}$suf'); + + void _printKV(void Function(Object) logPrint, String? key, Object? v) { + final pre = '╟ $key: '; + final msg = v.toString(); + + if (pre.length + msg.length > maxWidth) { + logPrint(pre); + _printBlock(logPrint, msg); + } else { + logPrint('$pre$msg'); + } + } + + void _printBlock(void Function(Object) logPrint, String msg) { + final lines = (msg.length / maxWidth).ceil(); + for (var i = 0; i < lines; ++i) { + logPrint((i >= 0 ? '║ ' : '') + + msg.substring(i * maxWidth, + math.min(i * maxWidth + maxWidth, msg.length))); + } + } + + String _indent([int tabCount = initialTab]) => tabStep * tabCount; + + void _printPrettyMap( + void Function(Object) logPrint, + Map data, { + int tabs = initialTab, + bool isListItem = false, + bool isLast = false, + }) { + var _tabs = tabs; + final isRoot = _tabs == initialTab; + final initialIndent = _indent(_tabs); + _tabs++; + + if (isRoot || isListItem) logPrint('║$initialIndent{'); + + data.keys.toList().asMap().forEach((index, dynamic key) { + final isLast = index == data.length - 1; + dynamic value = data[key]; + if (value is String) { + value = '"${value.toString().replaceAll(RegExp(r'(\r|\n)+'), " ")}"'; + } + if (value is Map) { + if (compact) { + logPrint('║${_indent(_tabs)} $key: $value${!isLast ? ',' : ''}'); + } else { + logPrint('║${_indent(_tabs)} $key: {'); + _printPrettyMap(logPrint, value, tabs: _tabs); + } + } else if (value is List) { + if (compact) { + logPrint('║${_indent(_tabs)} $key: ${value.toString()}'); + } else { + logPrint('║${_indent(_tabs)} $key: ['); + _printList(logPrint, value, tabs: _tabs); + logPrint('║${_indent(_tabs)} ]${isLast ? '' : ','}'); + } + } else { + final msg = value.toString().replaceAll('\n', ''); + final indent = _indent(_tabs); + final linWidth = maxWidth - indent.length; + if (msg.length + indent.length > linWidth) { + final lines = (msg.length / linWidth).ceil(); + for (var i = 0; i < lines; ++i) { + logPrint('║${_indent(_tabs)} ${msg.substring( + i * linWidth, + math.min(i * linWidth + linWidth, msg.length), + )}'); + } + } else { + logPrint('║${_indent(_tabs)} $key: $msg${!isLast ? ',' : ''}'); + } + } + }); + + logPrint('║$initialIndent}${isListItem && !isLast ? ',' : ''}'); + } + + void _printList( + void Function(Object) logPrint, + List list, { + int tabs = initialTab, + }) { + list.asMap().forEach((i, dynamic e) { + final isLast = i == list.length - 1; + if (e is Map) { + if (compact) { + logPrint('║${_indent(tabs)} $e${!isLast ? ',' : ''}'); + } else { + _printPrettyMap(logPrint, e, + tabs: tabs + 1, isListItem: true, isLast: isLast); + } + } else { + logPrint('║${_indent(tabs + 2)} $e${isLast ? '' : ','}'); + } + }); + } + + void _printMapAsTable( + void Function(Object) logPrint, + Map? map, { + String? header, + }) { + if (map == null || map.isEmpty) return; + logPrint('╔ $header '); + map.forEach((dynamic key, dynamic value) => + _printKV(logPrint, key.toString(), value)); + _printLine(logPrint, '╚'); + } + + void _logPrintRequest(Object object) => + logPrint(InterceptStep.request, object); + + void _logPrintResponse(Object object) => + logPrint(InterceptStep.response, object); + + void _logPrintError(Object object) => logPrint(InterceptStep.error, object); +} diff --git a/packages/stream_chat/lib/src/core/http/stream_chat_dio_error.dart b/packages/stream_chat/lib/src/core/http/stream_chat_dio_error.dart new file mode 100644 index 00000000..a8ee0988 --- /dev/null +++ b/packages/stream_chat/lib/src/core/http/stream_chat_dio_error.dart @@ -0,0 +1,21 @@ +import 'package:dio/dio.dart'; +import 'package:stream_chat/src/core/error/error.dart'; + +/// Error class specific to StreamChat and Dio +class StreamChatDioError extends DioError { + /// Initialize a stream chat dio error + StreamChatDioError({ + required this.error, + required RequestOptions requestOptions, + Response? response, + DioErrorType type = DioErrorType.other, + }) : super( + error: error, + requestOptions: requestOptions, + response: response, + type: type, + ); + + @override + final StreamChatNetworkError error; +} diff --git a/packages/stream_chat/lib/src/core/http/stream_http_client.dart b/packages/stream_chat/lib/src/core/http/stream_http_client.dart new file mode 100644 index 00000000..406138c3 --- /dev/null +++ b/packages/stream_chat/lib/src/core/http/stream_http_client.dart @@ -0,0 +1,281 @@ +import 'dart:async'; + +import 'package:dio/dio.dart'; +import 'package:logging/logging.dart'; +import 'package:meta/meta.dart'; +import 'package:stream_chat/src/core/error/error.dart'; +import 'package:stream_chat/src/core/http/connection_id_manager.dart'; +import 'package:stream_chat/src/core/http/interceptor/auth_interceptor.dart'; +import 'package:stream_chat/src/core/http/interceptor/connection_id_interceptor.dart'; +import 'package:stream_chat/src/core/http/interceptor/logging_interceptor.dart'; +import 'package:stream_chat/src/core/http/stream_chat_dio_error.dart'; +import 'package:stream_chat/src/core/http/token_manager.dart'; +import 'package:stream_chat/src/core/platform_detector/platform_detector.dart'; +import 'package:stream_chat/src/location.dart'; +import 'package:stream_chat/version.dart'; + +part 'stream_http_client_options.dart'; + +/// This is where we configure the base url, headers, +/// query parameters and convenient methods for http verbs with error parsing. +class StreamHttpClient { + /// [StreamHttpClient] constructor + StreamHttpClient( + this.apiKey, { + Dio? dio, + StreamHttpClientOptions? options, + TokenManager? tokenManager, + ConnectionIdManager? connectionIdManager, + Logger? logger, + }) : _options = options ?? const StreamHttpClientOptions(), + httpClient = dio ?? Dio() { + httpClient + ..options.baseUrl = _options.baseUrl + ..options.receiveTimeout = _options.receiveTimeout.inMilliseconds + ..options.connectTimeout = _options.connectTimeout.inMilliseconds + ..options.queryParameters = {'api_key': apiKey} + ..options.headers = { + 'Content-Type': 'application/json', + 'X-Stream-Client': _options.userAgent, + 'Content-Encoding': 'application/gzip', + } + ..interceptors.addAll([ + if (tokenManager != null) AuthInterceptor(this, tokenManager), + if (connectionIdManager != null) + ConnectionIdInterceptor(connectionIdManager), + if (logger != null && logger.level != Level.OFF) + LoggingInterceptor( + requestHeader: true, + logPrint: (step, message) { + switch (step) { + case InterceptStep.request: + return logger.info(message); + case InterceptStep.response: + return logger.info(message); + case InterceptStep.error: + return logger.severe(message); + } + }, + ), + ]); + } + + /// Your project Stream Chat api key. + /// Find your API keys here https://getstream.io/dashboard/ + final String apiKey; + + /// Your project Stream Chat ClientOptions + final StreamHttpClientOptions _options; + + /// [Dio] httpClient + /// It's been chosen because it's easy to use + /// and supports interesting features out of the box + /// (Interceptors, Global configuration, FormData, File downloading etc.) + @visibleForTesting + final Dio httpClient; + + /// Lock the current [StreamHttpClient] instance. + /// + /// [StreamHttpClient] will enqueue the incoming request tasks instead + /// send them directly when [interceptor.requestOptions] is locked. + void lock() => httpClient.lock(); + + /// Unlock the current [StreamHttpClient] instance. + /// + /// [StreamHttpClient] instance dequeue the request task。 + void unlock() => httpClient.unlock(); + + /// Clear the current [StreamHttpClient] instance waiting queue. + void clear() => httpClient.clear(); + + /// Shuts down the [StreamHttpClient]. + /// + /// If [force] is `false` the [StreamHttpClient] will be kept alive + /// until all active connections are done. If [force] is `true` any active + /// connections will be closed to immediately release all resources. These + /// closed connections will receive an error event to indicate that the client + /// was shut down. In both cases trying to establish a new connection after + /// calling [close] will throw an exception. + void close({bool force = false}) => httpClient.close(force: force); + + StreamChatNetworkError _parseError(DioError err) { + StreamChatNetworkError error; + // locally thrown dio error + if (err is StreamChatDioError) { + error = err.error; + } else { + // real network request dio error + error = StreamChatNetworkError.fromDioError(err); + } + return error..stackTrace = err.stackTrace; + } + + /// Handy method to make http GET request with error parsing. + Future> get( + String path, { + Map? queryParameters, + Map? headers, + ProgressCallback? onReceiveProgress, + CancelToken? cancelToken, + }) async { + try { + final response = await httpClient.get( + path, + queryParameters: queryParameters, + options: Options(headers: headers), + onReceiveProgress: onReceiveProgress, + cancelToken: cancelToken, + ); + return response; + } on DioError catch (error) { + throw _parseError(error); + } + } + + /// Handy method to make http POST request with error parsing. + Future> post( + String path, { + Object? data, + Map? queryParameters, + Map? headers, + ProgressCallback? onSendProgress, + ProgressCallback? onReceiveProgress, + CancelToken? cancelToken, + }) async { + try { + final response = await httpClient.post( + path, + queryParameters: queryParameters, + data: data, + options: Options(headers: headers), + onSendProgress: onSendProgress, + onReceiveProgress: onReceiveProgress, + cancelToken: cancelToken, + ); + return response; + } on DioError catch (error) { + throw _parseError(error); + } + } + + /// Handy method to make http DELETE request with error parsing. + Future> delete( + String path, { + Map? queryParameters, + Map? headers, + CancelToken? cancelToken, + }) async { + try { + final response = await httpClient.delete( + path, + queryParameters: queryParameters, + options: Options(headers: headers), + cancelToken: cancelToken, + ); + return response; + } on DioError catch (error) { + throw _parseError(error); + } + } + + /// Handy method to make http PATCH request with error parsing. + Future> patch( + String path, { + Object? data, + Map? queryParameters, + Map? headers, + ProgressCallback? onSendProgress, + ProgressCallback? onReceiveProgress, + CancelToken? cancelToken, + }) async { + try { + final response = await httpClient.patch( + path, + queryParameters: queryParameters, + data: data, + options: Options(headers: headers), + onSendProgress: onSendProgress, + onReceiveProgress: onReceiveProgress, + cancelToken: cancelToken, + ); + return response; + } on DioError catch (error) { + throw _parseError(error); + } + } + + /// Handy method to make http PUT request with error parsing. + Future> put( + String path, { + Object? data, + Map? queryParameters, + Map? headers, + ProgressCallback? onSendProgress, + ProgressCallback? onReceiveProgress, + CancelToken? cancelToken, + }) async { + try { + final response = await httpClient.put( + path, + queryParameters: queryParameters, + data: data, + options: Options(headers: headers), + onSendProgress: onSendProgress, + onReceiveProgress: onReceiveProgress, + cancelToken: cancelToken, + ); + return response; + } on DioError catch (error) { + throw _parseError(error); + } + } + + /// Handy method to post files with error parsing. + Future> postFile( + String path, + MultipartFile file, { + Map? queryParameters, + Map? headers, + ProgressCallback? onSendProgress, + ProgressCallback? onReceiveProgress, + CancelToken? cancelToken, + }) async { + final formData = FormData.fromMap({'file': file}); + final response = await post( + path, + data: formData, + queryParameters: queryParameters, + headers: headers, + onSendProgress: onSendProgress, + onReceiveProgress: onReceiveProgress, + cancelToken: cancelToken, + ); + return response; + } + + /// Handy method to make generic http request with error parsing. + Future> request( + String path, { + Object? data, + Map? queryParameters, + Options? options, + ProgressCallback? onSendProgress, + ProgressCallback? onReceiveProgress, + CancelToken? cancelToken, + }) async { + try { + final response = await httpClient.request( + path, + data: data, + queryParameters: queryParameters, + options: options, + onSendProgress: onSendProgress, + onReceiveProgress: onReceiveProgress, + cancelToken: cancelToken, + ); + return response; + } on DioError catch (error) { + throw _parseError(error); + } + } +} diff --git a/packages/stream_chat/lib/src/core/http/stream_http_client_options.dart b/packages/stream_chat/lib/src/core/http/stream_http_client_options.dart new file mode 100644 index 00000000..faad46a1 --- /dev/null +++ b/packages/stream_chat/lib/src/core/http/stream_http_client_options.dart @@ -0,0 +1,39 @@ +part of 'stream_http_client.dart'; + +const _defaultBaseURL = 'https://chat-us-east-1.stream-io-api.com'; + +/// Client options to modify [StreamHttpClient] +class StreamHttpClientOptions { + /// Instantiates a new [StreamHttpClientOptions] + const StreamHttpClientOptions({ + String? baseUrl, + this.location, + this.connectTimeout = const Duration(seconds: 6), + this.receiveTimeout = const Duration(seconds: 6), + }) : _baseUrl = baseUrl ?? _defaultBaseURL; + + final String _baseUrl; + + /// base url to use with client. + String get baseUrl { + if (location == null) return _baseUrl; + const serviceName = 'chat'; + final locationName = location!.name; + const baseDomainName = 'stream-io-api.com'; + return 'https://$serviceName-proxy-$locationName.$baseDomainName'; + } + + /// data center to use with client + final Location? location; + + /// connect timeout, default to 6s + final Duration connectTimeout; + + /// received timeout, default to 6s + final Duration receiveTimeout; + + /// Get the current user agent + String get userAgent => 'stream-chat-dart-client-' + '${CurrentPlatform.name}-' + '${PACKAGE_VERSION.split('+')[0]}'; +} diff --git a/packages/stream_chat/lib/src/core/http/token.dart b/packages/stream_chat/lib/src/core/http/token.dart new file mode 100644 index 00000000..7d746286 --- /dev/null +++ b/packages/stream_chat/lib/src/core/http/token.dart @@ -0,0 +1,86 @@ +import 'dart:convert'; + +import 'package:equatable/equatable.dart'; +import 'package:jose/jose.dart'; +import 'package:stream_chat/src/core/models/user.dart'; +import 'package:stream_chat/src/core/util/utils.dart'; + +/// A function which can be used to request a Stream Chat API token from your +/// own backend server +typedef GuestTokenProvider = Future Function(User user); + +/// Authentication type +enum AuthType { + /// JWT token + jwt, + + /// Anonymous user + anonymous, +} + +/// Extension for returning the AuthType as a string +extension AuthTypeX on AuthType { + /// Returns the AuthType as a string + String get raw => { + AuthType.jwt: 'jwt', + AuthType.anonymous: 'anonymous', + }[this]!; +} + +/// Token designed to store the JWT and the user it is related to. +class Token extends Equatable { + const Token._({ + required this.rawValue, + required this.userId, + required this.authType, + }); + + /// The token that can be used when user is unknown. + /// Is used by `anonymous` token provider. + factory Token.anonymous({String? userId}) => Token._( + rawValue: '', + userId: userId ?? randomId(), + authType: AuthType.anonymous, + ); + + /// Creates a [Token] instance from the provided [rawValue] if it's valid. + factory Token.fromRawValue(String rawValue) { + final jwtBody = JsonWebToken.unverified(rawValue); + final userId = jwtBody.claims.getTyped('user_id'); + assert( + userId != null, + 'Invalid `token`, It should contain `user_id`', + ); + return Token._(rawValue: rawValue, userId: userId!, authType: AuthType.jwt); + } + + /// The token which can be used during the development. + /// Is used by `development(userId:)` token provider. + factory Token.development(String userId) { + const devSignature = 'devtoken'; + const header = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9'; + final payload = json.encode({'user_id': userId}); + final payloadBytes = utf8.encode(payload); + final payloadB64 = base64.encode(payloadBytes); + final jwt = '$header.$payloadB64.$devSignature'; + return Token._(rawValue: jwt, userId: userId, authType: AuthType.jwt); + } + + /// The token which designed to be used for guest users. + static Future guest(User user, GuestTokenProvider provider) async { + final rawToken = await provider(user); + return Token.fromRawValue(rawToken); + } + + /// Authentication type of this token + final AuthType authType; + + /// String value of the token + final String rawValue; + + /// User id associated with this token + final String userId; + + @override + List get props => [authType, rawValue, userId]; +} diff --git a/packages/stream_chat/lib/src/core/http/token_manager.dart b/packages/stream_chat/lib/src/core/http/token_manager.dart new file mode 100644 index 00000000..e5af0ddc --- /dev/null +++ b/packages/stream_chat/lib/src/core/http/token_manager.dart @@ -0,0 +1,81 @@ +import 'package:stream_chat/src/core/http/token.dart'; + +/// A function which can be used to request a Stream Chat API token from your +/// own backend server. +/// Function requires a single [userId]. +typedef TokenProvider = Future Function(String userId); + +/// Handles common token operations +class TokenManager { + /// Initialize a new token manager + TokenManager({ + String? userId, + Token? token, + TokenProvider? tokenProvider, + }) : _userId = userId, + _token = token, + _provider = tokenProvider; + + String? _type; + Token? _token; + + TokenProvider? _provider; + + String? _userId; + + /// User id to which this TokenManager is configured to + String? get userId => _userId; + + /// True if it's a static token + bool get isStatic => _type == 'static'; + + /// Set a token or a token provider + Future setTokenOrProvider( + String userId, { + Token? token, + TokenProvider? provider, + }) async { + assert(() { + if (token == null && provider == null) { + throw AssertionError('Provide at-least token or provider'); + } + if (token != null && provider != null) { + throw AssertionError("Can't set both token and provider"); + } + return true; + }(), ''); + + _userId = userId; + + if (token != null) { + _type = 'static'; + _token = token; + } + if (provider != null) { + _type = 'provider'; + _provider = provider; + } + + return loadToken(); + } + + /// Returns the token refreshing the existing one if [refresh] is true + Future loadToken({bool refresh = false}) async { + assert( + _userId != null && _type != null, + 'Please call `setTokenOrProvider` before calling `loadToken`', + ); + if (refresh || _token == null) { + final rawValue = await _provider!(_userId!); + _token = Token.fromRawValue(rawValue); + } + return _token!; + } + + /// Resets the token manager + void reset() { + _userId = null; + _token = null; + _provider = null; + } +} diff --git a/packages/stream_chat/lib/src/models/action.dart b/packages/stream_chat/lib/src/core/models/action.dart similarity index 77% rename from packages/stream_chat/lib/src/models/action.dart rename to packages/stream_chat/lib/src/core/models/action.dart index 62d0f104..16a307e7 100644 --- a/packages/stream_chat/lib/src/models/action.dart +++ b/packages/stream_chat/lib/src/core/models/action.dart @@ -6,7 +6,13 @@ part 'action.g.dart'; @JsonSerializable() class Action { /// Constructor used for json serialization - Action({this.name, this.style, this.text, this.type, this.value}); + Action({ + required this.name, + this.style = 'default', + required this.text, + required this.type, + this.value, + }); /// Create a new instance from a json factory Action.fromJson(Map json) => _$ActionFromJson(json); @@ -15,6 +21,7 @@ class Action { final String name; /// The style of the action + @JsonKey(defaultValue: 'default') final String style; /// The test of the action @@ -24,7 +31,7 @@ class Action { final String type; /// The value of the action - final String value; + final String? value; /// Serialize to json Map toJson() => _$ActionToJson(this); diff --git a/packages/stream_chat/lib/src/models/action.g.dart b/packages/stream_chat/lib/src/core/models/action.g.dart similarity index 81% rename from packages/stream_chat/lib/src/models/action.g.dart rename to packages/stream_chat/lib/src/core/models/action.g.dart index 3c567843..9ae999ed 100644 --- a/packages/stream_chat/lib/src/models/action.g.dart +++ b/packages/stream_chat/lib/src/core/models/action.g.dart @@ -6,13 +6,13 @@ part of 'action.dart'; // JsonSerializableGenerator // ************************************************************************** -Action _$ActionFromJson(Map json) { +Action _$ActionFromJson(Map json) { return Action( name: json['name'] as String, - style: json['style'] as String, + style: json['style'] as String? ?? 'default', text: json['text'] as String, type: json['type'] as String, - value: json['value'] as String, + value: json['value'] as String?, ); } diff --git a/packages/stream_chat/lib/src/models/attachment.dart b/packages/stream_chat/lib/src/core/models/attachment.dart similarity index 52% rename from packages/stream_chat/lib/src/models/attachment.dart rename to packages/stream_chat/lib/src/core/models/attachment.dart index de5149db..8973aee2 100644 --- a/packages/stream_chat/lib/src/models/attachment.dart +++ b/packages/stream_chat/lib/src/core/models/attachment.dart @@ -1,22 +1,23 @@ // ignore_for_file: public_member_api_docs +import 'package:equatable/equatable.dart'; import 'package:json_annotation/json_annotation.dart'; -import 'package:stream_chat/src/models/action.dart'; -import 'package:stream_chat/src/models/attachment_file.dart'; -import 'package:stream_chat/src/models/serialization.dart'; +import 'package:stream_chat/src/core/models/action.dart'; +import 'package:stream_chat/src/core/models/attachment_file.dart'; +import 'package:stream_chat/src/core/util/serializer.dart'; import 'package:uuid/uuid.dart'; part 'attachment.g.dart'; /// The class that contains the information about an attachment @JsonSerializable(includeIfNull: false) -class Attachment { +class Attachment extends Equatable { /// Constructor used for json serialization Attachment({ - String id, + String? id, this.type, this.titleLink, - String title, + String? title, this.thumbUrl, this.text, this.pretext, @@ -31,13 +32,14 @@ class Attachment { this.authorLink, this.authorIcon, this.assetUrl, - this.actions, - this.extraData, + List? actions, + this.extraData = const {}, this.file, - UploadState uploadState, - }) : id = id ?? Uuid().v4(), + UploadState? uploadState, + }) : id = id ?? const Uuid().v4(), title = title ?? file?.name, - localUri = file?.path != null ? Uri.parse(file.path) : null { + localUri = file?.path != null ? Uri.parse(file!.path!) : null, + actions = actions ?? [] { this.uploadState = uploadState ?? ((assetUrl != null || imageUrl != null) ? const UploadState.success() @@ -47,68 +49,72 @@ class Attachment { /// Create a new instance from a json factory Attachment.fromJson(Map json) => _$AttachmentFromJson( - Serialization.moveToExtraDataFromRoot(json, topLevelFields)); + Serializer.moveToExtraDataFromRoot(json, topLevelFields)); /// Create a new instance from a db data factory Attachment.fromData(Map json) => - _$AttachmentFromJson(Serialization.moveToExtraDataFromRoot( + _$AttachmentFromJson(Serializer.moveToExtraDataFromRoot( json, topLevelFields + dbSpecificTopLevelFields)); ///The attachment type based on the URL resource. This can be: audio, ///image or video - final String type; + final String? type; ///The link to which the attachment message points to. - final String titleLink; + final String? titleLink; /// The attachment title - final String title; + final String? title; /// The URL to the attached file thumbnail. You can use this to represent the /// attached link. - final String thumbUrl; + final String? thumbUrl; /// The attachment text. It will be displayed in the channel next to the /// original message. - final String text; + final String? text; /// Optional text that appears above the attachment block - final String pretext; + final String? pretext; /// The original URL that was used to scrape this attachment. - final String ogScrapeUrl; + final String? ogScrapeUrl; /// The URL to the attached image. This is present for URL pointing to an /// image article (eg. Unsplash) - final String imageUrl; - final String footerIcon; - final String footer; + final String? imageUrl; + final String? footerIcon; + final String? footer; final dynamic fields; - final String fallback; - final String color; + final String? fallback; + final String? color; /// The name of the author. - final String authorName; - final String authorLink; - final String authorIcon; + final String? authorName; + final String? authorLink; + final String? authorIcon; /// The URL to the audio, video or image related to the URL. - final String assetUrl; + final String? assetUrl; /// Actions from a command + @JsonKey(defaultValue: []) final List actions; - final Uri localUri; + final Uri? localUri; /// The file present inside this attachment. - final AttachmentFile file; + final AttachmentFile? file; /// The current upload state of the attachment - UploadState uploadState; + late final UploadState uploadState; /// Map of custom channel extraData - @JsonKey(includeIfNull: false) - final Map extraData; + @JsonKey( + includeIfNull: false, + defaultValue: {}, + ) + final Map extraData; /// The attachment ID. /// @@ -116,7 +122,7 @@ class Attachment { final String id; /// Known top level fields. - /// Useful for [Serialization] methods. + /// Useful for [Serializer] methods. static const topLevelFields = [ 'type', 'title_link', @@ -139,7 +145,7 @@ class Attachment { ]; /// Known db specific top level fields. - /// Useful for [Serialization] methods. + /// Useful for [Serializer] methods. static const dbSpecificTopLevelFields = [ 'id', 'upload_state', @@ -147,37 +153,37 @@ class Attachment { ]; /// Serialize to json - Map toJson() => Serialization.moveFromExtraDataToRoot( - _$AttachmentToJson(this), topLevelFields) - ..removeWhere((key, value) => dbSpecificTopLevelFields.contains(key)); + Map toJson() => + Serializer.moveFromExtraDataToRoot(_$AttachmentToJson(this)) + ..removeWhere((key, value) => dbSpecificTopLevelFields.contains(key)); /// Serialize to db data - Map toData() => Serialization.moveFromExtraDataToRoot( - _$AttachmentToJson(this), topLevelFields + dbSpecificTopLevelFields); + Map toData() => + Serializer.moveFromExtraDataToRoot(_$AttachmentToJson(this)); Attachment copyWith({ - String id, - String type, - String titleLink, - String title, - String thumbUrl, - String text, - String pretext, - String ogScrapeUrl, - String imageUrl, - String footerIcon, - String footer, + String? id, + String? type, + String? titleLink, + String? title, + String? thumbUrl, + String? text, + String? pretext, + String? ogScrapeUrl, + String? imageUrl, + String? footerIcon, + String? footer, dynamic fields, - String fallback, - String color, - String authorName, - String authorLink, - String authorIcon, - String assetUrl, - List actions, - AttachmentFile file, - UploadState uploadState, - Map extraData, + String? fallback, + String? color, + String? authorName, + String? authorLink, + String? authorIcon, + String? assetUrl, + List? actions, + AttachmentFile? file, + UploadState? uploadState, + Map? extraData, }) => Attachment( id: id ?? this.id, @@ -205,49 +211,28 @@ class Attachment { ); @override - bool operator ==(Object other) => - identical(this, other) || - other is Attachment && - runtimeType == other.runtimeType && - type == other.type && - titleLink == other.titleLink && - title == other.title && - thumbUrl == other.thumbUrl && - text == other.text && - pretext == other.pretext && - ogScrapeUrl == other.ogScrapeUrl && - imageUrl == other.imageUrl && - footerIcon == other.footerIcon && - footer == other.footer && - fields == other.fields && - fallback == other.fallback && - color == other.color && - authorName == other.authorName && - authorLink == other.authorLink && - authorIcon == other.authorIcon && - assetUrl == other.assetUrl && - actions == other.actions && - extraData == other.extraData; - - @override - int get hashCode => - type.hashCode ^ - titleLink.hashCode ^ - title.hashCode ^ - thumbUrl.hashCode ^ - text.hashCode ^ - pretext.hashCode ^ - ogScrapeUrl.hashCode ^ - imageUrl.hashCode ^ - footerIcon.hashCode ^ - footer.hashCode ^ - fields.hashCode ^ - fallback.hashCode ^ - color.hashCode ^ - authorName.hashCode ^ - authorLink.hashCode ^ - authorIcon.hashCode ^ - assetUrl.hashCode ^ - actions.hashCode ^ - extraData.hashCode; + List get props => [ + id, + type, + titleLink, + title, + thumbUrl, + text, + pretext, + ogScrapeUrl, + imageUrl, + footerIcon, + footer, + fields, + fallback, + color, + authorName, + authorLink, + authorIcon, + assetUrl, + actions, + file, + uploadState, + extraData, + ]; } diff --git a/packages/stream_chat/lib/src/core/models/attachment.g.dart b/packages/stream_chat/lib/src/core/models/attachment.g.dart new file mode 100644 index 00000000..de129ab6 --- /dev/null +++ b/packages/stream_chat/lib/src/core/models/attachment.g.dart @@ -0,0 +1,75 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'attachment.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +Attachment _$AttachmentFromJson(Map json) { + return Attachment( + id: json['id'] as String?, + type: json['type'] as String?, + titleLink: json['title_link'] as String?, + title: json['title'] as String?, + thumbUrl: json['thumb_url'] as String?, + text: json['text'] as String?, + pretext: json['pretext'] as String?, + ogScrapeUrl: json['og_scrape_url'] as String?, + imageUrl: json['image_url'] as String?, + footerIcon: json['footer_icon'] as String?, + footer: json['footer'] as String?, + fields: json['fields'], + fallback: json['fallback'] as String?, + color: json['color'] as String?, + authorName: json['author_name'] as String?, + authorLink: json['author_link'] as String?, + authorIcon: json['author_icon'] as String?, + assetUrl: json['asset_url'] as String?, + actions: (json['actions'] as List?) + ?.map((e) => Action.fromJson(e as Map)) + .toList() ?? + [], + extraData: json['extra_data'] as Map? ?? {}, + file: json['file'] == null + ? null + : AttachmentFile.fromJson(json['file'] as Map), + uploadState: json['upload_state'] == null + ? null + : UploadState.fromJson(json['upload_state'] as Map), + ); +} + +Map _$AttachmentToJson(Attachment instance) { + final val = {}; + + void writeNotNull(String key, dynamic value) { + if (value != null) { + val[key] = value; + } + } + + writeNotNull('type', instance.type); + writeNotNull('title_link', instance.titleLink); + writeNotNull('title', instance.title); + writeNotNull('thumb_url', instance.thumbUrl); + writeNotNull('text', instance.text); + writeNotNull('pretext', instance.pretext); + writeNotNull('og_scrape_url', instance.ogScrapeUrl); + writeNotNull('image_url', instance.imageUrl); + writeNotNull('footer_icon', instance.footerIcon); + writeNotNull('footer', instance.footer); + writeNotNull('fields', instance.fields); + writeNotNull('fallback', instance.fallback); + writeNotNull('color', instance.color); + writeNotNull('author_name', instance.authorName); + writeNotNull('author_link', instance.authorLink); + writeNotNull('author_icon', instance.authorIcon); + writeNotNull('asset_url', instance.assetUrl); + val['actions'] = instance.actions.map((e) => e.toJson()).toList(); + writeNotNull('file', instance.file?.toJson()); + val['upload_state'] = instance.uploadState.toJson(); + val['extra_data'] = instance.extraData; + val['id'] = instance.id; + return val; +} diff --git a/packages/stream_chat/lib/src/models/attachment_file.dart b/packages/stream_chat/lib/src/core/models/attachment_file.dart similarity index 53% rename from packages/stream_chat/lib/src/models/attachment_file.dart rename to packages/stream_chat/lib/src/core/models/attachment_file.dart index bae7810f..7bff5806 100644 --- a/packages/stream_chat/lib/src/models/attachment_file.dart +++ b/packages/stream_chat/lib/src/core/models/attachment_file.dart @@ -1,25 +1,32 @@ import 'dart:typed_data'; +import 'package:dio/dio.dart' show MultipartFile; import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:meta/meta.dart'; +import 'package:stream_chat/src/core/platform_detector/platform_detector.dart'; +import 'package:stream_chat/src/core/util/extension.dart'; part 'attachment_file.freezed.dart'; + part 'attachment_file.g.dart'; /// Union class to hold various [UploadState] of a attachment. @freezed -abstract class UploadState with _$UploadState { +class UploadState with _$UploadState { /// Preparing state of the union const factory UploadState.preparing() = Preparing; /// InProgress state of the union - const factory UploadState.inProgress({int uploaded, int total}) = InProgress; + const factory UploadState.inProgress({ + required int uploaded, + required int total, + }) = InProgress; /// Success state of the union const factory UploadState.success() = Success; /// Failed state of the union - const factory UploadState.failed({@required String error}) = Failed; + const factory UploadState.failed({required String error}) = Failed; /// Creates a new instance from a json factory UploadState.fromJson(Map json) => @@ -27,7 +34,7 @@ abstract class UploadState with _$UploadState { } /// Helper extension for UploadState -extension UploadStateX on UploadState { +extension UploadStateX on UploadState? { /// Returns true if state is [Preparing] bool get isPreparing => this is Preparing; @@ -41,20 +48,33 @@ extension UploadStateX on UploadState { bool get isFailed => this is Failed; } -Uint8List _fromString(String bytes) => Uint8List.fromList(bytes.codeUnits); +Uint8List? _fromString(String? bytes) { + if (bytes == null) return null; + return Uint8List.fromList(bytes.codeUnits); +} -String _toString(Uint8List bytes) => String.fromCharCodes(bytes); +String? _toString(Uint8List? bytes) { + if (bytes == null) return null; + return String.fromCharCodes(bytes); +} /// The class that contains the information about an attachment file @JsonSerializable() class AttachmentFile { /// Creates a new [AttachmentFile] instance. - const AttachmentFile({ + AttachmentFile({ + required this.size, this.path, this.name, this.bytes, - this.size, - }); + }) : assert( + path != null || bytes != null, + 'Either path or bytes should be != null', + ), + assert( + !CurrentPlatform.isWeb || bytes != null, + 'File by path is not supported in web, Please provide bytes', + ); /// Create a new instance from a json factory AttachmentFile.fromJson(Map json) => @@ -65,22 +85,45 @@ class AttachmentFile { /// ``` /// final File myFile = File(platformFile.path); /// ``` - final String path; + final String? path; /// File name including its extension. - final String name; + final String? name; /// Byte data for this file. Particularly useful if you want to manipulate /// its data or easily upload to somewhere else. @JsonKey(toJson: _toString, fromJson: _fromString) - final Uint8List bytes; + final Uint8List? bytes; /// The file size in bytes. - final int size; + final int? size; /// File extension for this file. - String get extension => name?.split('.')?.last; + String? get extension => name?.split('.').last; /// Serialize to json Map toJson() => _$AttachmentFileToJson(this); + + /// Converts this into a [MultipartFile] + Future toMultipartFile() async { + final filename = path?.split('/').last ?? name; + final mimeType = filename?.mimeType; + + late MultipartFile multiPartFile; + + if (CurrentPlatform.isWeb) { + multiPartFile = MultipartFile.fromBytes( + bytes!, + filename: filename, + contentType: mimeType, + ); + } else { + multiPartFile = await MultipartFile.fromFile( + path!, + filename: filename, + contentType: mimeType, + ); + } + return multiPartFile; + } } diff --git a/packages/stream_chat/lib/src/models/attachment_file.freezed.dart b/packages/stream_chat/lib/src/core/models/attachment_file.freezed.dart similarity index 56% rename from packages/stream_chat/lib/src/models/attachment_file.freezed.dart rename to packages/stream_chat/lib/src/core/models/attachment_file.freezed.dart index 4eea1f13..5d7075c3 100644 --- a/packages/stream_chat/lib/src/models/attachment_file.freezed.dart +++ b/packages/stream_chat/lib/src/core/models/attachment_file.freezed.dart @@ -1,5 +1,5 @@ // GENERATED CODE - DO NOT MODIFY BY HAND -// ignore_for_file: deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides part of 'attachment_file.dart'; @@ -8,6 +8,10 @@ part of 'attachment_file.dart'; // ************************************************************************** T _$identity(T value) => value; + +final _privateConstructorUsedError = UnsupportedError( + 'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more informations: https://github.com/rrousselGit/freezed#custom-getters-and-methods'); + UploadState _$UploadStateFromJson(Map json) { switch (json['runtimeType'] as String) { case 'preparing': @@ -28,74 +32,72 @@ UploadState _$UploadStateFromJson(Map json) { class _$UploadStateTearOff { const _$UploadStateTearOff(); -// ignore: unused_element Preparing preparing() { return const Preparing(); } -// ignore: unused_element - InProgress inProgress({int uploaded, int total}) { + InProgress inProgress({required int uploaded, required int total}) { return InProgress( uploaded: uploaded, total: total, ); } -// ignore: unused_element Success success() { return const Success(); } -// ignore: unused_element - Failed failed({@required String error}) { + Failed failed({required String error}) { return Failed( error: error, ); } -// ignore: unused_element UploadState fromJson(Map json) { return UploadState.fromJson(json); } } /// @nodoc -// ignore: unused_element const $UploadState = _$UploadStateTearOff(); /// @nodoc mixin _$UploadState { @optionalTypeArgs - TResult when({ - @required TResult preparing(), - @required TResult inProgress(int uploaded, int total), - @required TResult success(), - @required TResult failed(String error), - }); + TResult when({ + required TResult Function() preparing, + required TResult Function(int uploaded, int total) inProgress, + required TResult Function() success, + required TResult Function(String error) failed, + }) => + throw _privateConstructorUsedError; @optionalTypeArgs - TResult maybeWhen({ - TResult preparing(), - TResult inProgress(int uploaded, int total), - TResult success(), - TResult failed(String error), - @required TResult orElse(), - }); + TResult maybeWhen({ + TResult Function()? preparing, + TResult Function(int uploaded, int total)? inProgress, + TResult Function()? success, + TResult Function(String error)? failed, + required TResult orElse(), + }) => + throw _privateConstructorUsedError; @optionalTypeArgs - TResult map({ - @required TResult preparing(Preparing value), - @required TResult inProgress(InProgress value), - @required TResult success(Success value), - @required TResult failed(Failed value), - }); + TResult map({ + required TResult Function(Preparing value) preparing, + required TResult Function(InProgress value) inProgress, + required TResult Function(Success value) success, + required TResult Function(Failed value) failed, + }) => + throw _privateConstructorUsedError; @optionalTypeArgs - TResult maybeMap({ - TResult preparing(Preparing value), - TResult inProgress(InProgress value), - TResult success(Success value), - TResult failed(Failed value), - @required TResult orElse(), - }); - Map toJson(); + TResult maybeMap({ + TResult Function(Preparing value)? preparing, + TResult Function(InProgress value)? inProgress, + TResult Function(Success value)? success, + TResult Function(Failed value)? failed, + required TResult orElse(), + }) => + throw _privateConstructorUsedError; + Map toJson() => throw _privateConstructorUsedError; } /// @nodoc @@ -130,9 +132,8 @@ class _$PreparingCopyWithImpl<$Res> extends _$UploadStateCopyWithImpl<$Res> Preparing get _value => super._value as Preparing; } -@JsonSerializable() - /// @nodoc +@JsonSerializable() class _$Preparing implements Preparing { const _$Preparing(); @@ -154,29 +155,24 @@ class _$Preparing implements Preparing { @override @optionalTypeArgs - TResult when({ - @required TResult preparing(), - @required TResult inProgress(int uploaded, int total), - @required TResult success(), - @required TResult failed(String error), + TResult when({ + required TResult Function() preparing, + required TResult Function(int uploaded, int total) inProgress, + required TResult Function() success, + required TResult Function(String error) failed, }) { - assert(preparing != null); - assert(inProgress != null); - assert(success != null); - assert(failed != null); return preparing(); } @override @optionalTypeArgs - TResult maybeWhen({ - TResult preparing(), - TResult inProgress(int uploaded, int total), - TResult success(), - TResult failed(String error), - @required TResult orElse(), + TResult maybeWhen({ + TResult Function()? preparing, + TResult Function(int uploaded, int total)? inProgress, + TResult Function()? success, + TResult Function(String error)? failed, + required TResult orElse(), }) { - assert(orElse != null); if (preparing != null) { return preparing(); } @@ -185,29 +181,24 @@ class _$Preparing implements Preparing { @override @optionalTypeArgs - TResult map({ - @required TResult preparing(Preparing value), - @required TResult inProgress(InProgress value), - @required TResult success(Success value), - @required TResult failed(Failed value), + TResult map({ + required TResult Function(Preparing value) preparing, + required TResult Function(InProgress value) inProgress, + required TResult Function(Success value) success, + required TResult Function(Failed value) failed, }) { - assert(preparing != null); - assert(inProgress != null); - assert(success != null); - assert(failed != null); return preparing(this); } @override @optionalTypeArgs - TResult maybeMap({ - TResult preparing(Preparing value), - TResult inProgress(InProgress value), - TResult success(Success value), - TResult failed(Failed value), - @required TResult orElse(), + TResult maybeMap({ + TResult Function(Preparing value)? preparing, + TResult Function(InProgress value)? inProgress, + TResult Function(Success value)? success, + TResult Function(Failed value)? failed, + required TResult orElse(), }) { - assert(orElse != null); if (preparing != null) { return preparing(this); } @@ -245,21 +236,26 @@ class _$InProgressCopyWithImpl<$Res> extends _$UploadStateCopyWithImpl<$Res> @override $Res call({ - Object uploaded = freezed, - Object total = freezed, + Object? uploaded = freezed, + Object? total = freezed, }) { return _then(InProgress( - uploaded: uploaded == freezed ? _value.uploaded : uploaded as int, - total: total == freezed ? _value.total : total as int, + uploaded: uploaded == freezed + ? _value.uploaded + : uploaded // ignore: cast_nullable_to_non_nullable + as int, + total: total == freezed + ? _value.total + : total // ignore: cast_nullable_to_non_nullable + as int, )); } } -@JsonSerializable() - /// @nodoc +@JsonSerializable() class _$InProgress implements InProgress { - const _$InProgress({this.uploaded, this.total}); + const _$InProgress({required this.uploaded, required this.total}); factory _$InProgress.fromJson(Map json) => _$_$InProgressFromJson(json); @@ -298,29 +294,24 @@ class _$InProgress implements InProgress { @override @optionalTypeArgs - TResult when({ - @required TResult preparing(), - @required TResult inProgress(int uploaded, int total), - @required TResult success(), - @required TResult failed(String error), + TResult when({ + required TResult Function() preparing, + required TResult Function(int uploaded, int total) inProgress, + required TResult Function() success, + required TResult Function(String error) failed, }) { - assert(preparing != null); - assert(inProgress != null); - assert(success != null); - assert(failed != null); return inProgress(uploaded, total); } @override @optionalTypeArgs - TResult maybeWhen({ - TResult preparing(), - TResult inProgress(int uploaded, int total), - TResult success(), - TResult failed(String error), - @required TResult orElse(), + TResult maybeWhen({ + TResult Function()? preparing, + TResult Function(int uploaded, int total)? inProgress, + TResult Function()? success, + TResult Function(String error)? failed, + required TResult orElse(), }) { - assert(orElse != null); if (inProgress != null) { return inProgress(uploaded, total); } @@ -329,29 +320,24 @@ class _$InProgress implements InProgress { @override @optionalTypeArgs - TResult map({ - @required TResult preparing(Preparing value), - @required TResult inProgress(InProgress value), - @required TResult success(Success value), - @required TResult failed(Failed value), + TResult map({ + required TResult Function(Preparing value) preparing, + required TResult Function(InProgress value) inProgress, + required TResult Function(Success value) success, + required TResult Function(Failed value) failed, }) { - assert(preparing != null); - assert(inProgress != null); - assert(success != null); - assert(failed != null); return inProgress(this); } @override @optionalTypeArgs - TResult maybeMap({ - TResult preparing(Preparing value), - TResult inProgress(InProgress value), - TResult success(Success value), - TResult failed(Failed value), - @required TResult orElse(), + TResult maybeMap({ + TResult Function(Preparing value)? preparing, + TResult Function(InProgress value)? inProgress, + TResult Function(Success value)? success, + TResult Function(Failed value)? failed, + required TResult orElse(), }) { - assert(orElse != null); if (inProgress != null) { return inProgress(this); } @@ -365,15 +351,17 @@ class _$InProgress implements InProgress { } abstract class InProgress implements UploadState { - const factory InProgress({int uploaded, int total}) = _$InProgress; + const factory InProgress({required int uploaded, required int total}) = + _$InProgress; factory InProgress.fromJson(Map json) = _$InProgress.fromJson; - int get uploaded; - int get total; + int get uploaded => throw _privateConstructorUsedError; + int get total => throw _privateConstructorUsedError; @JsonKey(ignore: true) - $InProgressCopyWith get copyWith; + $InProgressCopyWith get copyWith => + throw _privateConstructorUsedError; } /// @nodoc @@ -392,9 +380,8 @@ class _$SuccessCopyWithImpl<$Res> extends _$UploadStateCopyWithImpl<$Res> Success get _value => super._value as Success; } -@JsonSerializable() - /// @nodoc +@JsonSerializable() class _$Success implements Success { const _$Success(); @@ -416,29 +403,24 @@ class _$Success implements Success { @override @optionalTypeArgs - TResult when({ - @required TResult preparing(), - @required TResult inProgress(int uploaded, int total), - @required TResult success(), - @required TResult failed(String error), + TResult when({ + required TResult Function() preparing, + required TResult Function(int uploaded, int total) inProgress, + required TResult Function() success, + required TResult Function(String error) failed, }) { - assert(preparing != null); - assert(inProgress != null); - assert(success != null); - assert(failed != null); return success(); } @override @optionalTypeArgs - TResult maybeWhen({ - TResult preparing(), - TResult inProgress(int uploaded, int total), - TResult success(), - TResult failed(String error), - @required TResult orElse(), + TResult maybeWhen({ + TResult Function()? preparing, + TResult Function(int uploaded, int total)? inProgress, + TResult Function()? success, + TResult Function(String error)? failed, + required TResult orElse(), }) { - assert(orElse != null); if (success != null) { return success(); } @@ -447,29 +429,24 @@ class _$Success implements Success { @override @optionalTypeArgs - TResult map({ - @required TResult preparing(Preparing value), - @required TResult inProgress(InProgress value), - @required TResult success(Success value), - @required TResult failed(Failed value), + TResult map({ + required TResult Function(Preparing value) preparing, + required TResult Function(InProgress value) inProgress, + required TResult Function(Success value) success, + required TResult Function(Failed value) failed, }) { - assert(preparing != null); - assert(inProgress != null); - assert(success != null); - assert(failed != null); return success(this); } @override @optionalTypeArgs - TResult maybeMap({ - TResult preparing(Preparing value), - TResult inProgress(InProgress value), - TResult success(Success value), - TResult failed(Failed value), - @required TResult orElse(), + TResult maybeMap({ + TResult Function(Preparing value)? preparing, + TResult Function(InProgress value)? inProgress, + TResult Function(Success value)? success, + TResult Function(Failed value)? failed, + required TResult orElse(), }) { - assert(orElse != null); if (success != null) { return success(this); } @@ -506,19 +483,21 @@ class _$FailedCopyWithImpl<$Res> extends _$UploadStateCopyWithImpl<$Res> @override $Res call({ - Object error = freezed, + Object? error = freezed, }) { return _then(Failed( - error: error == freezed ? _value.error : error as String, + error: error == freezed + ? _value.error + : error // ignore: cast_nullable_to_non_nullable + as String, )); } } -@JsonSerializable() - /// @nodoc +@JsonSerializable() class _$Failed implements Failed { - const _$Failed({@required this.error}) : assert(error != null); + const _$Failed({required this.error}); factory _$Failed.fromJson(Map json) => _$_$FailedFromJson(json); @@ -550,29 +529,24 @@ class _$Failed implements Failed { @override @optionalTypeArgs - TResult when({ - @required TResult preparing(), - @required TResult inProgress(int uploaded, int total), - @required TResult success(), - @required TResult failed(String error), + TResult when({ + required TResult Function() preparing, + required TResult Function(int uploaded, int total) inProgress, + required TResult Function() success, + required TResult Function(String error) failed, }) { - assert(preparing != null); - assert(inProgress != null); - assert(success != null); - assert(failed != null); return failed(error); } @override @optionalTypeArgs - TResult maybeWhen({ - TResult preparing(), - TResult inProgress(int uploaded, int total), - TResult success(), - TResult failed(String error), - @required TResult orElse(), + TResult maybeWhen({ + TResult Function()? preparing, + TResult Function(int uploaded, int total)? inProgress, + TResult Function()? success, + TResult Function(String error)? failed, + required TResult orElse(), }) { - assert(orElse != null); if (failed != null) { return failed(error); } @@ -581,29 +555,24 @@ class _$Failed implements Failed { @override @optionalTypeArgs - TResult map({ - @required TResult preparing(Preparing value), - @required TResult inProgress(InProgress value), - @required TResult success(Success value), - @required TResult failed(Failed value), + TResult map({ + required TResult Function(Preparing value) preparing, + required TResult Function(InProgress value) inProgress, + required TResult Function(Success value) success, + required TResult Function(Failed value) failed, }) { - assert(preparing != null); - assert(inProgress != null); - assert(success != null); - assert(failed != null); return failed(this); } @override @optionalTypeArgs - TResult maybeMap({ - TResult preparing(Preparing value), - TResult inProgress(InProgress value), - TResult success(Success value), - TResult failed(Failed value), - @required TResult orElse(), + TResult maybeMap({ + TResult Function(Preparing value)? preparing, + TResult Function(InProgress value)? inProgress, + TResult Function(Success value)? success, + TResult Function(Failed value)? failed, + required TResult orElse(), }) { - assert(orElse != null); if (failed != null) { return failed(this); } @@ -617,11 +586,11 @@ class _$Failed implements Failed { } abstract class Failed implements UploadState { - const factory Failed({@required String error}) = _$Failed; + const factory Failed({required String error}) = _$Failed; factory Failed.fromJson(Map json) = _$Failed.fromJson; - String get error; + String get error => throw _privateConstructorUsedError; @JsonKey(ignore: true) - $FailedCopyWith get copyWith; + $FailedCopyWith get copyWith => throw _privateConstructorUsedError; } diff --git a/packages/stream_chat/lib/src/models/attachment_file.g.dart b/packages/stream_chat/lib/src/core/models/attachment_file.g.dart similarity index 72% rename from packages/stream_chat/lib/src/models/attachment_file.g.dart rename to packages/stream_chat/lib/src/core/models/attachment_file.g.dart index c6716fba..5844ae16 100644 --- a/packages/stream_chat/lib/src/models/attachment_file.g.dart +++ b/packages/stream_chat/lib/src/core/models/attachment_file.g.dart @@ -6,12 +6,12 @@ part of 'attachment_file.dart'; // JsonSerializableGenerator // ************************************************************************** -AttachmentFile _$AttachmentFileFromJson(Map json) { +AttachmentFile _$AttachmentFileFromJson(Map json) { return AttachmentFile( - path: json['path'] as String, - name: json['name'] as String, - bytes: _fromString(json['bytes'] as String), - size: json['size'] as int, + size: json['size'] as int?, + path: json['path'] as String?, + name: json['name'] as String?, + bytes: _fromString(json['bytes'] as String?), ); } @@ -23,14 +23,14 @@ Map _$AttachmentFileToJson(AttachmentFile instance) => 'size': instance.size, }; -_$Preparing _$_$PreparingFromJson(Map json) { +_$Preparing _$_$PreparingFromJson(Map json) { return _$Preparing(); } Map _$_$PreparingToJson(_$Preparing instance) => {}; -_$InProgress _$_$InProgressFromJson(Map json) { +_$InProgress _$_$InProgressFromJson(Map json) { return _$InProgress( uploaded: json['uploaded'] as int, total: json['total'] as int, @@ -43,14 +43,14 @@ Map _$_$InProgressToJson(_$InProgress instance) => 'total': instance.total, }; -_$Success _$_$SuccessFromJson(Map json) { +_$Success _$_$SuccessFromJson(Map json) { return _$Success(); } Map _$_$SuccessToJson(_$Success instance) => {}; -_$Failed _$_$FailedFromJson(Map json) { +_$Failed _$_$FailedFromJson(Map json) { return _$Failed( error: json['error'] as String, ); diff --git a/packages/stream_chat/lib/src/models/channel_config.dart b/packages/stream_chat/lib/src/core/models/channel_config.dart similarity index 61% rename from packages/stream_chat/lib/src/models/channel_config.dart rename to packages/stream_chat/lib/src/core/models/channel_config.dart index 3541573e..2d5182b3 100644 --- a/packages/stream_chat/lib/src/models/channel_config.dart +++ b/packages/stream_chat/lib/src/core/models/channel_config.dart @@ -1,5 +1,6 @@ import 'package:json_annotation/json_annotation.dart'; -import 'package:stream_chat/src/models/command.dart'; +import 'package:stream_chat/src/core/models/command.dart'; + part 'channel_config.g.dart'; /// The class that contains the information about the configuration of a channel @@ -7,35 +8,38 @@ part 'channel_config.g.dart'; class ChannelConfig { /// Constructor used for json serialization ChannelConfig({ - this.automod, - this.commands, - this.connectEvents, - this.createdAt, - this.updatedAt, - this.maxMessageLength, - this.messageRetention, - this.mutes, - this.name, - this.reactions, - this.readEvents, - this.replies, - this.search, - this.typingEvents, - this.uploads, - this.urlEnrichment, - }); + this.automod = 'flag', + this.commands = const [], + this.connectEvents = false, + DateTime? createdAt, + DateTime? updatedAt, + this.maxMessageLength = 0, + this.messageRetention = '', + this.mutes = false, + this.reactions = false, + this.readEvents = false, + this.replies = false, + this.search = false, + this.typingEvents = false, + this.uploads = false, + this.urlEnrichment = false, + }) : createdAt = createdAt ?? DateTime.now(), + updatedAt = updatedAt ?? DateTime.now(); /// Create a new instance from a json factory ChannelConfig.fromJson(Map json) => _$ChannelConfigFromJson(json); /// Moderation configuration + @JsonKey(defaultValue: 'flag') final String automod; /// List of available commands + @JsonKey(defaultValue: []) final List commands; /// True if the channel should send connect events + @JsonKey(defaultValue: false) final bool connectEvents; /// Date of channel creation @@ -45,36 +49,43 @@ class ChannelConfig { final DateTime updatedAt; /// Max channel message length + @JsonKey(defaultValue: 0) final int maxMessageLength; /// Duration of message retention + @JsonKey(defaultValue: '') final String messageRetention; /// True if users can be muted + @JsonKey(defaultValue: false) final bool mutes; - /// Name of the channel - final String name; - /// True if reaction are active for this channel + @JsonKey(defaultValue: false) final bool reactions; /// True if readEvents are active for this channel + @JsonKey(defaultValue: false) final bool readEvents; /// True if reply message are active for this channel + @JsonKey(defaultValue: false) final bool replies; /// True if it's possible to perform a search in this channel + @JsonKey(defaultValue: false) final bool search; /// True if typing events should be sent for this channel + @JsonKey(defaultValue: false) final bool typingEvents; /// True if it's possible to upload files to this channel + @JsonKey(defaultValue: false) final bool uploads; /// True if urls appears as attachments + @JsonKey(defaultValue: false) final bool urlEnrichment; /// Serialize to json diff --git a/packages/stream_chat/lib/src/models/channel_config.g.dart b/packages/stream_chat/lib/src/core/models/channel_config.g.dart similarity index 51% rename from packages/stream_chat/lib/src/models/channel_config.g.dart rename to packages/stream_chat/lib/src/core/models/channel_config.g.dart index e8152c80..723281c0 100644 --- a/packages/stream_chat/lib/src/models/channel_config.g.dart +++ b/packages/stream_chat/lib/src/core/models/channel_config.g.dart @@ -6,48 +6,43 @@ part of 'channel_config.dart'; // JsonSerializableGenerator // ************************************************************************** -ChannelConfig _$ChannelConfigFromJson(Map json) { +ChannelConfig _$ChannelConfigFromJson(Map json) { return ChannelConfig( - automod: json['automod'] as String, - commands: (json['commands'] as List) - ?.map((e) => e == null - ? null - : Command.fromJson((e as Map)?.map( - (k, e) => MapEntry(k as String, e), - ))) - ?.toList(), - connectEvents: json['connect_events'] as bool, + automod: json['automod'] as String? ?? 'flag', + commands: (json['commands'] as List?) + ?.map((e) => Command.fromJson(e as Map)) + .toList() ?? + [], + connectEvents: json['connect_events'] as bool? ?? false, createdAt: json['created_at'] == null ? null : DateTime.parse(json['created_at'] as String), updatedAt: json['updated_at'] == null ? null : DateTime.parse(json['updated_at'] as String), - maxMessageLength: json['max_message_length'] as int, - messageRetention: json['message_retention'] as String, - mutes: json['mutes'] as bool, - name: json['name'] as String, - reactions: json['reactions'] as bool, - readEvents: json['read_events'] as bool, - replies: json['replies'] as bool, - search: json['search'] as bool, - typingEvents: json['typing_events'] as bool, - uploads: json['uploads'] as bool, - urlEnrichment: json['url_enrichment'] as bool, + maxMessageLength: json['max_message_length'] as int? ?? 0, + messageRetention: json['message_retention'] as String? ?? '', + mutes: json['mutes'] as bool? ?? false, + reactions: json['reactions'] as bool? ?? false, + readEvents: json['read_events'] as bool? ?? false, + replies: json['replies'] as bool? ?? false, + search: json['search'] as bool? ?? false, + typingEvents: json['typing_events'] as bool? ?? false, + uploads: json['uploads'] as bool? ?? false, + urlEnrichment: json['url_enrichment'] as bool? ?? false, ); } Map _$ChannelConfigToJson(ChannelConfig instance) => { 'automod': instance.automod, - 'commands': instance.commands?.map((e) => e?.toJson())?.toList(), + 'commands': instance.commands.map((e) => e.toJson()).toList(), 'connect_events': instance.connectEvents, - 'created_at': instance.createdAt?.toIso8601String(), - 'updated_at': instance.updatedAt?.toIso8601String(), + 'created_at': instance.createdAt.toIso8601String(), + 'updated_at': instance.updatedAt.toIso8601String(), 'max_message_length': instance.maxMessageLength, 'message_retention': instance.messageRetention, 'mutes': instance.mutes, - 'name': instance.name, 'reactions': instance.reactions, 'read_events': instance.readEvents, 'replies': instance.replies, diff --git a/packages/stream_chat/lib/src/models/channel_model.dart b/packages/stream_chat/lib/src/core/models/channel_model.dart similarity index 55% rename from packages/stream_chat/lib/src/models/channel_model.dart rename to packages/stream_chat/lib/src/core/models/channel_model.dart index 62ea1853..37e587ff 100644 --- a/packages/stream_chat/lib/src/models/channel_model.dart +++ b/packages/stream_chat/lib/src/core/models/channel_model.dart @@ -1,7 +1,7 @@ import 'package:json_annotation/json_annotation.dart'; -import 'package:stream_chat/src/models/channel_config.dart'; -import 'package:stream_chat/src/models/serialization.dart'; -import 'package:stream_chat/src/models/user.dart'; +import 'package:stream_chat/src/core/models/channel_config.dart'; +import 'package:stream_chat/src/core/util/serializer.dart'; +import 'package:stream_chat/src/core/models/user.dart'; part 'channel_model.g.dart'; @@ -10,25 +10,34 @@ part 'channel_model.g.dart'; class ChannelModel { /// Constructor used for json serialization ChannelModel({ - this.id, - this.type, - this.cid, - this.config, + String? id, + String? type, + String? cid, + ChannelConfig? config, this.createdBy, - this.frozen, + this.frozen = false, this.lastMessageAt, - this.createdAt, - this.updatedAt, + DateTime? createdAt, + DateTime? updatedAt, this.deletedAt, - this.memberCount, - this.extraData, + this.memberCount = 0, + this.extraData = const {}, this.team, - }); + }) : assert( + (cid != null && cid.contains(':')) || (id != null && type != null), + 'provide either a cid or an id and type', + ), + id = id ?? cid!.split(':')[1], + type = type ?? cid!.split(':')[0], + cid = cid ?? '$type:$id', + config = config ?? ChannelConfig(), + createdAt = createdAt ?? DateTime.now(), + updatedAt = updatedAt ?? DateTime.now(); /// Create a new instance from a json factory ChannelModel.fromJson(Map json) => _$ChannelModelFromJson( - Serialization.moveToExtraDataFromRoot(json, topLevelFields)); + Serializer.moveToExtraDataFromRoot(json, topLevelFields)); /// The id of this channel final String id; @@ -37,51 +46,54 @@ class ChannelModel { final String type; /// The cid of this channel - @JsonKey(includeIfNull: false, toJson: Serialization.readOnly) + @JsonKey(includeIfNull: false, toJson: Serializer.readOnly) final String cid; /// The channel configuration data - @JsonKey(includeIfNull: false, toJson: Serialization.readOnly) + @JsonKey(includeIfNull: false, toJson: Serializer.readOnly) final ChannelConfig config; /// The user that created this channel - @JsonKey(includeIfNull: false, toJson: Serialization.readOnly) - final User createdBy; + @JsonKey(includeIfNull: false, toJson: Serializer.readOnly) + final User? createdBy; /// True if this channel is frozen - @JsonKey(includeIfNull: false) + @JsonKey(includeIfNull: false, defaultValue: false) final bool frozen; /// The date of the last message - @JsonKey(includeIfNull: false, toJson: Serialization.readOnly) - final DateTime lastMessageAt; + @JsonKey(includeIfNull: false, toJson: Serializer.readOnly) + final DateTime? lastMessageAt; /// The date of channel creation - @JsonKey(includeIfNull: false, toJson: Serialization.readOnly) + @JsonKey(includeIfNull: false, toJson: Serializer.readOnly) final DateTime createdAt; /// The date of the last channel update - @JsonKey(includeIfNull: false, toJson: Serialization.readOnly) + @JsonKey(includeIfNull: false, toJson: Serializer.readOnly) final DateTime updatedAt; /// The date of channel deletion - @JsonKey(includeIfNull: false, toJson: Serialization.readOnly) - final DateTime deletedAt; + @JsonKey(includeIfNull: false, toJson: Serializer.readOnly) + final DateTime? deletedAt; /// The count of this channel members - @JsonKey(includeIfNull: false, toJson: Serialization.readOnly) + @JsonKey(includeIfNull: false, toJson: Serializer.readOnly, defaultValue: 0) final int memberCount; /// Map of custom channel extraData - @JsonKey(includeIfNull: false) - final Map extraData; + @JsonKey( + includeIfNull: false, + defaultValue: {}, + ) + final Map extraData; /// The team the channel belongs to - @JsonKey(includeIfNull: false, toJson: Serialization.readOnly) - final String team; + @JsonKey(includeIfNull: false, toJson: Serializer.readOnly) + final String? team; /// Known top level fields. - /// Useful for [Serialization] methods. + /// Useful for [Serializer] methods. static const topLevelFields = [ 'id', 'type', @@ -99,29 +111,28 @@ class ChannelModel { /// Shortcut for channel name String get name => - extraData?.containsKey('name') == true ? extraData['name'] : cid; + extraData.containsKey('name') ? extraData['name']! as String : cid; /// Serialize to json - Map toJson() => Serialization.moveFromExtraDataToRoot( + Map toJson() => Serializer.moveFromExtraDataToRoot( _$ChannelModelToJson(this), - topLevelFields, ); /// Creates a copy of [ChannelModel] with specified attributes overridden. ChannelModel copyWith({ - String id, - String type, - String cid, - ChannelConfig config, - User createdBy, - bool frozen, - DateTime lastMessageAt, - DateTime createdAt, - DateTime updatedAt, - DateTime deletedAt, - int memberCount, - Map extraData, - String team, + String? id, + String? type, + String? cid, + ChannelConfig? config, + User? createdBy, + bool? frozen, + DateTime? lastMessageAt, + DateTime? createdAt, + DateTime? updatedAt, + DateTime? deletedAt, + int? memberCount, + Map? extraData, + String? team, }) => ChannelModel( id: id ?? this.id, @@ -141,7 +152,7 @@ class ChannelModel { /// Returns a new [ChannelModel] that is a combination of this channelModel /// and the given [other] channelModel. - ChannelModel merge(ChannelModel other) { + ChannelModel merge(ChannelModel? other) { if (other == null) return this; return copyWith( id: other.id, diff --git a/packages/stream_chat/lib/src/models/channel_model.g.dart b/packages/stream_chat/lib/src/core/models/channel_model.g.dart similarity index 70% rename from packages/stream_chat/lib/src/models/channel_model.g.dart rename to packages/stream_chat/lib/src/core/models/channel_model.g.dart index 4f535f46..4bde3d4a 100644 --- a/packages/stream_chat/lib/src/models/channel_model.g.dart +++ b/packages/stream_chat/lib/src/core/models/channel_model.g.dart @@ -6,22 +6,18 @@ part of 'channel_model.dart'; // JsonSerializableGenerator // ************************************************************************** -ChannelModel _$ChannelModelFromJson(Map json) { +ChannelModel _$ChannelModelFromJson(Map json) { return ChannelModel( - id: json['id'] as String, - type: json['type'] as String, - cid: json['cid'] as String, + id: json['id'] as String?, + type: json['type'] as String?, + cid: json['cid'] as String?, config: json['config'] == null ? null - : ChannelConfig.fromJson((json['config'] as Map)?.map( - (k, e) => MapEntry(k as String, e), - )), + : ChannelConfig.fromJson(json['config'] as Map), createdBy: json['created_by'] == null ? null - : User.fromJson((json['created_by'] as Map)?.map( - (k, e) => MapEntry(k as String, e), - )), - frozen: json['frozen'] as bool, + : User.fromJson(json['created_by'] as Map), + frozen: json['frozen'] as bool? ?? false, lastMessageAt: json['last_message_at'] == null ? null : DateTime.parse(json['last_message_at'] as String), @@ -34,11 +30,9 @@ ChannelModel _$ChannelModelFromJson(Map json) { deletedAt: json['deleted_at'] == null ? null : DateTime.parse(json['deleted_at'] as String), - memberCount: json['member_count'] as int, - extraData: (json['extra_data'] as Map)?.map( - (k, e) => MapEntry(k as String, e), - ), - team: json['team'] as String, + memberCount: json['member_count'] as int? ?? 0, + extraData: json['extra_data'] as Map? ?? {}, + team: json['team'] as String?, ); } @@ -57,13 +51,13 @@ Map _$ChannelModelToJson(ChannelModel instance) { writeNotNull('cid', readonly(instance.cid)); writeNotNull('config', readonly(instance.config)); writeNotNull('created_by', readonly(instance.createdBy)); - writeNotNull('frozen', instance.frozen); + val['frozen'] = instance.frozen; writeNotNull('last_message_at', readonly(instance.lastMessageAt)); writeNotNull('created_at', readonly(instance.createdAt)); writeNotNull('updated_at', readonly(instance.updatedAt)); writeNotNull('deleted_at', readonly(instance.deletedAt)); writeNotNull('member_count', readonly(instance.memberCount)); - writeNotNull('extra_data', instance.extraData); + val['extra_data'] = instance.extraData; writeNotNull('team', readonly(instance.team)); return val; } diff --git a/packages/stream_chat/lib/src/models/channel_state.dart b/packages/stream_chat/lib/src/core/models/channel_state.dart similarity index 68% rename from packages/stream_chat/lib/src/models/channel_state.dart rename to packages/stream_chat/lib/src/core/models/channel_state.dart index 3a1f3178..998efa98 100644 --- a/packages/stream_chat/lib/src/models/channel_state.dart +++ b/packages/stream_chat/lib/src/core/models/channel_state.dart @@ -1,9 +1,9 @@ import 'package:json_annotation/json_annotation.dart'; -import 'package:stream_chat/src/models/channel_model.dart'; -import 'package:stream_chat/src/models/member.dart'; -import 'package:stream_chat/src/models/message.dart'; -import 'package:stream_chat/src/models/read.dart'; -import 'package:stream_chat/src/models/user.dart'; +import 'package:stream_chat/src/core/models/channel_model.dart'; +import 'package:stream_chat/src/core/models/member.dart'; +import 'package:stream_chat/src/core/models/message.dart'; +import 'package:stream_chat/src/core/models/read.dart'; +import 'package:stream_chat/src/core/models/user.dart'; part 'channel_state.g.dart'; @@ -22,24 +22,29 @@ class ChannelState { }); /// The channel to which this state belongs - final ChannelModel channel; + final ChannelModel? channel; /// A paginated list of channel messages + @JsonKey(defaultValue: []) final List messages; /// A paginated list of channel members + @JsonKey(defaultValue: []) final List members; /// A paginated list of pinned messages + @JsonKey(defaultValue: []) final List pinnedMessages; /// The count of users watching the channel - final int watcherCount; + final int? watcherCount; /// A paginated list of users watching the channel + @JsonKey(defaultValue: []) final List watchers; /// The list of channel reads + @JsonKey(defaultValue: []) final List read; /// Create a new instance from a json @@ -51,13 +56,13 @@ class ChannelState { /// Creates a copy of [ChannelState] with specified attributes overridden. ChannelState copyWith({ - ChannelModel channel, - List messages, - List members, - List pinnedMessages, - int watcherCount, - List watchers, - List read, + ChannelModel? channel, + List? messages, + List? members, + List? pinnedMessages, + int? watcherCount, + List? watchers, + List? read, }) => ChannelState( channel: channel ?? this.channel, diff --git a/packages/stream_chat/lib/src/core/models/channel_state.g.dart b/packages/stream_chat/lib/src/core/models/channel_state.g.dart new file mode 100644 index 00000000..39c6373f --- /dev/null +++ b/packages/stream_chat/lib/src/core/models/channel_state.g.dart @@ -0,0 +1,48 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'channel_state.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +ChannelState _$ChannelStateFromJson(Map json) { + return ChannelState( + channel: json['channel'] == null + ? null + : ChannelModel.fromJson(json['channel'] as Map), + messages: (json['messages'] as List?) + ?.map((e) => Message.fromJson(e as Map)) + .toList() ?? + [], + members: (json['members'] as List?) + ?.map((e) => Member.fromJson(e as Map)) + .toList() ?? + [], + pinnedMessages: (json['pinned_messages'] as List?) + ?.map((e) => Message.fromJson(e as Map)) + .toList() ?? + [], + watcherCount: json['watcher_count'] as int?, + watchers: (json['watchers'] as List?) + ?.map((e) => User.fromJson(e as Map)) + .toList() ?? + [], + read: (json['read'] as List?) + ?.map((e) => Read.fromJson(e as Map)) + .toList() ?? + [], + ); +} + +Map _$ChannelStateToJson(ChannelState instance) => + { + 'channel': instance.channel?.toJson(), + 'messages': instance.messages.map((e) => e.toJson()).toList(), + 'members': instance.members.map((e) => e.toJson()).toList(), + 'pinned_messages': + instance.pinnedMessages.map((e) => e.toJson()).toList(), + 'watcher_count': instance.watcherCount, + 'watchers': instance.watchers.map((e) => e.toJson()).toList(), + 'read': instance.read.map((e) => e.toJson()).toList(), + }; diff --git a/packages/stream_chat/lib/src/models/command.dart b/packages/stream_chat/lib/src/core/models/command.dart similarity index 88% rename from packages/stream_chat/lib/src/models/command.dart rename to packages/stream_chat/lib/src/core/models/command.dart index a5ababd2..5ba0043c 100644 --- a/packages/stream_chat/lib/src/models/command.dart +++ b/packages/stream_chat/lib/src/core/models/command.dart @@ -7,9 +7,9 @@ part 'command.g.dart'; class Command { /// Constructor used for json serialization Command({ - this.name, - this.description, - this.args, + required this.name, + required this.description, + required this.args, }); /// Create a new instance from a json diff --git a/packages/stream_chat/lib/src/models/command.g.dart b/packages/stream_chat/lib/src/core/models/command.g.dart similarity index 91% rename from packages/stream_chat/lib/src/models/command.g.dart rename to packages/stream_chat/lib/src/core/models/command.g.dart index f32e8e8a..cf8be971 100644 --- a/packages/stream_chat/lib/src/models/command.g.dart +++ b/packages/stream_chat/lib/src/core/models/command.g.dart @@ -6,7 +6,7 @@ part of 'command.dart'; // JsonSerializableGenerator // ************************************************************************** -Command _$CommandFromJson(Map json) { +Command _$CommandFromJson(Map json) { return Command( name: json['name'] as String, description: json['description'] as String, diff --git a/packages/stream_chat/lib/src/models/device.dart b/packages/stream_chat/lib/src/core/models/device.dart similarity index 91% rename from packages/stream_chat/lib/src/models/device.dart rename to packages/stream_chat/lib/src/core/models/device.dart index 150e6759..5dc98d25 100644 --- a/packages/stream_chat/lib/src/models/device.dart +++ b/packages/stream_chat/lib/src/core/models/device.dart @@ -7,8 +7,8 @@ part 'device.g.dart'; class Device { /// Constructor used for json serialization Device({ - this.id, - this.pushProvider, + required this.id, + required this.pushProvider, }); /// Create a new instance from a json diff --git a/packages/stream_chat/lib/src/models/device.g.dart b/packages/stream_chat/lib/src/core/models/device.g.dart similarity index 90% rename from packages/stream_chat/lib/src/models/device.g.dart rename to packages/stream_chat/lib/src/core/models/device.g.dart index bac60856..5fcd9435 100644 --- a/packages/stream_chat/lib/src/models/device.g.dart +++ b/packages/stream_chat/lib/src/core/models/device.g.dart @@ -6,7 +6,7 @@ part of 'device.dart'; // JsonSerializableGenerator // ************************************************************************** -Device _$DeviceFromJson(Map json) { +Device _$DeviceFromJson(Map json) { return Device( id: json['id'] as String, pushProvider: json['push_provider'] as String, diff --git a/packages/stream_chat/lib/src/models/event.dart b/packages/stream_chat/lib/src/core/models/event.dart similarity index 64% rename from packages/stream_chat/lib/src/models/event.dart rename to packages/stream_chat/lib/src/core/models/event.dart index 28cf1b38..26831555 100644 --- a/packages/stream_chat/lib/src/models/event.dart +++ b/packages/stream_chat/lib/src/core/models/event.dart @@ -1,7 +1,7 @@ import 'package:json_annotation/json_annotation.dart'; -import 'package:stream_chat/src/models/channel_model.dart'; -import 'package:stream_chat/src/models/message.dart'; -import 'package:stream_chat/src/models/serialization.dart'; +import 'package:stream_chat/src/core/models/channel_model.dart'; +import 'package:stream_chat/src/core/models/message.dart'; +import 'package:stream_chat/src/core/util/serializer.dart'; import 'package:stream_chat/stream_chat.dart'; part 'event.g.dart'; @@ -11,10 +11,10 @@ part 'event.g.dart'; class Event { /// Constructor used for json serialization Event({ - this.type, + this.type = 'local.event', this.cid, this.connectionId, - this.createdAt, + DateTime? createdAt, this.me, this.user, this.message, @@ -27,75 +27,76 @@ class Event { this.channelId, this.channelType, this.parentId, - this.extraData, - }) : isLocal = true; + this.extraData = const {}, + this.isLocal = true, + }) : createdAt = createdAt?.toUtc() ?? DateTime.now().toUtc(); /// Create a new instance from a json factory Event.fromJson(Map json) => - _$EventFromJson(Serialization.moveToExtraDataFromRoot( + _$EventFromJson(Serializer.moveToExtraDataFromRoot( json, topLevelFields, - )) - ..isLocal = false; + )); /// The type of the event /// [EventType] contains some predefined constant types final String type; /// The channel cid to which the event belongs - final String cid; + final String? cid; /// The channel id to which the event belongs - final String channelId; + final String? channelId; /// The channel type to which the event belongs - final String channelType; + final String? channelType; /// The connection id in which the event has been sent - final String connectionId; + final String? connectionId; /// The date of creation of the event final DateTime createdAt; /// User object of the health check user - final OwnUser me; + final OwnUser? me; /// User object of the current user - final User user; + final User? user; /// The message sent with the event - final Message message; + final Message? message; /// The channel sent with the event - final EventChannel channel; + final EventChannel? channel; /// The member sent with the event - final Member member; + final Member? member; /// The reaction sent with the event - final Reaction reaction; + final Reaction? reaction; /// The number of unread messages for current user - final int totalUnreadCount; + final int? totalUnreadCount; /// User total unread channels - final int unreadChannels; + final int? unreadChannels; /// Online status - final bool online; + final bool? online; /// The id of the parent message of a thread - final String parentId; + final String? parentId; /// True if the event is generated by this client - bool isLocal; + @JsonKey(defaultValue: false) + final bool isLocal; /// Map of custom channel extraData - @JsonKey(includeIfNull: false) - final Map extraData; + @JsonKey(defaultValue: {}) + final Map extraData; /// Known top level fields. - /// Useful for [Serialization] methods. + /// Useful for [Serializer] methods. static final topLevelFields = [ 'type', 'cid', @@ -117,30 +118,29 @@ class Event { ]; /// Serialize to json - Map toJson() => Serialization.moveFromExtraDataToRoot( + Map toJson() => Serializer.moveFromExtraDataToRoot( _$EventToJson(this), - topLevelFields, ); /// Creates a copy of [Event] with specified attributes overridden. Event copyWith({ - String type, - String cid, - String channelId, - String channelType, - String connectionId, - DateTime createdAt, - OwnUser me, - User user, - Message message, - EventChannel channel, - Member member, - Reaction reaction, - int totalUnreadCount, - int unreadChannels, - bool online, - String parentId, - Map extraData, + String? type, + String? cid, + String? channelId, + String? channelType, + String? connectionId, + DateTime? createdAt, + OwnUser? me, + User? user, + Message? message, + EventChannel? channel, + Member? member, + Reaction? reaction, + int? totalUnreadCount, + int? unreadChannels, + bool? online, + String? parentId, + Map? extraData, }) => Event( type: type ?? this.type, @@ -160,27 +160,30 @@ class Event { channelType: channelType ?? this.channelType, parentId: parentId ?? this.parentId, extraData: extraData ?? this.extraData, + isLocal: isLocal, ); } /// The channel embedded in the event object -@JsonSerializable() +@JsonSerializable( + createToJson: false, +) class EventChannel extends ChannelModel { /// Constructor used for json serialization EventChannel({ this.members, - String id, - String type, - String cid, - ChannelConfig config, - User createdBy, - bool frozen, - DateTime lastMessageAt, - DateTime createdAt, - DateTime updatedAt, - DateTime deletedAt, - int memberCount, - Map extraData, + String? id, + String? type, + required String cid, + required ChannelConfig config, + User? createdBy, + bool frozen = false, + DateTime? lastMessageAt, + required DateTime createdAt, + required DateTime updatedAt, + DateTime? deletedAt, + required int memberCount, + Map? extraData, }) : super( id: id, type: type, @@ -193,30 +196,23 @@ class EventChannel extends ChannelModel { updatedAt: updatedAt, deletedAt: deletedAt, memberCount: memberCount, - extraData: extraData, + extraData: extraData ?? {}, ); /// Create a new instance from a json factory EventChannel.fromJson(Map json) => - _$EventChannelFromJson(Serialization.moveToExtraDataFromRoot( + _$EventChannelFromJson(Serializer.moveToExtraDataFromRoot( json, topLevelFields, )); /// A paginated list of channel members - final List members; + final List? members; /// Known top level fields. - /// Useful for [Serialization] methods. + /// Useful for [Serializer] methods. static final topLevelFields = [ 'members', ...ChannelModel.topLevelFields, ]; - - /// Serialize to json - @override - Map toJson() => Serialization.moveFromExtraDataToRoot( - _$EventChannelToJson(this), - topLevelFields, - ); } diff --git a/packages/stream_chat/lib/src/core/models/event.g.dart b/packages/stream_chat/lib/src/core/models/event.g.dart new file mode 100644 index 00000000..5247af17 --- /dev/null +++ b/packages/stream_chat/lib/src/core/models/event.g.dart @@ -0,0 +1,91 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'event.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +Event _$EventFromJson(Map json) { + return Event( + type: json['type'] as String, + cid: json['cid'] as String?, + connectionId: json['connection_id'] as String?, + createdAt: json['created_at'] == null + ? null + : DateTime.parse(json['created_at'] as String), + me: json['me'] == null + ? null + : OwnUser.fromJson(json['me'] as Map), + user: json['user'] == null + ? null + : User.fromJson(json['user'] as Map), + message: json['message'] == null + ? null + : Message.fromJson(json['message'] as Map), + totalUnreadCount: json['total_unread_count'] as int?, + unreadChannels: json['unread_channels'] as int?, + reaction: json['reaction'] == null + ? null + : Reaction.fromJson(json['reaction'] as Map), + online: json['online'] as bool?, + channel: json['channel'] == null + ? null + : EventChannel.fromJson(json['channel'] as Map), + member: json['member'] == null + ? null + : Member.fromJson(json['member'] as Map), + channelId: json['channel_id'] as String?, + channelType: json['channel_type'] as String?, + parentId: json['parent_id'] as String?, + extraData: json['extra_data'] as Map? ?? {}, + isLocal: json['is_local'] as bool? ?? false, + ); +} + +Map _$EventToJson(Event instance) => { + 'type': instance.type, + 'cid': instance.cid, + 'channel_id': instance.channelId, + 'channel_type': instance.channelType, + 'connection_id': instance.connectionId, + 'created_at': instance.createdAt.toIso8601String(), + 'me': instance.me?.toJson(), + 'user': instance.user?.toJson(), + 'message': instance.message?.toJson(), + 'channel': instance.channel?.toJson(), + 'member': instance.member?.toJson(), + 'reaction': instance.reaction?.toJson(), + 'total_unread_count': instance.totalUnreadCount, + 'unread_channels': instance.unreadChannels, + 'online': instance.online, + 'parent_id': instance.parentId, + 'is_local': instance.isLocal, + 'extra_data': instance.extraData, + }; + +EventChannel _$EventChannelFromJson(Map json) { + return EventChannel( + members: (json['members'] as List?) + ?.map((e) => Member.fromJson(e as Map)) + .toList(), + id: json['id'] as String?, + type: json['type'] as String?, + cid: json['cid'] as String, + config: ChannelConfig.fromJson(json['config'] as Map), + createdBy: json['created_by'] == null + ? null + : User.fromJson(json['created_by'] as Map), + frozen: json['frozen'] as bool? ?? false, + lastMessageAt: json['last_message_at'] == null + ? null + : DateTime.parse(json['last_message_at'] as String), + createdAt: DateTime.parse(json['created_at'] as String), + updatedAt: DateTime.parse(json['updated_at'] as String), + deletedAt: json['deleted_at'] == null + ? null + : DateTime.parse(json['deleted_at'] as String), + memberCount: json['member_count'] as int? ?? 0, + extraData: json['extra_data'] as Map? ?? {}, + ); +} diff --git a/packages/stream_chat/lib/src/core/models/filter.dart b/packages/stream_chat/lib/src/core/models/filter.dart new file mode 100644 index 00000000..eb3932ff --- /dev/null +++ b/packages/stream_chat/lib/src/core/models/filter.dart @@ -0,0 +1,219 @@ +// ignore_for_file: non_constant_identifier_names, constant_identifier_names + +import 'package:equatable/equatable.dart'; + +const _groupOperators = [ + FilterOperator.and, + FilterOperator.or, + FilterOperator.nor, +]; + +/// Possible operators to use in filters. +enum FilterOperator { + /// Matches values that are equal to a specified value. + equal, + + /// Matches all values that are not equal to a specified value. + notEqual, + + /// Matches values that are greater than a specified value. + greater, + + /// Matches values that are greater than a specified value. + greaterOrEqual, + + /// Matches values that are less than a specified value. + less, + + /// Matches values that are less than or equal to a specified value. + lessOrEqual, + + /// Matches any of the values specified in an array. + in_, + + /// Matches none of the values specified in an array. + notIn, + + /// Matches values by performing text search with the specified value. + query, + + /// Matches values with the specified prefix. + autoComplete, + + /// Matches values that exist/don't exist based on the specified boolean value. + exists, + + /// Matches all the values specified in an array. + and, + + /// Matches at least one of the values specified in an array. + or, + + /// Matches none of the values specified in an array. + nor, +} + +/// Helper extension for [FilterOperator] +extension FilterOperatorX on FilterOperator { + /// Converts [FilterOperator] into rew values + String get rawValue => { + FilterOperator.equal: '\$eq', + FilterOperator.notEqual: '\$ne', + FilterOperator.greater: '\$gt', + FilterOperator.greaterOrEqual: '\$gte', + FilterOperator.less: '\$lt', + FilterOperator.lessOrEqual: '\$lte', + FilterOperator.in_: '\$in', + FilterOperator.notIn: '\$nin', + FilterOperator.query: '\$q', + FilterOperator.autoComplete: '\$autocomplete', + FilterOperator.exists: '\$exists', + FilterOperator.and: '\$and', + FilterOperator.or: '\$or', + FilterOperator.nor: '\$nor', + }[this]!; +} + +/// Stream supports a limited set of filters for querying channels, +/// users and members. The example below shows how to filter for channels +/// of type messaging where the current user is a member +/// +/// ```dart +/// final filter = Filter.and( +/// Filter.equal('type', 'messaging'), +/// Filter.in_('members', [user.id]) +/// ) +/// ``` +/// See Query Channels Documentation +class Filter extends Equatable { + const Filter.__({ + required this.value, + this.operator, + this.key, + }); + + Filter._({ + required FilterOperator operator, + required this.value, + this.key, + }) : operator = operator.rawValue; + + /// Combines the provided filters and matches the values + /// matched by all filters. + factory Filter.and(List filters) => + Filter._(operator: FilterOperator.and, value: filters); + + /// Combines the provided filters and matches the values + /// matched by at least one of the filters. + factory Filter.or(List filters) => + Filter._(operator: FilterOperator.or, value: filters); + + /// Combines the provided filters and matches the values + /// not matched by all the filters. + factory Filter.nor(List filters) => + Filter._(operator: FilterOperator.nor, value: filters); + + /// Matches values that are equal to a specified value. + factory Filter.equal(String key, Object value) => + Filter._(operator: FilterOperator.equal, key: key, value: value); + + /// Matches all values that are not equal to a specified value. + factory Filter.notEqual(String key, Object value) => + Filter._(operator: FilterOperator.notEqual, key: key, value: value); + + /// Matches values that are greater than a specified value. + factory Filter.greater(String key, Object value) => + Filter._(operator: FilterOperator.greater, key: key, value: value); + + /// Matches values that are greater than a specified value. + factory Filter.greaterOrEqual(String key, Object value) => + Filter._(operator: FilterOperator.greaterOrEqual, key: key, value: value); + + /// Matches values that are less than a specified value. + factory Filter.less(String key, Object value) => + Filter._(operator: FilterOperator.less, key: key, value: value); + + /// Matches values that are less than or equal to a specified value. + factory Filter.lessOrEqual(String key, Object value) => + Filter._(operator: FilterOperator.lessOrEqual, key: key, value: value); + + /// Matches any of the values specified in an array. + factory Filter.in_(String key, List values) => + Filter._(operator: FilterOperator.in_, key: key, value: values); + + /// Matches none of the values specified in an array. + factory Filter.notIn(String key, List values) => + Filter._(operator: FilterOperator.notIn, key: key, value: values); + + /// Matches values by performing text search with the specified value. + factory Filter.query(String key, String text) => + Filter._(operator: FilterOperator.query, key: key, value: text); + + /// Matches values with the specified prefix. + factory Filter.autoComplete(String key, String text) => + Filter._(operator: FilterOperator.autoComplete, key: key, value: text); + + /// Matches values that exist/don't exist based on the specified boolean value. + factory Filter.exists(String key, {bool exists = true}) => + Filter._(operator: FilterOperator.exists, key: key, value: exists); + + /// Creates a custom [Filter] if there isn't one already available. + const factory Filter.custom({ + required Object value, + String? operator, + String? key, + }) = Filter.__; + + /// Creates a custom [Filter] from a raw map value + /// + /// ```dart + /// final filter = Filter.raw( + /// { + /// 'members': [user1.id, user2.id], + /// } + /// ) + /// ``` + const factory Filter.raw({ + required Map value, + }) = Filter.__; + + /// An operator used for the filter. The operator string must start with `$` + final String? operator; + + /// The "left-hand" side of the filter. + /// Specifies the name of the field the filter should match. + /// + /// Some operators like `and` or `or`, + /// don't require the key value to be present. + /// see-more : [_groupOperators] + final String? key; + + /// The "right-hand" side of the filter. + /// Specifies the [value] the filter should match. + final Object /*List|List|String*/ value; + + @override + List get props => [operator, key, value]; + + /// Serializes to json object + Map toJson() { + final json = {}; + final groupOperators = _groupOperators.map((it) => it.rawValue); + + if (groupOperators.contains(operator)) { + // Filters with group operators are encoded in the following form: + // { $: [ , ] } + json[operator!] = value; + } else if (operator != null) { + // Normal filters are encoded in the following form: + // { key: { $: } } + json[key!] = {operator: value}; + } else if (key != null) { + json[key!] = value; + } else { + return value as Map; + } + + return json; + } +} diff --git a/packages/stream_chat/lib/src/models/member.dart b/packages/stream_chat/lib/src/core/models/member.dart similarity index 62% rename from packages/stream_chat/lib/src/models/member.dart rename to packages/stream_chat/lib/src/core/models/member.dart index e99d8d34..5c9bc95c 100644 --- a/packages/stream_chat/lib/src/models/member.dart +++ b/packages/stream_chat/lib/src/core/models/member.dart @@ -1,26 +1,28 @@ +import 'package:equatable/equatable.dart'; import 'package:json_annotation/json_annotation.dart'; -import 'package:stream_chat/src/models/user.dart'; +import 'package:stream_chat/src/core/models/user.dart'; part 'member.g.dart'; /// The class that contains the information about the user membership /// in a channel @JsonSerializable() -class Member { +class Member extends Equatable { /// Constructor used for json serialization Member({ this.user, this.inviteAcceptedAt, this.inviteRejectedAt, - this.invited, + this.invited = false, this.role, this.userId, - this.isModerator, - this.createdAt, - this.updatedAt, - this.banned, - this.shadowBanned, - }); + this.isModerator = false, + DateTime? createdAt, + DateTime? updatedAt, + this.banned = false, + this.shadowBanned = false, + }) : createdAt = createdAt ?? DateTime.now(), + updatedAt = updatedAt ?? DateTime.now(); /// Create a new instance from a json factory Member.fromJson(Map json) { @@ -31,30 +33,34 @@ class Member { } /// The interested user - final User user; + final User? user; /// The date in which the user accepted the invite to the channel - final DateTime inviteAcceptedAt; + final DateTime? inviteAcceptedAt; /// The date in which the user rejected the invite to the channel - final DateTime inviteRejectedAt; + final DateTime? inviteRejectedAt; /// True if the user has been invited to the channel + @JsonKey(defaultValue: false) final bool invited; /// The role of the user in the channel - final String role; + final String? role; /// The id of the interested user - final String userId; + final String? userId; /// True if the user is a moderator of the channel + @JsonKey(defaultValue: false) final bool isModerator; /// True if the member is banned from the channel + @JsonKey(defaultValue: false) final bool banned; /// True if the member is shadow banned from the channel + @JsonKey(defaultValue: false) final bool shadowBanned; /// The date of creation @@ -65,17 +71,17 @@ class Member { /// Creates a copy of [Member] with specified attributes overridden. Member copyWith({ - User user, - DateTime inviteAcceptedAt, - DateTime inviteRejectedAt, - bool invited, - String role, - String userId, - bool isModerator, - DateTime createdAt, - DateTime updatedAt, - bool banned, - bool shadowBanned, + User? user, + DateTime? inviteAcceptedAt, + DateTime? inviteRejectedAt, + bool? invited, + String? role, + String? userId, + bool? isModerator, + DateTime? createdAt, + DateTime? updatedAt, + bool? banned, + bool? shadowBanned, }) => Member( user: user ?? this.user, @@ -93,4 +99,19 @@ class Member { /// Serialize to json Map toJson() => _$MemberToJson(this); + + @override + List get props => [ + user, + inviteAcceptedAt, + inviteRejectedAt, + invited, + role, + userId, + isModerator, + banned, + shadowBanned, + createdAt, + updatedAt, + ]; } diff --git a/packages/stream_chat/lib/src/models/member.g.dart b/packages/stream_chat/lib/src/core/models/member.g.dart similarity index 71% rename from packages/stream_chat/lib/src/models/member.g.dart rename to packages/stream_chat/lib/src/core/models/member.g.dart index 3ac8778e..a75b458d 100644 --- a/packages/stream_chat/lib/src/models/member.g.dart +++ b/packages/stream_chat/lib/src/core/models/member.g.dart @@ -6,31 +6,29 @@ part of 'member.dart'; // JsonSerializableGenerator // ************************************************************************** -Member _$MemberFromJson(Map json) { +Member _$MemberFromJson(Map json) { return Member( user: json['user'] == null ? null - : User.fromJson((json['user'] as Map)?.map( - (k, e) => MapEntry(k as String, e), - )), + : User.fromJson(json['user'] as Map), inviteAcceptedAt: json['invite_accepted_at'] == null ? null : DateTime.parse(json['invite_accepted_at'] as String), inviteRejectedAt: json['invite_rejected_at'] == null ? null : DateTime.parse(json['invite_rejected_at'] as String), - invited: json['invited'] as bool, - role: json['role'] as String, - userId: json['user_id'] as String, - isModerator: json['is_moderator'] as bool, + invited: json['invited'] as bool? ?? false, + role: json['role'] as String?, + userId: json['user_id'] as String?, + isModerator: json['is_moderator'] as bool? ?? false, createdAt: json['created_at'] == null ? null : DateTime.parse(json['created_at'] as String), updatedAt: json['updated_at'] == null ? null : DateTime.parse(json['updated_at'] as String), - banned: json['banned'] as bool, - shadowBanned: json['shadow_banned'] as bool, + banned: json['banned'] as bool? ?? false, + shadowBanned: json['shadow_banned'] as bool? ?? false, ); } @@ -44,6 +42,6 @@ Map _$MemberToJson(Member instance) => { 'is_moderator': instance.isModerator, 'banned': instance.banned, 'shadow_banned': instance.shadowBanned, - 'created_at': instance.createdAt?.toIso8601String(), - 'updated_at': instance.updatedAt?.toIso8601String(), + 'created_at': instance.createdAt.toIso8601String(), + 'updated_at': instance.updatedAt.toIso8601String(), }; diff --git a/packages/stream_chat/lib/src/models/message.dart b/packages/stream_chat/lib/src/core/models/message.dart similarity index 53% rename from packages/stream_chat/lib/src/models/message.dart rename to packages/stream_chat/lib/src/core/models/message.dart index a89324a7..d394dbdd 100644 --- a/packages/stream_chat/lib/src/models/message.dart +++ b/packages/stream_chat/lib/src/core/models/message.dart @@ -1,8 +1,9 @@ +import 'package:equatable/equatable.dart'; import 'package:json_annotation/json_annotation.dart'; -import 'package:stream_chat/src/models/attachment.dart'; -import 'package:stream_chat/src/models/reaction.dart'; -import 'package:stream_chat/src/models/serialization.dart'; -import 'package:stream_chat/src/models/user.dart'; +import 'package:stream_chat/src/core/models/attachment.dart'; +import 'package:stream_chat/src/core/models/reaction.dart'; +import 'package:stream_chat/src/core/util/serializer.dart'; +import 'package:stream_chat/src/core/models/user.dart'; import 'package:uuid/uuid.dart'; part 'message.g.dart'; @@ -41,16 +42,16 @@ enum MessageSendingStatus { /// The class that contains the information about a message @JsonSerializable() -class Message { +class Message extends Equatable { /// Constructor used for json serialization Message({ - String id, + String? id, this.text, - this.type, - this.attachments, - this.mentionedUsers, - this.silent, - this.shadowed, + this.type = 'regular', + this.attachments = const [], + this.mentionedUsers = const [], + this.silent = false, + this.shadowed = false, this.reactionCounts, this.reactionScores, this.latestReactions, @@ -62,130 +63,147 @@ class Message { this.threadParticipants, this.showInChannel, this.command, - this.createdAt, - this.updatedAt, + DateTime? createdAt, + DateTime? updatedAt, this.user, this.pinned = false, this.pinnedAt, - DateTime pinExpires, + DateTime? pinExpires, this.pinnedBy, - this.extraData, + this.extraData = const {}, this.deletedAt, this.status = MessageSendingStatus.sent, - this.skipPush, - }) : id = id ?? Uuid().v4(), - pinExpires = pinExpires?.toUtc(); + }) : id = id ?? const Uuid().v4(), + pinExpires = pinExpires?.toUtc(), + createdAt = createdAt ?? DateTime.now(), + updatedAt = updatedAt ?? DateTime.now(); /// Create a new instance from a json factory Message.fromJson(Map json) => _$MessageFromJson( - Serialization.moveToExtraDataFromRoot(json, topLevelFields)); + Serializer.moveToExtraDataFromRoot(json, topLevelFields)); /// The message ID. This is either created by Stream or set client side when /// the message is added. final String id; /// The text of this message - final String text; + final String? text; /// The status of a sending message @JsonKey(ignore: true) final MessageSendingStatus status; /// The message type - @JsonKey(includeIfNull: false, toJson: Serialization.readOnly) + @JsonKey( + includeIfNull: false, + toJson: Serializer.readOnly, + defaultValue: 'regular', + ) final String type; /// The list of attachments, either provided by the user or generated from a /// command or as a result of URL scraping. - @JsonKey(includeIfNull: false) + @JsonKey( + includeIfNull: false, + defaultValue: [], + ) final List attachments; /// The list of user mentioned in the message - @JsonKey(toJson: Serialization.userIds) + @JsonKey( + toJson: User.toIds, + defaultValue: [], + ) final List mentionedUsers; /// A map describing the count of number of every reaction - @JsonKey(includeIfNull: false, toJson: Serialization.readOnly) - final Map reactionCounts; + @JsonKey(includeIfNull: false, toJson: Serializer.readOnly) + final Map? reactionCounts; /// A map describing the count of score of every reaction - @JsonKey(includeIfNull: false, toJson: Serialization.readOnly) - final Map reactionScores; + @JsonKey(includeIfNull: false, toJson: Serializer.readOnly) + final Map? reactionScores; /// The latest reactions to the message created by any user. - @JsonKey(includeIfNull: false, toJson: Serialization.readOnly) - final List latestReactions; + @JsonKey(includeIfNull: false, toJson: Serializer.readOnly) + final List? latestReactions; /// The reactions added to the message by the current user. - @JsonKey(includeIfNull: false, toJson: Serialization.readOnly) - final List ownReactions; + @JsonKey(includeIfNull: false, toJson: Serializer.readOnly) + final List? ownReactions; /// The ID of the parent message, if the message is a thread reply. - final String parentId; + final String? parentId; /// A quoted reply message - @JsonKey(toJson: Serialization.readOnly) - final Message quotedMessage; + @JsonKey(toJson: Serializer.readOnly) + final Message? quotedMessage; /// The ID of the quoted message, if the message is a quoted reply. - final String quotedMessageId; + final String? quotedMessageId; /// Reserved field indicating the number of replies for this message. - @JsonKey(includeIfNull: false, toJson: Serialization.readOnly) - final int replyCount; + @JsonKey(includeIfNull: false, toJson: Serializer.readOnly) + final int? replyCount; /// Reserved field indicating the thread participants for this message. - @JsonKey(includeIfNull: false, toJson: Serialization.readOnly) - final List threadParticipants; + @JsonKey(includeIfNull: false, toJson: Serializer.readOnly) + final List? threadParticipants; /// Check if this message needs to show in the channel. - final bool showInChannel; + final bool? showInChannel; /// If true the message is silent + @JsonKey(defaultValue: false) final bool silent; - /// If true the message will not send a push notification - final bool skipPush; - /// If true the message is shadowed - @JsonKey(includeIfNull: false, toJson: Serialization.readOnly) + @JsonKey( + includeIfNull: false, + toJson: Serializer.readOnly, + defaultValue: false, + ) final bool shadowed; /// A used command name. - @JsonKey(includeIfNull: false, toJson: Serialization.readOnly) - final String command; + @JsonKey(includeIfNull: false, toJson: Serializer.readOnly) + final String? command; /// Reserved field indicating when the message was created. - @JsonKey(includeIfNull: false, toJson: Serialization.readOnly) + @JsonKey(includeIfNull: false, toJson: Serializer.readOnly) final DateTime createdAt; /// Reserved field indicating when the message was updated last time. - @JsonKey(includeIfNull: false, toJson: Serialization.readOnly) + @JsonKey(includeIfNull: false, toJson: Serializer.readOnly) final DateTime updatedAt; /// User who sent the message - @JsonKey(includeIfNull: false, toJson: Serialization.readOnly) - final User user; + @JsonKey(includeIfNull: false, toJson: Serializer.readOnly) + final User? user; /// If true the message is pinned + @JsonKey(defaultValue: false) final bool pinned; /// Reserved field indicating when the message was pinned - @JsonKey(toJson: Serialization.readOnly) - final DateTime pinnedAt; + @JsonKey(toJson: Serializer.readOnly) + final DateTime? pinnedAt; /// Reserved field indicating when the message will expire /// /// if `null` message has no expiry - final DateTime pinExpires; + final DateTime? pinExpires; /// Reserved field indicating who pinned the message - @JsonKey(toJson: Serialization.readOnly) - final User pinnedBy; + @JsonKey(toJson: Serializer.readOnly) + final User? pinnedBy; /// Message custom extraData - @JsonKey(includeIfNull: false) - final Map extraData; + @JsonKey( + includeIfNull: false, + defaultValue: {}, + ) + final Map extraData; /// True if the message is a system info bool get isSystem => type == 'system'; @@ -197,11 +215,11 @@ class Message { bool get isEphemeral => type == 'ephemeral'; /// Reserved field indicating when the message was deleted. - @JsonKey(includeIfNull: false, toJson: Serialization.readOnly) - final DateTime deletedAt; + @JsonKey(includeIfNull: false, toJson: Serializer.readOnly) + final DateTime? deletedAt; /// Known top level fields. - /// Useful for [Serialization] methods. + /// Useful for [Serializer] methods. static const topLevelFields = [ 'id', 'text', @@ -230,44 +248,43 @@ class Message { 'pinned_at', 'pin_expires', 'pinned_by', - 'skip_push', ]; /// Serialize to json - Map toJson() => Serialization.moveFromExtraDataToRoot( - _$MessageToJson(this), topLevelFields); + Map toJson() => Serializer.moveFromExtraDataToRoot( + _$MessageToJson(this), + ); /// Creates a copy of [Message] with specified attributes overridden. Message copyWith({ - String id, - String text, - String type, - List attachments, - List mentionedUsers, - Map reactionCounts, - Map reactionScores, - List latestReactions, - List ownReactions, - String parentId, - Message quotedMessage, - String quotedMessageId, - int replyCount, - List threadParticipants, - bool showInChannel, - bool shadowed, - bool silent, - String command, - DateTime createdAt, - DateTime updatedAt, - DateTime deletedAt, - User user, - bool pinned, - DateTime pinnedAt, - Object pinExpires = _pinExpires, - User pinnedBy, - Map extraData, - MessageSendingStatus status, - bool skipPush, + String? id, + String? text, + String? type, + List? attachments, + List? mentionedUsers, + Map? reactionCounts, + Map? reactionScores, + List? latestReactions, + List? ownReactions, + String? parentId, + Message? quotedMessage, + String? quotedMessageId, + int? replyCount, + List? threadParticipants, + bool? showInChannel, + bool? shadowed, + bool? silent, + String? command, + DateTime? createdAt, + DateTime? updatedAt, + DateTime? deletedAt, + User? user, + bool? pinned, + DateTime? pinnedAt, + Object? pinExpires = _pinExpires, + User? pinnedBy, + Map? extraData, + MessageSendingStatus? status, }) { assert(() { if (pinExpires is! DateTime && @@ -305,46 +322,75 @@ class Message { pinned: pinned ?? this.pinned, pinnedAt: pinnedAt ?? this.pinnedAt, pinnedBy: pinnedBy ?? this.pinnedBy, - pinExpires: pinExpires == _pinExpires ? this.pinExpires : pinExpires, - skipPush: skipPush ?? this.skipPush, + pinExpires: + pinExpires == _pinExpires ? this.pinExpires : pinExpires as DateTime?, ); } /// Returns a new [Message] that is a combination of this message and the /// given [other] message. - Message merge(Message other) { - if (other == null) return this; - return copyWith( - id: other.id, - text: other.text, - type: other.type, - attachments: other.attachments, - mentionedUsers: other.mentionedUsers, - reactionCounts: other.reactionCounts, - reactionScores: other.reactionScores, - latestReactions: other.latestReactions, - ownReactions: other.ownReactions, - parentId: other.parentId, - quotedMessage: other.quotedMessage, - quotedMessageId: other.quotedMessageId, - replyCount: other.replyCount, - threadParticipants: other.threadParticipants, - showInChannel: other.showInChannel, - command: other.command, - createdAt: other.createdAt, - silent: other.silent, - extraData: other.extraData, - user: other.user, - shadowed: other.shadowed, - updatedAt: other.updatedAt, - deletedAt: other.deletedAt, - status: other.status, - pinned: other.pinned, - pinnedAt: other.pinnedAt, - pinExpires: other.pinExpires, - pinnedBy: other.pinnedBy, - ); - } + Message merge(Message other) => copyWith( + id: other.id, + text: other.text, + type: other.type, + attachments: other.attachments, + mentionedUsers: other.mentionedUsers, + reactionCounts: other.reactionCounts, + reactionScores: other.reactionScores, + latestReactions: other.latestReactions, + ownReactions: other.ownReactions, + parentId: other.parentId, + quotedMessage: other.quotedMessage, + quotedMessageId: other.quotedMessageId, + replyCount: other.replyCount, + threadParticipants: other.threadParticipants, + showInChannel: other.showInChannel, + command: other.command, + createdAt: other.createdAt, + silent: other.silent, + extraData: other.extraData, + user: other.user, + shadowed: other.shadowed, + updatedAt: other.updatedAt, + deletedAt: other.deletedAt, + status: other.status, + pinned: other.pinned, + pinnedAt: other.pinnedAt, + pinExpires: other.pinExpires, + pinnedBy: other.pinnedBy, + ); + + @override + List get props => [ + id, + text, + type, + attachments, + mentionedUsers, + reactionCounts, + reactionScores, + latestReactions, + ownReactions, + parentId, + quotedMessage, + quotedMessageId, + replyCount, + threadParticipants, + showInChannel, + shadowed, + silent, + command, + createdAt, + updatedAt, + deletedAt, + user, + pinned, + pinnedAt, + pinExpires, + pinnedBy, + extraData, + status, + ]; } /// A translated message @@ -352,19 +398,19 @@ class Message { @JsonSerializable() class TranslatedMessage extends Message { /// Constructor used for json serialization - TranslatedMessage(this.i18n); + TranslatedMessage(this.i18n) : super(); /// Create a new instance from a json factory TranslatedMessage.fromJson(Map json) => _$TranslatedMessageFromJson( - Serialization.moveToExtraDataFromRoot(json, topLevelFields), + Serializer.moveToExtraDataFromRoot(json, topLevelFields), ); /// A Map of - final Map i18n; + final Map? i18n; /// Known top level fields. - /// Useful for [Serialization] methods. + /// Useful for [Serializer] methods. static final topLevelFields = [ 'i18n', ...Message.topLevelFields, @@ -372,8 +418,7 @@ class TranslatedMessage extends Message { /// Serialize to json @override - Map toJson() => Serialization.moveFromExtraDataToRoot( + Map toJson() => Serializer.moveFromExtraDataToRoot( _$TranslatedMessageToJson(this), - topLevelFields, ); } diff --git a/packages/stream_chat/lib/src/core/models/message.g.dart b/packages/stream_chat/lib/src/core/models/message.g.dart new file mode 100644 index 00000000..c49e2a6e --- /dev/null +++ b/packages/stream_chat/lib/src/core/models/message.g.dart @@ -0,0 +1,124 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'message.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +Message _$MessageFromJson(Map json) { + return Message( + id: json['id'] as String?, + text: json['text'] as String?, + type: json['type'] as String? ?? 'regular', + attachments: (json['attachments'] as List?) + ?.map((e) => Attachment.fromJson(e as Map)) + .toList() ?? + [], + mentionedUsers: (json['mentioned_users'] as List?) + ?.map((e) => User.fromJson(e as Map)) + .toList() ?? + [], + silent: json['silent'] as bool? ?? false, + shadowed: json['shadowed'] as bool? ?? false, + reactionCounts: (json['reaction_counts'] as Map?)?.map( + (k, e) => MapEntry(k, e as int), + ), + reactionScores: (json['reaction_scores'] as Map?)?.map( + (k, e) => MapEntry(k, e as int), + ), + latestReactions: (json['latest_reactions'] as List?) + ?.map((e) => Reaction.fromJson(e as Map)) + .toList(), + ownReactions: (json['own_reactions'] as List?) + ?.map((e) => Reaction.fromJson(e as Map)) + .toList(), + parentId: json['parent_id'] as String?, + quotedMessage: json['quoted_message'] == null + ? null + : Message.fromJson(json['quoted_message'] as Map), + quotedMessageId: json['quoted_message_id'] as String?, + replyCount: json['reply_count'] as int?, + threadParticipants: (json['thread_participants'] as List?) + ?.map((e) => User.fromJson(e as Map)) + .toList(), + showInChannel: json['show_in_channel'] as bool?, + command: json['command'] as String?, + createdAt: json['created_at'] == null + ? null + : DateTime.parse(json['created_at'] as String), + updatedAt: json['updated_at'] == null + ? null + : DateTime.parse(json['updated_at'] as String), + user: json['user'] == null + ? null + : User.fromJson(json['user'] as Map), + pinned: json['pinned'] as bool? ?? false, + pinnedAt: json['pinned_at'] == null + ? null + : DateTime.parse(json['pinned_at'] as String), + pinExpires: json['pin_expires'] == null + ? null + : DateTime.parse(json['pin_expires'] as String), + pinnedBy: json['pinned_by'] == null + ? null + : User.fromJson(json['pinned_by'] as Map), + extraData: json['extra_data'] as Map? ?? {}, + deletedAt: json['deleted_at'] == null + ? null + : DateTime.parse(json['deleted_at'] as String), + ); +} + +Map _$MessageToJson(Message instance) { + final val = { + 'id': instance.id, + 'text': instance.text, + }; + + void writeNotNull(String key, dynamic value) { + if (value != null) { + val[key] = value; + } + } + + writeNotNull('type', readonly(instance.type)); + val['attachments'] = instance.attachments.map((e) => e.toJson()).toList(); + val['mentioned_users'] = User.toIds(instance.mentionedUsers); + writeNotNull('reaction_counts', readonly(instance.reactionCounts)); + writeNotNull('reaction_scores', readonly(instance.reactionScores)); + writeNotNull('latest_reactions', readonly(instance.latestReactions)); + writeNotNull('own_reactions', readonly(instance.ownReactions)); + val['parent_id'] = instance.parentId; + val['quoted_message'] = readonly(instance.quotedMessage); + val['quoted_message_id'] = instance.quotedMessageId; + writeNotNull('reply_count', readonly(instance.replyCount)); + writeNotNull('thread_participants', readonly(instance.threadParticipants)); + val['show_in_channel'] = instance.showInChannel; + val['silent'] = instance.silent; + writeNotNull('shadowed', readonly(instance.shadowed)); + writeNotNull('command', readonly(instance.command)); + writeNotNull('created_at', readonly(instance.createdAt)); + writeNotNull('updated_at', readonly(instance.updatedAt)); + writeNotNull('user', readonly(instance.user)); + val['pinned'] = instance.pinned; + val['pinned_at'] = readonly(instance.pinnedAt); + val['pin_expires'] = instance.pinExpires?.toIso8601String(); + val['pinned_by'] = readonly(instance.pinnedBy); + val['extra_data'] = instance.extraData; + writeNotNull('deleted_at', readonly(instance.deletedAt)); + return val; +} + +TranslatedMessage _$TranslatedMessageFromJson(Map json) { + return TranslatedMessage( + (json['i18n'] as Map?)?.map( + (k, e) => MapEntry(k, e as String), + ), + ); +} + +Map _$TranslatedMessageToJson(TranslatedMessage instance) => + { + 'i18n': instance.i18n, + }; diff --git a/packages/stream_chat/lib/src/core/models/mute.dart b/packages/stream_chat/lib/src/core/models/mute.dart new file mode 100644 index 00000000..857b34a1 --- /dev/null +++ b/packages/stream_chat/lib/src/core/models/mute.dart @@ -0,0 +1,37 @@ +import 'package:json_annotation/json_annotation.dart'; +import 'package:stream_chat/src/core/models/channel_model.dart'; +import 'package:stream_chat/src/core/models/user.dart'; +import 'package:stream_chat/src/core/util/serializer.dart'; + +part 'mute.g.dart'; + +/// The class that contains the information about a muted user +@JsonSerializable(createToJson: false) +class Mute { + /// Constructor used for json serialization + Mute({ + required this.user, + required this.channel, + required this.createdAt, + required this.updatedAt, + }); + + /// Create a new instance from a json + factory Mute.fromJson(Map json) => _$MuteFromJson(json); + + /// The user that performed the muting action + @JsonKey(includeIfNull: false, toJson: Serializer.readOnly) + final User user; + + /// The target user + @JsonKey(includeIfNull: false, toJson: Serializer.readOnly) + final ChannelModel channel; + + /// The date in which the use was muted + @JsonKey(includeIfNull: false, toJson: Serializer.readOnly) + final DateTime createdAt; + + /// The date of the last update + @JsonKey(includeIfNull: false, toJson: Serializer.readOnly) + final DateTime updatedAt; +} diff --git a/packages/stream_chat/lib/src/core/models/mute.g.dart b/packages/stream_chat/lib/src/core/models/mute.g.dart new file mode 100644 index 00000000..b847a109 --- /dev/null +++ b/packages/stream_chat/lib/src/core/models/mute.g.dart @@ -0,0 +1,16 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'mute.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +Mute _$MuteFromJson(Map json) { + return Mute( + user: User.fromJson(json['user'] as Map), + channel: ChannelModel.fromJson(json['channel'] as Map), + createdAt: DateTime.parse(json['created_at'] as String), + updatedAt: DateTime.parse(json['updated_at'] as String), + ); +} diff --git a/packages/stream_chat/lib/src/core/models/own_user.dart b/packages/stream_chat/lib/src/core/models/own_user.dart new file mode 100644 index 00000000..679abb47 --- /dev/null +++ b/packages/stream_chat/lib/src/core/models/own_user.dart @@ -0,0 +1,149 @@ +import 'package:json_annotation/json_annotation.dart'; +import 'package:stream_chat/src/core/models/device.dart'; +import 'package:stream_chat/src/core/models/mute.dart'; +import 'package:stream_chat/src/core/models/user.dart'; +import 'package:stream_chat/src/core/util/serializer.dart'; +import 'package:stream_chat/stream_chat.dart'; + +part 'own_user.g.dart'; + +/// The class that defines the own user model +/// This object can be found in [Event] +@JsonSerializable(createToJson: false) +class OwnUser extends User { + /// Constructor used for json serialization + OwnUser({ + this.devices = const [], + this.mutes = const [], + this.totalUnreadCount = 0, + this.unreadChannels, + this.channelMutes = const [], + required String id, + String? role, + DateTime? createdAt, + DateTime? updatedAt, + DateTime? lastActive, + bool online = false, + Map extraData = const {}, + bool banned = false, + List teams = const [], + }) : super( + id: id, + role: role, + createdAt: createdAt, + updatedAt: updatedAt, + lastActive: lastActive, + online: online, + extraData: extraData, + banned: banned, + teams: teams, + ); + + /// Create a new instance from a json + factory OwnUser.fromJson(Map json) => _$OwnUserFromJson( + Serializer.moveToExtraDataFromRoot(json, topLevelFields)); + + /// Create a new instance from [User] object + factory OwnUser.fromUser(User user) => OwnUser( + id: user.id, + role: user.role, + createdAt: user.createdAt, + updatedAt: user.updatedAt, + lastActive: user.lastActive, + online: user.online, + banned: user.banned, + extraData: user.extraData, + teams: user.teams, + ); + + /// Creates a copy of [OwnUser] with specified attributes overridden. + @override + OwnUser copyWith({ + String? id, + String? role, + DateTime? createdAt, + DateTime? updatedAt, + DateTime? lastActive, + bool? online, + Map? extraData, + bool? banned, + List? teams, + List? channelMutes, + List? devices, + List? mutes, + int? totalUnreadCount, + int? unreadChannels, + }) => + OwnUser( + id: id ?? this.id, + banned: banned ?? this.banned, + role: role ?? this.role, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + lastActive: lastActive ?? this.lastActive, + online: online ?? this.online, + extraData: extraData ?? this.extraData, + teams: teams ?? this.teams, + channelMutes: channelMutes ?? this.channelMutes, + devices: devices ?? this.devices, + mutes: mutes ?? this.mutes, + totalUnreadCount: totalUnreadCount ?? this.totalUnreadCount, + unreadChannels: unreadChannels ?? this.unreadChannels, + ); + + /// Returns a new [OwnUser] that is a combination of this ownUser + /// and the given [other] ownUser. + OwnUser merge(OwnUser? other) { + if (other == null) { + return this; + } + + return copyWith( + banned: other.banned, + channelMutes: other.channelMutes, + createdAt: other.createdAt, + devices: other.devices, + extraData: other.extraData, + id: other.id, + lastActive: other.lastActive, + mutes: other.mutes, + online: other.online, + role: other.role, + teams: other.teams, + totalUnreadCount: other.totalUnreadCount, + unreadChannels: other.unreadChannels, + updatedAt: other.updatedAt, + ); + } + + /// List of user devices + @JsonKey(includeIfNull: false, defaultValue: []) + final List devices; + + /// List of users muted by the user + @JsonKey(includeIfNull: false, defaultValue: []) + final List mutes; + + /// List of users muted by the user + @JsonKey(includeIfNull: false, defaultValue: []) + final List channelMutes; + + /// Total unread messages by the user + @JsonKey(includeIfNull: false, defaultValue: 0) + final int totalUnreadCount; + + /// Total unread channels by the user + @JsonKey(includeIfNull: false) + final int? unreadChannels; + + /// Known top level fields. + /// Useful for [Serializer] methods. + static final topLevelFields = [ + 'devices', + 'mutes', + 'total_unread_count', + 'unread_channels', + 'channel_mutes', + ...User.topLevelFields, + ]; +} diff --git a/packages/stream_chat/lib/src/core/models/own_user.g.dart b/packages/stream_chat/lib/src/core/models/own_user.g.dart new file mode 100644 index 00000000..26e4786e --- /dev/null +++ b/packages/stream_chat/lib/src/core/models/own_user.g.dart @@ -0,0 +1,40 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'own_user.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +OwnUser _$OwnUserFromJson(Map json) { + return OwnUser( + devices: (json['devices'] as List?) + ?.map((e) => Device.fromJson(e as Map)) + .toList() ?? + [], + mutes: (json['mutes'] as List?) + ?.map((e) => Mute.fromJson(e as Map)) + .toList() ?? + [], + totalUnreadCount: json['total_unread_count'] as int? ?? 0, + unreadChannels: json['unread_channels'] as int?, + channelMutes: (json['channel_mutes'] as List?) + ?.map((e) => Mute.fromJson(e as Map)) + .toList() ?? + [], + id: json['id'] as String, + role: json['role'] as String?, + createdAt: json['created_at'] == null + ? null + : DateTime.parse(json['created_at'] as String), + updatedAt: json['updated_at'] == null + ? null + : DateTime.parse(json['updated_at'] as String), + lastActive: json['last_active'] == null + ? null + : DateTime.parse(json['last_active'] as String), + online: json['online'] as bool? ?? false, + extraData: json['extra_data'] as Map? ?? {}, + banned: json['banned'] as bool? ?? false, + ); +} diff --git a/packages/stream_chat/lib/src/core/models/reaction.dart b/packages/stream_chat/lib/src/core/models/reaction.dart new file mode 100644 index 00000000..57d0b48d --- /dev/null +++ b/packages/stream_chat/lib/src/core/models/reaction.dart @@ -0,0 +1,104 @@ +import 'package:json_annotation/json_annotation.dart'; +import 'package:stream_chat/src/core/util/serializer.dart'; +import 'package:stream_chat/src/core/models/user.dart'; + +part 'reaction.g.dart'; + +/// The class that defines a reaction +@JsonSerializable() +class Reaction { + /// Constructor used for json serialization + Reaction({ + this.messageId, + DateTime? createdAt, + required this.type, + this.user, + String? userId, + this.score = 0, + this.extraData = const {}, + }) : userId = userId ?? user?.id, + createdAt = createdAt ?? DateTime.now(); + + /// Create a new instance from a json + factory Reaction.fromJson(Map json) => + _$ReactionFromJson(Serializer.moveToExtraDataFromRoot( + json, + topLevelFields, + )); + + /// The messageId to which the reaction belongs + final String? messageId; + + /// The type of the reaction + final String type; + + /// The date of the reaction + @JsonKey(includeIfNull: false, toJson: Serializer.readOnly) + final DateTime createdAt; + + /// The user that sent the reaction + @JsonKey(includeIfNull: false, toJson: Serializer.readOnly) + final User? user; + + /// The score of the reaction (ie. number of reactions sent) + @JsonKey(defaultValue: 0) + final int score; + + /// The userId that sent the reaction + @JsonKey(includeIfNull: false, toJson: Serializer.readOnly) + final String? userId; + + /// Reaction custom extraData + @JsonKey( + includeIfNull: false, + defaultValue: {}, + ) + final Map extraData; + + /// Map of custom user extraData + static const topLevelFields = [ + 'message_id', + 'created_at', + 'type', + 'user', + 'user_id', + 'score', + ]; + + /// Serialize to json + Map toJson() => Serializer.moveFromExtraDataToRoot( + _$ReactionToJson(this), + ); + + /// Creates a copy of [Reaction] with specified attributes overridden. + Reaction copyWith({ + String? messageId, + DateTime? createdAt, + String? type, + User? user, + String? userId, + int? score, + Map? extraData, + }) => + Reaction( + messageId: messageId ?? this.messageId, + createdAt: createdAt ?? this.createdAt, + type: type ?? this.type, + user: user ?? this.user, + userId: userId ?? this.userId, + score: score ?? this.score, + extraData: extraData ?? this.extraData, + ); + + /// Returns a new [Reaction] that is a combination of this reaction and the + /// given [other] reaction. + Reaction merge(Reaction other) => copyWith( + messageId: other.messageId, + createdAt: other.createdAt, + type: other.type, + user: other.user, + userId: other.userId, + score: other.score, + extraData: other.extraData, + ); +} diff --git a/packages/stream_chat/lib/src/models/reaction.g.dart b/packages/stream_chat/lib/src/core/models/reaction.g.dart similarity index 69% rename from packages/stream_chat/lib/src/models/reaction.g.dart rename to packages/stream_chat/lib/src/core/models/reaction.g.dart index a270af01..3e92150d 100644 --- a/packages/stream_chat/lib/src/models/reaction.g.dart +++ b/packages/stream_chat/lib/src/core/models/reaction.g.dart @@ -6,23 +6,19 @@ part of 'reaction.dart'; // JsonSerializableGenerator // ************************************************************************** -Reaction _$ReactionFromJson(Map json) { +Reaction _$ReactionFromJson(Map json) { return Reaction( - messageId: json['message_id'] as String, + messageId: json['message_id'] as String?, createdAt: json['created_at'] == null ? null : DateTime.parse(json['created_at'] as String), type: json['type'] as String, user: json['user'] == null ? null - : User.fromJson((json['user'] as Map)?.map( - (k, e) => MapEntry(k as String, e), - )), - userId: json['user_id'] as String, - score: json['score'] as int, - extraData: (json['extra_data'] as Map)?.map( - (k, e) => MapEntry(k as String, e), - ), + : User.fromJson(json['user'] as Map), + userId: json['user_id'] as String?, + score: json['score'] as int? ?? 0, + extraData: json['extra_data'] as Map? ?? {}, ); } @@ -42,6 +38,6 @@ Map _$ReactionToJson(Reaction instance) { writeNotNull('user', readonly(instance.user)); val['score'] = instance.score; writeNotNull('user_id', readonly(instance.userId)); - writeNotNull('extra_data', instance.extraData); + val['extra_data'] = instance.extraData; return val; } diff --git a/packages/stream_chat/lib/src/models/read.dart b/packages/stream_chat/lib/src/core/models/read.dart similarity index 78% rename from packages/stream_chat/lib/src/models/read.dart rename to packages/stream_chat/lib/src/core/models/read.dart index fc8ef1bc..55912762 100644 --- a/packages/stream_chat/lib/src/models/read.dart +++ b/packages/stream_chat/lib/src/core/models/read.dart @@ -1,5 +1,5 @@ import 'package:json_annotation/json_annotation.dart'; -import 'package:stream_chat/src/models/user.dart'; +import 'package:stream_chat/src/core/models/user.dart'; part 'read.g.dart'; @@ -8,9 +8,9 @@ part 'read.g.dart'; class Read { /// Constructor used for json serialization Read({ - this.lastRead, - this.user, - this.unreadMessages, + required this.lastRead, + required this.user, + this.unreadMessages = 0, }); /// Create a new instance from a json @@ -23,6 +23,7 @@ class Read { final User user; /// Number of unread messages + @JsonKey(defaultValue: 0) final int unreadMessages; /// Serialize to json @@ -30,9 +31,9 @@ class Read { /// Creates a copy of [Read] with specified attributes overridden. Read copyWith({ - DateTime lastRead, - User user, - int unreadMessages, + DateTime? lastRead, + User? user, + int? unreadMessages, }) => Read( lastRead: lastRead ?? this.lastRead, diff --git a/packages/stream_chat/lib/src/core/models/read.g.dart b/packages/stream_chat/lib/src/core/models/read.g.dart new file mode 100644 index 00000000..93c832d8 --- /dev/null +++ b/packages/stream_chat/lib/src/core/models/read.g.dart @@ -0,0 +1,21 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'read.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +Read _$ReadFromJson(Map json) { + return Read( + lastRead: DateTime.parse(json['last_read'] as String), + user: User.fromJson(json['user'] as Map), + unreadMessages: json['unread_messages'] as int? ?? 0, + ); +} + +Map _$ReadToJson(Read instance) => { + 'last_read': instance.lastRead.toIso8601String(), + 'user': instance.user.toJson(), + 'unread_messages': instance.unreadMessages, + }; diff --git a/packages/stream_chat/lib/src/core/models/user.dart b/packages/stream_chat/lib/src/core/models/user.dart new file mode 100644 index 00000000..1360d14e --- /dev/null +++ b/packages/stream_chat/lib/src/core/models/user.dart @@ -0,0 +1,147 @@ +import 'package:equatable/equatable.dart'; +import 'package:json_annotation/json_annotation.dart'; +import 'package:stream_chat/src/core/util/serializer.dart'; + +part 'user.g.dart'; + +/// The class that defines the user model +@JsonSerializable() +class User extends Equatable { + /// Constructor used for json serialization + User({ + required this.id, + this.role, + DateTime? createdAt, + DateTime? updatedAt, + this.lastActive, + this.online = false, + this.extraData = const {}, + this.banned = false, + this.teams = const [], + }) : createdAt = createdAt ?? DateTime.now(), + updatedAt = updatedAt ?? DateTime.now(); + + /// Create a new instance from a json + factory User.fromJson(Map json) => + _$UserFromJson(Serializer.moveToExtraDataFromRoot(json, topLevelFields)); + + /// Known top level fields. + /// Useful for [Serializer] methods. + static const topLevelFields = [ + 'id', + 'role', + 'created_at', + 'updated_at', + 'last_active', + 'online', + 'banned', + 'teams', + ]; + + /// User id + final String id; + + /// User role + @JsonKey(includeIfNull: false, toJson: Serializer.readOnly) + final String? role; + + /// User role + @JsonKey( + includeIfNull: false, + toJson: Serializer.readOnly, + defaultValue: [], + ) + final List teams; + + /// Date of user creation + @JsonKey(includeIfNull: false, toJson: Serializer.readOnly) + final DateTime createdAt; + + /// Date of last user update + @JsonKey(includeIfNull: false, toJson: Serializer.readOnly) + final DateTime updatedAt; + + /// Date of last user connection + @JsonKey(includeIfNull: false, toJson: Serializer.readOnly) + final DateTime? lastActive; + + /// True if user is online + @JsonKey( + includeIfNull: false, toJson: Serializer.readOnly, defaultValue: false) + final bool online; + + /// True if user is banned from the chat + @JsonKey( + includeIfNull: false, toJson: Serializer.readOnly, defaultValue: false) + final bool banned; + + /// Map of custom user extraData + @JsonKey( + includeIfNull: false, + defaultValue: {}, + ) + final Map extraData; + + @override + int get hashCode => id.hashCode; + + /// Shortcut for user name + String get name { + if (extraData.containsKey('name')) { + final name = extraData['name']! as String; + if (name.isNotEmpty) return name; + } + return id; + } + + /// List of users to list of userIds + static List? toIds(List? users) => + users?.map((u) => u.id).toList(); + + @override + bool operator ==(Object other) => + identical(this, other) || + other is User && runtimeType == other.runtimeType && id == other.id; + + /// Serialize to json + Map toJson() => Serializer.moveFromExtraDataToRoot( + _$UserToJson(this), + ); + + /// Creates a copy of [User] with specified attributes overridden. + User copyWith({ + String? id, + String? role, + DateTime? createdAt, + DateTime? updatedAt, + DateTime? lastActive, + bool? online, + Map? extraData, + bool? banned, + List? teams, + }) => + User( + id: id ?? this.id, + role: role ?? this.role, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + lastActive: lastActive ?? this.lastActive, + online: online ?? this.online, + extraData: extraData ?? this.extraData, + banned: banned ?? this.banned, + teams: teams ?? this.teams, + ); + + @override + List get props => [ + id, + role, + teams, + createdAt, + updatedAt, + lastActive, + online, + banned, + extraData, + ]; +} diff --git a/packages/stream_chat/lib/src/models/user.g.dart b/packages/stream_chat/lib/src/core/models/user.g.dart similarity index 76% rename from packages/stream_chat/lib/src/models/user.g.dart rename to packages/stream_chat/lib/src/core/models/user.g.dart index b27935a7..befcac03 100644 --- a/packages/stream_chat/lib/src/models/user.g.dart +++ b/packages/stream_chat/lib/src/core/models/user.g.dart @@ -6,10 +6,10 @@ part of 'user.dart'; // JsonSerializableGenerator // ************************************************************************** -User _$UserFromJson(Map json) { +User _$UserFromJson(Map json) { return User( id: json['id'] as String, - role: json['role'] as String, + role: json['role'] as String?, createdAt: json['created_at'] == null ? null : DateTime.parse(json['created_at'] as String), @@ -19,12 +19,12 @@ User _$UserFromJson(Map json) { lastActive: json['last_active'] == null ? null : DateTime.parse(json['last_active'] as String), - online: json['online'] as bool, - extraData: (json['extra_data'] as Map)?.map( - (k, e) => MapEntry(k as String, e), - ), - banned: json['banned'] as bool, - teams: (json['teams'] as List)?.map((e) => e as String)?.toList(), + online: json['online'] as bool? ?? false, + extraData: json['extra_data'] as Map? ?? {}, + banned: json['banned'] as bool? ?? false, + teams: + (json['teams'] as List?)?.map((e) => e as String).toList() ?? + [], ); } @@ -46,6 +46,6 @@ Map _$UserToJson(User instance) { writeNotNull('last_active', readonly(instance.lastActive)); writeNotNull('online', readonly(instance.online)); writeNotNull('banned', readonly(instance.banned)); - writeNotNull('extra_data', instance.extraData); + val['extra_data'] = instance.extraData; return val; } diff --git a/packages/stream_chat/lib/src/platform_detector/platform_detector.dart b/packages/stream_chat/lib/src/core/platform_detector/platform_detector.dart similarity index 95% rename from packages/stream_chat/lib/src/platform_detector/platform_detector.dart rename to packages/stream_chat/lib/src/core/platform_detector/platform_detector.dart index 58d86db4..cf817cac 100644 --- a/packages/stream_chat/lib/src/platform_detector/platform_detector.dart +++ b/packages/stream_chat/lib/src/core/platform_detector/platform_detector.dart @@ -1,4 +1,4 @@ -import 'package:stream_chat/src/platform_detector/platform_detector_stub.dart' +import 'package:stream_chat/src/core/platform_detector/platform_detector_stub.dart' if (dart.library.html) 'platform_detector_web.dart' if (dart.library.io) 'platform_detector_io.dart'; diff --git a/packages/stream_chat/lib/src/platform_detector/platform_detector_io.dart b/packages/stream_chat/lib/src/core/platform_detector/platform_detector_io.dart similarity index 82% rename from packages/stream_chat/lib/src/platform_detector/platform_detector_io.dart rename to packages/stream_chat/lib/src/core/platform_detector/platform_detector_io.dart index c7b4a0b7..da707eed 100644 --- a/packages/stream_chat/lib/src/platform_detector/platform_detector_io.dart +++ b/packages/stream_chat/lib/src/core/platform_detector/platform_detector_io.dart @@ -1,5 +1,5 @@ import 'dart:io'; -import 'package:stream_chat/src/platform_detector/platform_detector.dart'; +import 'package:stream_chat/src/core/platform_detector/platform_detector.dart'; /// Version running on native systems PlatformType get currentPlatform { diff --git a/packages/stream_chat/lib/src/platform_detector/platform_detector_stub.dart b/packages/stream_chat/lib/src/core/platform_detector/platform_detector_stub.dart similarity index 53% rename from packages/stream_chat/lib/src/platform_detector/platform_detector_stub.dart rename to packages/stream_chat/lib/src/core/platform_detector/platform_detector_stub.dart index b9e13c2e..9d1a7f66 100644 --- a/packages/stream_chat/lib/src/platform_detector/platform_detector_stub.dart +++ b/packages/stream_chat/lib/src/core/platform_detector/platform_detector_stub.dart @@ -1,4 +1,4 @@ -import 'package:stream_chat/src/platform_detector/platform_detector.dart'; +import 'package:stream_chat/src/core/platform_detector/platform_detector.dart'; /// Stub implementation PlatformType get currentPlatform { diff --git a/packages/stream_chat/lib/src/platform_detector/platform_detector_web.dart b/packages/stream_chat/lib/src/core/platform_detector/platform_detector_web.dart similarity index 50% rename from packages/stream_chat/lib/src/platform_detector/platform_detector_web.dart rename to packages/stream_chat/lib/src/core/platform_detector/platform_detector_web.dart index ba5d04fc..324b4145 100644 --- a/packages/stream_chat/lib/src/platform_detector/platform_detector_web.dart +++ b/packages/stream_chat/lib/src/core/platform_detector/platform_detector_web.dart @@ -1,4 +1,4 @@ -import 'package:stream_chat/src/platform_detector/platform_detector.dart'; +import 'package:stream_chat/src/core/platform_detector/platform_detector.dart'; /// Version running on web PlatformType get currentPlatform => PlatformType.web; diff --git a/packages/stream_chat/lib/src/core/util/extension.dart b/packages/stream_chat/lib/src/core/util/extension.dart new file mode 100644 index 00000000..a28dc12b --- /dev/null +++ b/packages/stream_chat/lib/src/core/util/extension.dart @@ -0,0 +1,33 @@ +import 'package:http_parser/http_parser.dart'; +import 'package:mime/mime.dart'; + +/// Useful extension functions for [Iterable] +extension IterableX on Iterable { + /// Removes all the null values + /// and converts `Iterable` into `Iterable` + Iterable get withNullifyer => whereType(); +} + +/// Useful extension functions for [Map] +extension MapX on Map { + /// Returns a new map with null keys or values removed + Map get nullProtected { + final nullProtected = {...this} + ..removeWhere((key, value) => key == null || value == null); + return nullProtected.cast(); + } +} + +/// Useful extension functions for [String] +extension StringX on String { + /// returns the mime type from the passed file name. + MediaType? get mimeType { + if (toLowerCase().endsWith('heic')) { + return MediaType.parse('image/heic'); + } else { + final mimeType = lookupMimeType(this); + if (mimeType == null) return null; + return MediaType.parse(mimeType); + } + } +} diff --git a/packages/stream_chat/lib/src/models/serialization.dart b/packages/stream_chat/lib/src/core/util/serializer.dart similarity index 81% rename from packages/stream_chat/lib/src/models/serialization.dart rename to packages/stream_chat/lib/src/core/util/serializer.dart index 18cdd545..2bf92e39 100644 --- a/packages/stream_chat/lib/src/models/serialization.dart +++ b/packages/stream_chat/lib/src/core/util/serializer.dart @@ -1,25 +1,17 @@ -import 'package:stream_chat/src/models/user.dart'; - /// Used to avoid to serialize properties to json // ignore: prefer_void_to_null Null readonly(_) => null; /// Helper class for serialization to and from json -class Serialization { +class Serializer { /// Used to avoid to serialize properties to json static const Function readOnly = readonly; - /// List of users to list of userIds - static List userIds(List users) => - users?.map((u) => u.id)?.toList(); - /// Takes unknown json keys and puts them in the `extra_data` key static Map moveToExtraDataFromRoot( Map json, List topLevelFields, ) { - if (json == null) return null; - final jsonClone = Map.from(json); final extraDataMap = Map.from(json) @@ -38,7 +30,6 @@ class Serialization { /// the json map static Map moveFromExtraDataToRoot( Map json, - List topLevelFields, ) { final jsonClone = Map.from(json); return jsonClone diff --git a/packages/stream_chat/lib/src/core/util/utils.dart b/packages/stream_chat/lib/src/core/util/utils.dart new file mode 100644 index 00000000..c2204991 --- /dev/null +++ b/packages/stream_chat/lib/src/core/util/utils.dart @@ -0,0 +1,25 @@ +import 'dart:convert'; +import 'dart:math' as math; + +// This alphabet uses `A-Za-z0-9_-` symbols. The genetic algorithm helped +// optimize the gzip compression for this alphabet. +const _alphabet = + 'ModuleSymbhasOwnPr-0123456789ABCDEFGHNRVfgctiUvz_KqYTJkLxpZXIjQW'; + +/// Generates a random String id +/// Adopted from: https://github.com/ai/nanoid/blob/main/non-secure/index.js +String randomId({int size = 21}) { + var id = ''; + for (var i = 0; i < size; i++) { + id += _alphabet[(math.Random().nextDouble() * 64).floor() | 0]; + } + return id; +} + +/// Creates a hash string from the passed [objects] +String generateHash(List objects) { + final payload = json.encode(objects); + final payloadBytes = utf8.encode(payload); + final payloadB64 = base64.encode(payloadBytes); + return payloadB64; +} diff --git a/packages/stream_chat/lib/src/db/chat_persistence_client.dart b/packages/stream_chat/lib/src/db/chat_persistence_client.dart index d15772df..2470e403 100644 --- a/packages/stream_chat/lib/src/db/chat_persistence_client.dart +++ b/packages/stream_chat/lib/src/db/chat_persistence_client.dart @@ -1,12 +1,14 @@ -import 'package:stream_chat/src/api/requests.dart'; -import 'package:stream_chat/src/models/channel_model.dart'; -import 'package:stream_chat/src/models/channel_state.dart'; -import 'package:stream_chat/src/models/event.dart'; -import 'package:stream_chat/src/models/member.dart'; -import 'package:stream_chat/src/models/message.dart'; -import 'package:stream_chat/src/models/reaction.dart'; -import 'package:stream_chat/src/models/read.dart'; -import 'package:stream_chat/src/models/user.dart'; +import 'package:stream_chat/src/core/api/requests.dart'; +import 'package:stream_chat/src/core/models/channel_model.dart'; +import 'package:stream_chat/src/core/models/channel_state.dart'; +import 'package:stream_chat/src/core/models/event.dart'; +import 'package:stream_chat/src/core/models/filter.dart'; +import 'package:stream_chat/src/core/models/member.dart'; +import 'package:stream_chat/src/core/models/message.dart'; +import 'package:stream_chat/src/core/models/reaction.dart'; +import 'package:stream_chat/src/core/models/read.dart'; +import 'package:stream_chat/src/core/models/user.dart'; +import 'package:stream_chat/src/core/util/extension.dart'; /// A simple client used for persisting chat data locally. abstract class ChatPersistenceClient { @@ -20,14 +22,14 @@ abstract class ChatPersistenceClient { /// Get stored replies by messageId Future> getReplies( String parentId, { - PaginationParams options, + PaginationParams? options, }); /// Get stored connection event - Future getConnectionInfo(); + Future getConnectionInfo(); /// Get stored lastSyncAt - Future getLastSyncAt(); + Future getLastSyncAt(); /// Update stored connection event Future updateConnectionInfo(Event event); @@ -39,7 +41,7 @@ abstract class ChatPersistenceClient { Future> getChannelCids(); /// Get stored [ChannelModel]s by providing channel [cid] - Future getChannelByCid(String cid); + Future getChannelByCid(String cid); /// Get stored channel [Member]s by providing channel [cid] Future> getMembersByCid(String cid); @@ -53,20 +55,20 @@ abstract class ChatPersistenceClient { /// for filtering out messages Future> getMessagesByCid( String cid, { - PaginationParams messagePagination, + PaginationParams? messagePagination, }); /// Get stored pinned [Message]s by providing channel [cid] Future> getPinnedMessagesByCid( String cid, { - PaginationParams messagePagination, + PaginationParams? messagePagination, }); /// Get [ChannelState] data by providing channel [cid] Future getChannelStateByCid( String cid, { - PaginationParams messagePagination, - PaginationParams pinnedMessagePagination, + PaginationParams? messagePagination, + PaginationParams? pinnedMessagePagination, }) async { final data = await Future.wait([ getMembersByCid(cid), @@ -76,11 +78,16 @@ abstract class ChatPersistenceClient { getPinnedMessagesByCid(cid, messagePagination: pinnedMessagePagination), ]); return ChannelState( - members: data[0], - read: data[1], - channel: data[2], - messages: data[3], - pinnedMessages: data[4], + // ignore: cast_nullable_to_non_nullable + members: data[0] as List, + // ignore: cast_nullable_to_non_nullable + read: data[1] as List, + // ignore: cast_nullable_to_non_nullable + channel: data[2] as ChannelModel?, + // ignore: cast_nullable_to_non_nullable + messages: data[3] as List, + // ignore: cast_nullable_to_non_nullable + pinnedMessages: data[4] as List, ); } @@ -89,9 +96,9 @@ abstract class ChatPersistenceClient { /// Optionally, pass [filter], [sort], [paginationParams] /// for filtering out states. Future> getChannelStates({ - Map filter, - List> sort = const [], - PaginationParams paginationParams, + Filter? filter, + List>? sort, + PaginationParams? paginationParams, }); /// Update list of channel queries. @@ -99,7 +106,7 @@ abstract class ChatPersistenceClient { /// If [clearQueryCache] is true before the insert /// the list of matching rows will be deleted Future updateChannelQueries( - Map filter, + Filter? filter, List cids, { bool clearQueryCache = false, }); @@ -180,8 +187,11 @@ abstract class ChatPersistenceClient { .map((m) => m.id) .toList(growable: false)); + final cleanedChannelStates = + channelStates.where((it) => it.channel != null); + final deleteMembers = deleteMembersByCids( - channelStates.map((it) => it.channel.cid).toList(growable: false), + cleanedChannelStates.map((it) => it.channel!.cid).toList(growable: false), ); await Future.wait([ @@ -189,58 +199,57 @@ abstract class ChatPersistenceClient { deleteMembers, ]); - final channels = - channelStates.map((it) => it.channel).where((it) => it != null); + final channels = cleanedChannelStates.map((it) => it.channel).withNullifyer; - final reactions = channelStates + final reactions = cleanedChannelStates .expand((it) => it.messages) .expand((it) => [ if (it.ownReactions != null) - ...it.ownReactions.where((r) => r.userId != null), + ...it.ownReactions!.where((r) => r.userId != null), if (it.latestReactions != null) - ...it.latestReactions.where((r) => r.userId != null) + ...it.latestReactions!.where((r) => r.userId != null), ]) - .where((it) => it != null); + .withNullifyer; - final users = channelStates + final users = cleanedChannelStates .map((cs) => [ cs.channel?.createdBy, ...cs.messages - ?.map((m) => [ + .map((m) => [ m.user, if (m.latestReactions != null) - ...m.latestReactions.map((r) => r.user), + ...m.latestReactions!.map((r) => r.user), if (m.ownReactions != null) - ...m.ownReactions.map((r) => r.user), + ...m.ownReactions!.map((r) => r.user), ]) - ?.expand((v) => v), - if (cs.read != null) ...cs.read.map((r) => r.user), - if (cs.members != null) ...cs.members.map((m) => m.user), + .expand((v) => v), + ...cs.read.map((r) => r.user), + ...cs.members.map((m) => m.user), ]) .expand((it) => it) - .where((it) => it != null); + .withNullifyer; - final updateMessagesFuture = channelStates.map((it) { - final cid = it.channel.cid; - final messages = it.messages.where((it) => it != null); + final updateMessagesFuture = cleanedChannelStates.map((it) { + final cid = it.channel!.cid; + final messages = it.messages; return updateMessages(cid, messages.toList(growable: false)); }).toList(growable: false); - final updatePinnedMessagesFuture = channelStates.map((it) { - final cid = it.channel.cid; - final messages = it.pinnedMessages.where((it) => it != null); + final updatePinnedMessagesFuture = cleanedChannelStates.map((it) { + final cid = it.channel!.cid; + final messages = it.pinnedMessages; return updatePinnedMessages(cid, messages.toList(growable: false)); }).toList(growable: false); - final updateReadsFuture = channelStates.map((it) { - final cid = it.channel.cid; - final reads = it.read?.where((it) => it != null) ?? []; + final updateReadsFuture = cleanedChannelStates.map((it) { + final cid = it.channel!.cid; + final reads = it.read; return updateReads(cid, reads.toList(growable: false)); }).toList(growable: false); - final updateMembersFuture = channelStates.map((it) { - final cid = it.channel.cid; - final members = it.members.where((it) => it != null); + final updateMembersFuture = cleanedChannelStates.map((it) { + final cid = it.channel!.cid; + final members = it.members; return updateMembers(cid, members.toList(growable: false)); }).toList(growable: false); diff --git a/packages/stream_chat/lib/src/event_type.dart b/packages/stream_chat/lib/src/event_type.dart index fd93af2a..87529399 100644 --- a/packages/stream_chat/lib/src/event_type.dart +++ b/packages/stream_chat/lib/src/event_type.dart @@ -3,6 +3,9 @@ class EventType { /// Indicates any type of events static const String any = '*'; + /// + static const String healthCheck = 'health.check'; + /// Event sent when a user starts typing a message static const String typingStart = 'typing.start'; diff --git a/packages/stream_chat/lib/src/exceptions.dart b/packages/stream_chat/lib/src/exceptions.dart deleted file mode 100644 index 60dd6bdf..00000000 --- a/packages/stream_chat/lib/src/exceptions.dart +++ /dev/null @@ -1,53 +0,0 @@ -import 'dart:convert'; - -/// Exception related to api calls -class ApiError extends Error { - /// Creates a new ApiError instance using the response body and status code - ApiError(this.body, this.status) : jsonData = _decode(body) { - if (jsonData != null && jsonData.containsKey('code')) { - _code = jsonData['code']; - } - } - - /// Raw body of the response - final String body; - - /// Json parsed body - final Map jsonData; - - /// Http status code of the response - final int status; - - /// Stream specific error code - int get code => _code; - int _code; - - static Map _decode(String body) { - try { - if (body == null) { - return null; - } - return json.decode(body); - } on FormatException { - return null; - } - } - - @override - bool operator ==(Object other) => - identical(this, other) || - other is ApiError && - runtimeType == other.runtimeType && - body == other.body && - jsonData == other.jsonData && - status == other.status && - _code == other._code; - - @override - int get hashCode => - body.hashCode ^ jsonData.hashCode ^ status.hashCode ^ _code.hashCode; - - @override - String toString() => 'ApiError{body: $body, jsonData: $jsonData, ' - 'status: $status, code: $_code}'; -} diff --git a/packages/stream_chat/lib/src/extensions/map_extension.dart b/packages/stream_chat/lib/src/extensions/map_extension.dart deleted file mode 100644 index 1a3376ad..00000000 --- a/packages/stream_chat/lib/src/extensions/map_extension.dart +++ /dev/null @@ -1,6 +0,0 @@ -/// Useful extension functions for [Map] -extension MapX on Map { - /// Returns a new map with null keys or values removed - Map get nullProtected => - {...this}..removeWhere((key, value) => key == null || value == null); -} diff --git a/packages/stream_chat/lib/src/extensions/rate_limit.dart b/packages/stream_chat/lib/src/extensions/rate_limit.dart deleted file mode 100644 index c9f5934f..00000000 --- a/packages/stream_chat/lib/src/extensions/rate_limit.dart +++ /dev/null @@ -1,335 +0,0 @@ -// ignore_for_file: lines_longer_than_80_chars - -import 'dart:async' show Timer; -import 'dart:math' as math; - -/// Useful rate limiter extensions for [Function] class. -extension RateLimit on Function { - /// Converts this into a [Debounce] function. - Debounce debounced( - Duration wait, { - bool leading = false, - bool trailing = true, - Duration maxWait, - }) => - Debounce( - this, - wait, - leading: leading, - trailing: trailing, - maxWait: maxWait, - ); - - /// Converts this into a [Throttle] function. - Throttle throttled( - Duration wait, { - bool leading = true, - bool trailing = true, - }) => - Throttle( - this, - wait, - leading: leading, - trailing: trailing, - ); -} - -/// TopLevel lambda to create [Debounce] functions. -Debounce debounce( - Function func, - Duration wait, { - bool leading = false, - bool trailing = true, - Duration maxWait, -}) => - Debounce( - func, - wait, - leading: leading, - trailing: trailing, - maxWait: maxWait, - ); - -/// TopLevel lambda to create [Throttle] functions. -Throttle throttle( - Function func, - Duration wait, { - bool leading = true, - bool trailing = true, -}) => - Throttle( - func, - wait, - leading: leading, - trailing: trailing, - ); - -/// Creates a debounced function that delays invoking `func` until after `wait` -/// milliseconds have elapsed since the last time the debounced function was -/// invoked. The debounced function comes with a [Debounce.cancel] method to cancel -/// delayed `func` invocations and a [Debounce.flush] method to immediately invoke them. -/// Provide `leading` and/or `trailing` to indicate whether `func` should be -/// invoked on the `leading` and/or `trailing` edge of the `wait` interval. -/// The `func` is invoked with the last arguments provided to the [call] -/// function. Subsequent calls to the debounced function return the result of -/// the last `func` invocation. -/// -/// **Note:** If `leading` and `trailing` options are `true`, `func` is -/// invoked on the trailing edge of the timeout only if the debounced function -/// is invoked more than once during the `wait` timeout. -/// -/// If `wait` is [Duration.zero] and `leading` is `false`, -/// `func` invocation is deferred until the next tick. -/// -/// See [David Corbacho's article](https://css-tricks.com/debouncing-throttling-explained-examples/) -/// for details over the differences between [Debounce] and [Throttle]. -/// -/// Some examples: -/// -/// Avoid calling costly network calls when user is typing something. -/// ```dart -/// void fetchData(String query) async { -/// final data = api.getData(query); -/// doSomethingWithTheData(data); -/// } -/// -/// final debouncedFetchData = Debounce( -/// fetchData, -/// const Duration(milliseconds: 350), -/// ); -/// -/// void onSearchQueryChanged(query) { -/// debouncedFetchData(query); -/// } -/// ``` -/// -/// Cancel the trailing debounced invocation. -/// ```dart -/// void dispose() { -/// debounced.cancel(); -/// } -/// ``` -/// -/// Check for pending invocations. -/// ```dart -/// final status = debounced.isPending ? "Pending..." : "Ready"; -/// ``` -class Debounce { - /// Creates a new instance of [Debounce]. - Debounce( - this._func, - Duration wait, { - bool leading = false, - bool trailing = true, - Duration maxWait, - }) : _leading = leading, - _trailing = trailing, - _wait = wait?.inMilliseconds ?? 0, - _maxing = maxWait != null { - if (_maxing) { - _maxWait = math.max(maxWait.inMilliseconds, _wait); - } - } - - final Function _func; - final bool _leading; - final bool _trailing; - final int _wait; - final bool _maxing; - - int _maxWait; - List _lastArgs; - Map _lastNamedArgs; - Timer _timer; - int _lastCallTime; - Object _result; - int _lastInvokeTime = 0; - - Object _invokeFunc(int time) { - final args = _lastArgs; - final namedArgs = _lastNamedArgs; - _lastArgs = _lastNamedArgs = null; - _lastInvokeTime = time; - return _result = Function.apply(_func, args, namedArgs); - } - - Timer _startTimer(Function pendingFunc, int wait) => - Timer(Duration(milliseconds: wait), pendingFunc); - - bool _shouldInvoke(int time) { - final timeSinceLastCall = time - (_lastCallTime ?? double.nan); - final timeSinceLastInvoke = time - _lastInvokeTime; - - // Either this is the first call, activity has stopped and we're at the - // trailing edge, the system time has gone backwards and we're treating - // it as the trailing edge, or we've hit the `maxWait` limit. - return _lastCallTime == null || - (timeSinceLastCall >= _wait) || - (timeSinceLastCall < 0) || - (_maxing && timeSinceLastInvoke >= _maxWait); - } - - Object _trailingEdge(int time) { - _timer = null; - - // Only invoke if we have `lastArgs` which means `func` has been - // debounced at least once. - if (_trailing && _lastArgs != null) { - return _invokeFunc(time); - } - _lastArgs = _lastNamedArgs = null; - return _result; - } - - int _remainingWait(int time) { - final timeSinceLastCall = time - _lastCallTime; - final timeSinceLastInvoke = time - _lastInvokeTime; - final timeWaiting = _wait - timeSinceLastCall; - - return _maxing - ? math.min(timeWaiting, _maxWait - timeSinceLastInvoke) - : timeWaiting; - } - - void _timerExpired() { - final time = DateTime.now().millisecondsSinceEpoch; - if (_shouldInvoke(time)) { - _trailingEdge(time); - } else { - // Restart the timer. - _timer = _startTimer(_timerExpired, _remainingWait(time)); - } - } - - Object _leadingEdge(int time) { - // Reset any `maxWait` timer. - _lastInvokeTime = time; - // Start the timer for the trailing edge. - _timer = _startTimer(_timerExpired, _wait); - // Invoke the leading edge. - return _leading ? _invokeFunc(time) : _result; - } - - /// Cancels all the remaining delayed functions. - void cancel() { - _timer?.cancel(); - _lastInvokeTime = 0; - _lastArgs = _lastNamedArgs = _lastCallTime = _timer = null; - } - - /// Immediately invokes all the remaining delayed functions. - Object flush() { - final now = DateTime.now().millisecondsSinceEpoch; - return _timer == null ? _result : _trailingEdge(now); - } - - /// True if there are functions remaining to get invoked. - bool get isPending => _timer != null; - - /// Calls/invokes this class like a function. - /// Pass [args] and [namedArgs] to be used while invoking [_func]. - Object call( - List args, { - Map namedArgs, - }) { - final time = DateTime.now().millisecondsSinceEpoch; - final isInvoking = _shouldInvoke(time); - - _lastArgs = args; - _lastNamedArgs = namedArgs; - _lastCallTime = time; - - if (isInvoking) { - if (_timer == null) { - return _leadingEdge(_lastCallTime); - } - if (_maxing) { - // Handle invocations in a tight loop. - _timer = _startTimer(_timerExpired, _wait); - return _invokeFunc(_lastCallTime); - } - } - _timer ??= _startTimer(_timerExpired, _wait); - return _result; - } -} - -/// Creates a throttled function that only invokes `func` at most once per -/// every `wait` milliseconds. The throttled function comes with a [Throttle.cancel] -/// method to cancel delayed `func` invocations and a [Throttle.flush] method to -/// immediately invoke them. Provide `leading` and/or `trailing` to indicate -/// whether `func` should be invoked on the `leading` and/or `trailing` edge of the `wait` timeout. -/// The `func` is invoked with the last arguments provided to the -/// throttled function. Subsequent calls to the throttled function return the -/// result of the last `func` invocation. -/// -/// **Note:** If `leading` and `trailing` options are `true`, `func` is -/// invoked on the trailing edge of the timeout only if the throttled function -/// is invoked more than once during the `wait` timeout. -/// -/// If `wait` is [Duration.zero] and `leading` is `false`, `func` invocation is deferred -/// until the next tick. -/// -/// See [David Corbacho's article](https://css-tricks.com/debouncing-throttling-explained-examples/) -/// for details over the differences between [Throttle] and [Debounce]. -/// -/// Some examples: -/// -/// Avoid excessively rebuilding UI progress while uploading data to server. -/// ```dart -/// void updateUI(Data data) { -/// updateProgress(data); -/// } -/// -/// final throttledUpdateUI = Throttle( -/// updateUI, -/// const Duration(milliseconds: 350), -/// ); -/// -/// void onUploadProgressChanged(progress) { -/// throttledUpdateUI(progress); -/// } -/// ``` -/// -/// Cancel the trailing throttled invocation. -/// ```dart -/// void dispose() { -/// throttled.cancel(); -/// } -/// ``` -/// -/// Check for pending invocations. -/// ```dart -/// final status = throttled.isPending ? "Pending..." : "Ready"; -/// ``` -class Throttle { - /// Creates a new instance of [Throttle] - Throttle( - Function func, - Duration wait, { - bool leading = true, - bool trailing = true, - }) : _debounce = Debounce( - func, - wait, - leading: leading, - trailing: trailing, - maxWait: wait, - ); - - final Debounce _debounce; - - /// Cancels all the remaining delayed functions. - void cancel() => _debounce.cancel(); - - /// Immediately invokes all the remaining delayed functions. - Object flush() => _debounce.flush(); - - /// True if there are functions remaining to get invoked. - bool get isPending => _debounce.isPending; - - /// Calls/invokes this class like a function. - /// Pass [args] and [namedArgs] to be used while invoking `func`. - Object call(List args, {Map namedArgs}) => - _debounce.call(args, namedArgs: namedArgs); -} diff --git a/packages/stream_chat/lib/src/extensions/string_extension.dart b/packages/stream_chat/lib/src/extensions/string_extension.dart deleted file mode 100644 index 49949412..00000000 --- a/packages/stream_chat/lib/src/extensions/string_extension.dart +++ /dev/null @@ -1,15 +0,0 @@ -import 'package:http_parser/http_parser.dart' as http_parser; -import 'package:mime/mime.dart'; - -/// Useful extension functions for [String] -extension StringX on String { - /// Returns the mime type from the passed file name. - http_parser.MediaType get mimeType { - if (this == null) return null; - if (toLowerCase().endsWith('heic')) { - return http_parser.MediaType.parse('image/heic'); - } else { - return http_parser.MediaType.parse(lookupMimeType(this)); - } - } -} diff --git a/packages/stream_chat/lib/src/location.dart b/packages/stream_chat/lib/src/location.dart new file mode 100644 index 00000000..86813a0a --- /dev/null +++ b/packages/stream_chat/lib/src/location.dart @@ -0,0 +1,29 @@ +/// +enum Location { + /// + usEast, + + /// + euWest, + + /// + mumbai, + + /// + sydney, + + /// + singapore, +} + +/// +extension LocationX on Location { + /// + String get name => { + Location.usEast: 'us-east', + Location.euWest: 'dublin', + Location.mumbai: 'mumbai', + Location.sydney: 'sydney', + Location.singapore: 'singapore', + }[this]!; +} diff --git a/packages/stream_chat/lib/src/models/attachment.g.dart b/packages/stream_chat/lib/src/models/attachment.g.dart deleted file mode 100644 index c45771c2..00000000 --- a/packages/stream_chat/lib/src/models/attachment.g.dart +++ /dev/null @@ -1,84 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND - -part of 'attachment.dart'; - -// ************************************************************************** -// JsonSerializableGenerator -// ************************************************************************** - -Attachment _$AttachmentFromJson(Map json) { - return Attachment( - id: json['id'] as String, - type: json['type'] as String, - titleLink: json['title_link'] as String, - title: json['title'] as String, - thumbUrl: json['thumb_url'] as String, - text: json['text'] as String, - pretext: json['pretext'] as String, - ogScrapeUrl: json['og_scrape_url'] as String, - imageUrl: json['image_url'] as String, - footerIcon: json['footer_icon'] as String, - footer: json['footer'] as String, - fields: json['fields'], - fallback: json['fallback'] as String, - color: json['color'] as String, - authorName: json['author_name'] as String, - authorLink: json['author_link'] as String, - authorIcon: json['author_icon'] as String, - assetUrl: json['asset_url'] as String, - actions: (json['actions'] as List) - ?.map((e) => e == null - ? null - : Action.fromJson((e as Map)?.map( - (k, e) => MapEntry(k as String, e), - ))) - ?.toList(), - extraData: (json['extra_data'] as Map)?.map( - (k, e) => MapEntry(k as String, e), - ), - file: json['file'] == null - ? null - : AttachmentFile.fromJson((json['file'] as Map)?.map( - (k, e) => MapEntry(k as String, e), - )), - uploadState: json['upload_state'] == null - ? null - : UploadState.fromJson((json['upload_state'] as Map)?.map( - (k, e) => MapEntry(k as String, e), - )), - ); -} - -Map _$AttachmentToJson(Attachment instance) { - final val = {}; - - void writeNotNull(String key, dynamic value) { - if (value != null) { - val[key] = value; - } - } - - writeNotNull('type', instance.type); - writeNotNull('title_link', instance.titleLink); - writeNotNull('title', instance.title); - writeNotNull('thumb_url', instance.thumbUrl); - writeNotNull('text', instance.text); - writeNotNull('pretext', instance.pretext); - writeNotNull('og_scrape_url', instance.ogScrapeUrl); - writeNotNull('image_url', instance.imageUrl); - writeNotNull('footer_icon', instance.footerIcon); - writeNotNull('footer', instance.footer); - writeNotNull('fields', instance.fields); - writeNotNull('fallback', instance.fallback); - writeNotNull('color', instance.color); - writeNotNull('author_name', instance.authorName); - writeNotNull('author_link', instance.authorLink); - writeNotNull('author_icon', instance.authorIcon); - writeNotNull('asset_url', instance.assetUrl); - writeNotNull('actions', instance.actions?.map((e) => e?.toJson())?.toList()); - writeNotNull('file', instance.file?.toJson()); - writeNotNull('upload_state', instance.uploadState?.toJson()); - writeNotNull('extra_data', instance.extraData); - writeNotNull('id', instance.id); - return val; -} diff --git a/packages/stream_chat/lib/src/models/channel_state.g.dart b/packages/stream_chat/lib/src/models/channel_state.g.dart deleted file mode 100644 index a66899a4..00000000 --- a/packages/stream_chat/lib/src/models/channel_state.g.dart +++ /dev/null @@ -1,65 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND - -part of 'channel_state.dart'; - -// ************************************************************************** -// JsonSerializableGenerator -// ************************************************************************** - -ChannelState _$ChannelStateFromJson(Map json) { - return ChannelState( - channel: json['channel'] == null - ? null - : ChannelModel.fromJson((json['channel'] as Map)?.map( - (k, e) => MapEntry(k as String, e), - )), - messages: (json['messages'] as List) - ?.map((e) => e == null - ? null - : Message.fromJson((e as Map)?.map( - (k, e) => MapEntry(k as String, e), - ))) - ?.toList(), - members: (json['members'] as List) - ?.map((e) => e == null - ? null - : Member.fromJson((e as Map)?.map( - (k, e) => MapEntry(k as String, e), - ))) - ?.toList(), - pinnedMessages: (json['pinned_messages'] as List) - ?.map((e) => e == null - ? null - : Message.fromJson((e as Map)?.map( - (k, e) => MapEntry(k as String, e), - ))) - ?.toList(), - watcherCount: json['watcher_count'] as int, - watchers: (json['watchers'] as List) - ?.map((e) => e == null - ? null - : User.fromJson((e as Map)?.map( - (k, e) => MapEntry(k as String, e), - ))) - ?.toList(), - read: (json['read'] as List) - ?.map((e) => e == null - ? null - : Read.fromJson((e as Map)?.map( - (k, e) => MapEntry(k as String, e), - ))) - ?.toList(), - ); -} - -Map _$ChannelStateToJson(ChannelState instance) => - { - 'channel': instance.channel?.toJson(), - 'messages': instance.messages?.map((e) => e?.toJson())?.toList(), - 'members': instance.members?.map((e) => e?.toJson())?.toList(), - 'pinned_messages': - instance.pinnedMessages?.map((e) => e?.toJson())?.toList(), - 'watcher_count': instance.watcherCount, - 'watchers': instance.watchers?.map((e) => e?.toJson())?.toList(), - 'read': instance.read?.map((e) => e?.toJson())?.toList(), - }; diff --git a/packages/stream_chat/lib/src/models/event.g.dart b/packages/stream_chat/lib/src/models/event.g.dart deleted file mode 100644 index aaef8181..00000000 --- a/packages/stream_chat/lib/src/models/event.g.dart +++ /dev/null @@ -1,156 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND - -part of 'event.dart'; - -// ************************************************************************** -// JsonSerializableGenerator -// ************************************************************************** - -Event _$EventFromJson(Map json) { - return Event( - type: json['type'] as String, - cid: json['cid'] as String, - connectionId: json['connection_id'] as String, - createdAt: json['created_at'] == null - ? null - : DateTime.parse(json['created_at'] as String), - me: json['me'] == null - ? null - : OwnUser.fromJson((json['me'] as Map)?.map( - (k, e) => MapEntry(k as String, e), - )), - user: json['user'] == null - ? null - : User.fromJson((json['user'] as Map)?.map( - (k, e) => MapEntry(k as String, e), - )), - message: json['message'] == null - ? null - : Message.fromJson((json['message'] as Map)?.map( - (k, e) => MapEntry(k as String, e), - )), - totalUnreadCount: json['total_unread_count'] as int, - unreadChannels: json['unread_channels'] as int, - reaction: json['reaction'] == null - ? null - : Reaction.fromJson((json['reaction'] as Map)?.map( - (k, e) => MapEntry(k as String, e), - )), - online: json['online'] as bool, - channel: json['channel'] == null - ? null - : EventChannel.fromJson((json['channel'] as Map)?.map( - (k, e) => MapEntry(k as String, e), - )), - member: json['member'] == null - ? null - : Member.fromJson((json['member'] as Map)?.map( - (k, e) => MapEntry(k as String, e), - )), - channelId: json['channel_id'] as String, - channelType: json['channel_type'] as String, - parentId: json['parent_id'] as String, - extraData: (json['extra_data'] as Map)?.map( - (k, e) => MapEntry(k as String, e), - ), - )..isLocal = json['is_local'] as bool; -} - -Map _$EventToJson(Event instance) { - final val = { - 'type': instance.type, - 'cid': instance.cid, - 'channel_id': instance.channelId, - 'channel_type': instance.channelType, - 'connection_id': instance.connectionId, - 'created_at': instance.createdAt?.toIso8601String(), - 'me': instance.me?.toJson(), - 'user': instance.user?.toJson(), - 'message': instance.message?.toJson(), - 'channel': instance.channel?.toJson(), - 'member': instance.member?.toJson(), - 'reaction': instance.reaction?.toJson(), - 'total_unread_count': instance.totalUnreadCount, - 'unread_channels': instance.unreadChannels, - 'online': instance.online, - 'parent_id': instance.parentId, - 'is_local': instance.isLocal, - }; - - void writeNotNull(String key, dynamic value) { - if (value != null) { - val[key] = value; - } - } - - writeNotNull('extra_data', instance.extraData); - return val; -} - -EventChannel _$EventChannelFromJson(Map json) { - return EventChannel( - members: (json['members'] as List) - ?.map((e) => e == null - ? null - : Member.fromJson((e as Map)?.map( - (k, e) => MapEntry(k as String, e), - ))) - ?.toList(), - id: json['id'] as String, - type: json['type'] as String, - cid: json['cid'] as String, - config: json['config'] == null - ? null - : ChannelConfig.fromJson((json['config'] as Map)?.map( - (k, e) => MapEntry(k as String, e), - )), - createdBy: json['created_by'] == null - ? null - : User.fromJson((json['created_by'] as Map)?.map( - (k, e) => MapEntry(k as String, e), - )), - frozen: json['frozen'] as bool, - lastMessageAt: json['last_message_at'] == null - ? null - : DateTime.parse(json['last_message_at'] as String), - createdAt: json['created_at'] == null - ? null - : DateTime.parse(json['created_at'] as String), - updatedAt: json['updated_at'] == null - ? null - : DateTime.parse(json['updated_at'] as String), - deletedAt: json['deleted_at'] == null - ? null - : DateTime.parse(json['deleted_at'] as String), - memberCount: json['member_count'] as int, - extraData: (json['extra_data'] as Map)?.map( - (k, e) => MapEntry(k as String, e), - ), - ); -} - -Map _$EventChannelToJson(EventChannel instance) { - final val = { - 'id': instance.id, - 'type': instance.type, - }; - - void writeNotNull(String key, dynamic value) { - if (value != null) { - val[key] = value; - } - } - - writeNotNull('cid', readonly(instance.cid)); - writeNotNull('config', readonly(instance.config)); - writeNotNull('created_by', readonly(instance.createdBy)); - writeNotNull('frozen', instance.frozen); - writeNotNull('last_message_at', readonly(instance.lastMessageAt)); - writeNotNull('created_at', readonly(instance.createdAt)); - writeNotNull('updated_at', readonly(instance.updatedAt)); - writeNotNull('deleted_at', readonly(instance.deletedAt)); - writeNotNull('member_count', readonly(instance.memberCount)); - writeNotNull('extra_data', instance.extraData); - val['members'] = instance.members?.map((e) => e?.toJson())?.toList(); - return val; -} diff --git a/packages/stream_chat/lib/src/models/message.g.dart b/packages/stream_chat/lib/src/models/message.g.dart deleted file mode 100644 index 80df1589..00000000 --- a/packages/stream_chat/lib/src/models/message.g.dart +++ /dev/null @@ -1,153 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND - -part of 'message.dart'; - -// ************************************************************************** -// JsonSerializableGenerator -// ************************************************************************** - -Message _$MessageFromJson(Map json) { - return Message( - id: json['id'] as String, - text: json['text'] as String, - type: json['type'] as String, - attachments: (json['attachments'] as List) - ?.map((e) => e == null - ? null - : Attachment.fromJson((e as Map)?.map( - (k, e) => MapEntry(k as String, e), - ))) - ?.toList(), - mentionedUsers: (json['mentioned_users'] as List) - ?.map((e) => e == null - ? null - : User.fromJson((e as Map)?.map( - (k, e) => MapEntry(k as String, e), - ))) - ?.toList(), - silent: json['silent'] as bool, - shadowed: json['shadowed'] as bool, - reactionCounts: (json['reaction_counts'] as Map)?.map( - (k, e) => MapEntry(k as String, e as int), - ), - reactionScores: (json['reaction_scores'] as Map)?.map( - (k, e) => MapEntry(k as String, e as int), - ), - latestReactions: (json['latest_reactions'] as List) - ?.map((e) => e == null - ? null - : Reaction.fromJson((e as Map)?.map( - (k, e) => MapEntry(k as String, e), - ))) - ?.toList(), - ownReactions: (json['own_reactions'] as List) - ?.map((e) => e == null - ? null - : Reaction.fromJson((e as Map)?.map( - (k, e) => MapEntry(k as String, e), - ))) - ?.toList(), - parentId: json['parent_id'] as String, - quotedMessage: json['quoted_message'] == null - ? null - : Message.fromJson((json['quoted_message'] as Map)?.map( - (k, e) => MapEntry(k as String, e), - )), - quotedMessageId: json['quoted_message_id'] as String, - replyCount: json['reply_count'] as int, - threadParticipants: (json['thread_participants'] as List) - ?.map((e) => e == null - ? null - : User.fromJson((e as Map)?.map( - (k, e) => MapEntry(k as String, e), - ))) - ?.toList(), - showInChannel: json['show_in_channel'] as bool, - command: json['command'] as String, - createdAt: json['created_at'] == null - ? null - : DateTime.parse(json['created_at'] as String), - updatedAt: json['updated_at'] == null - ? null - : DateTime.parse(json['updated_at'] as String), - user: json['user'] == null - ? null - : User.fromJson((json['user'] as Map)?.map( - (k, e) => MapEntry(k as String, e), - )), - pinned: json['pinned'] as bool, - pinnedAt: json['pinned_at'] == null - ? null - : DateTime.parse(json['pinned_at'] as String), - pinExpires: json['pin_expires'] == null - ? null - : DateTime.parse(json['pin_expires'] as String), - pinnedBy: json['pinned_by'] == null - ? null - : User.fromJson((json['pinned_by'] as Map)?.map( - (k, e) => MapEntry(k as String, e), - )), - extraData: (json['extra_data'] as Map)?.map( - (k, e) => MapEntry(k as String, e), - ), - deletedAt: json['deleted_at'] == null - ? null - : DateTime.parse(json['deleted_at'] as String), - skipPush: json['skip_push'] as bool, - ); -} - -Map _$MessageToJson(Message instance) { - final val = { - 'id': instance.id, - 'text': instance.text, - }; - - void writeNotNull(String key, dynamic value) { - if (value != null) { - val[key] = value; - } - } - - writeNotNull('type', readonly(instance.type)); - writeNotNull( - 'attachments', instance.attachments?.map((e) => e?.toJson())?.toList()); - val['mentioned_users'] = Serialization.userIds(instance.mentionedUsers); - writeNotNull('reaction_counts', readonly(instance.reactionCounts)); - writeNotNull('reaction_scores', readonly(instance.reactionScores)); - writeNotNull('latest_reactions', readonly(instance.latestReactions)); - writeNotNull('own_reactions', readonly(instance.ownReactions)); - val['parent_id'] = instance.parentId; - val['quoted_message'] = readonly(instance.quotedMessage); - val['quoted_message_id'] = instance.quotedMessageId; - writeNotNull('reply_count', readonly(instance.replyCount)); - writeNotNull('thread_participants', readonly(instance.threadParticipants)); - val['show_in_channel'] = instance.showInChannel; - val['silent'] = instance.silent; - val['skip_push'] = instance.skipPush; - writeNotNull('shadowed', readonly(instance.shadowed)); - writeNotNull('command', readonly(instance.command)); - writeNotNull('created_at', readonly(instance.createdAt)); - writeNotNull('updated_at', readonly(instance.updatedAt)); - writeNotNull('user', readonly(instance.user)); - val['pinned'] = instance.pinned; - val['pinned_at'] = readonly(instance.pinnedAt); - val['pin_expires'] = instance.pinExpires?.toIso8601String(); - val['pinned_by'] = readonly(instance.pinnedBy); - writeNotNull('extra_data', instance.extraData); - writeNotNull('deleted_at', readonly(instance.deletedAt)); - return val; -} - -TranslatedMessage _$TranslatedMessageFromJson(Map json) { - return TranslatedMessage( - (json['i18n'] as Map)?.map( - (k, e) => MapEntry(k as String, e as String), - ), - ); -} - -Map _$TranslatedMessageToJson(TranslatedMessage instance) => - { - 'i18n': instance.i18n, - }; diff --git a/packages/stream_chat/lib/src/models/mute.dart b/packages/stream_chat/lib/src/models/mute.dart deleted file mode 100644 index e3d5e1a0..00000000 --- a/packages/stream_chat/lib/src/models/mute.dart +++ /dev/null @@ -1,35 +0,0 @@ -import 'package:json_annotation/json_annotation.dart'; -import 'package:stream_chat/src/models/channel_model.dart'; -import 'package:stream_chat/src/models/serialization.dart'; -import 'package:stream_chat/src/models/user.dart'; - -part 'mute.g.dart'; - -/// The class that contains the information about a muted user -@JsonSerializable() -class Mute { - /// Constructor used for json serialization - Mute({this.user, this.channel, this.createdAt, this.updatedAt}); - - /// Create a new instance from a json - factory Mute.fromJson(Map json) => _$MuteFromJson(json); - - /// The user that performed the muting action - @JsonKey(includeIfNull: false, toJson: Serialization.readOnly) - final User user; - - /// The target user - @JsonKey(includeIfNull: false, toJson: Serialization.readOnly) - final ChannelModel channel; - - /// The date in which the use was muted - @JsonKey(includeIfNull: false, toJson: Serialization.readOnly) - final DateTime createdAt; - - /// The date of the last update - @JsonKey(includeIfNull: false, toJson: Serialization.readOnly) - final DateTime updatedAt; - - /// Serialize to json - Map toJson() => _$MuteToJson(this); -} diff --git a/packages/stream_chat/lib/src/models/mute.g.dart b/packages/stream_chat/lib/src/models/mute.g.dart deleted file mode 100644 index 9d0b9318..00000000 --- a/packages/stream_chat/lib/src/models/mute.g.dart +++ /dev/null @@ -1,44 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND - -part of 'mute.dart'; - -// ************************************************************************** -// JsonSerializableGenerator -// ************************************************************************** - -Mute _$MuteFromJson(Map json) { - return Mute( - user: json['user'] == null - ? null - : User.fromJson((json['user'] as Map)?.map( - (k, e) => MapEntry(k as String, e), - )), - channel: json['channel'] == null - ? null - : ChannelModel.fromJson((json['channel'] as Map)?.map( - (k, e) => MapEntry(k as String, e), - )), - createdAt: json['created_at'] == null - ? null - : DateTime.parse(json['created_at'] as String), - updatedAt: json['updated_at'] == null - ? null - : DateTime.parse(json['updated_at'] as String), - ); -} - -Map _$MuteToJson(Mute instance) { - final val = {}; - - void writeNotNull(String key, dynamic value) { - if (value != null) { - val[key] = value; - } - } - - writeNotNull('user', readonly(instance.user)); - writeNotNull('channel', readonly(instance.channel)); - writeNotNull('created_at', readonly(instance.createdAt)); - writeNotNull('updated_at', readonly(instance.updatedAt)); - return val; -} diff --git a/packages/stream_chat/lib/src/models/own_user.dart b/packages/stream_chat/lib/src/models/own_user.dart deleted file mode 100644 index 18ee2abf..00000000 --- a/packages/stream_chat/lib/src/models/own_user.dart +++ /dev/null @@ -1,78 +0,0 @@ -import 'package:json_annotation/json_annotation.dart'; -import 'package:stream_chat/src/models/device.dart'; -import 'package:stream_chat/src/models/mute.dart'; -import 'package:stream_chat/src/models/serialization.dart'; -import 'package:stream_chat/src/models/user.dart'; - -part 'own_user.g.dart'; - -/// The class that defines the own user model -/// This object can be found in [Event] -@JsonSerializable() -class OwnUser extends User { - /// Constructor used for json serialization - OwnUser({ - this.devices, - this.mutes, - this.totalUnreadCount, - this.unreadChannels, - this.channelMutes, - String id, - String role, - DateTime createdAt, - DateTime updatedAt, - DateTime lastActive, - bool online, - Map extraData, - bool banned, - }) : super( - id: id, - role: role, - createdAt: createdAt, - updatedAt: updatedAt, - lastActive: lastActive, - online: online, - extraData: extraData, - banned: banned, - ); - - /// Create a new instance from a json - factory OwnUser.fromJson(Map json) => _$OwnUserFromJson( - Serialization.moveToExtraDataFromRoot(json, topLevelFields)); - - /// List of user devices - @JsonKey(includeIfNull: false, toJson: Serialization.readOnly) - final List devices; - - /// List of users muted by the user - @JsonKey(includeIfNull: false, toJson: Serialization.readOnly) - final List mutes; - - /// List of users muted by the user - @JsonKey(includeIfNull: false, toJson: Serialization.readOnly) - final List channelMutes; - - /// Total unread messages by the user - @JsonKey(includeIfNull: false, toJson: Serialization.readOnly) - final int totalUnreadCount; - - /// Total unread channels by the user - @JsonKey(includeIfNull: false, toJson: Serialization.readOnly) - final int unreadChannels; - - /// Known top level fields. - /// Useful for [Serialization] methods. - static final topLevelFields = [ - 'devices', - 'mutes', - 'total_unread_count', - 'unread_channels', - 'channel_mutes', - ...User.topLevelFields, - ]; - - /// Serialize to json - @override - Map toJson() => Serialization.moveFromExtraDataToRoot( - _$OwnUserToJson(this), topLevelFields); -} diff --git a/packages/stream_chat/lib/src/models/own_user.g.dart b/packages/stream_chat/lib/src/models/own_user.g.dart deleted file mode 100644 index 887e6b28..00000000 --- a/packages/stream_chat/lib/src/models/own_user.g.dart +++ /dev/null @@ -1,77 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND - -part of 'own_user.dart'; - -// ************************************************************************** -// JsonSerializableGenerator -// ************************************************************************** - -OwnUser _$OwnUserFromJson(Map json) { - return OwnUser( - devices: (json['devices'] as List) - ?.map((e) => e == null - ? null - : Device.fromJson((e as Map)?.map( - (k, e) => MapEntry(k as String, e), - ))) - ?.toList(), - mutes: (json['mutes'] as List) - ?.map((e) => e == null - ? null - : Mute.fromJson((e as Map)?.map( - (k, e) => MapEntry(k as String, e), - ))) - ?.toList(), - totalUnreadCount: json['total_unread_count'] as int, - unreadChannels: json['unread_channels'] as int, - channelMutes: (json['channel_mutes'] as List) - ?.map((e) => e == null - ? null - : Mute.fromJson((e as Map)?.map( - (k, e) => MapEntry(k as String, e), - ))) - ?.toList(), - id: json['id'] as String, - role: json['role'] as String, - createdAt: json['created_at'] == null - ? null - : DateTime.parse(json['created_at'] as String), - updatedAt: json['updated_at'] == null - ? null - : DateTime.parse(json['updated_at'] as String), - lastActive: json['last_active'] == null - ? null - : DateTime.parse(json['last_active'] as String), - online: json['online'] as bool, - extraData: (json['extra_data'] as Map)?.map( - (k, e) => MapEntry(k as String, e), - ), - banned: json['banned'] as bool, - ); -} - -Map _$OwnUserToJson(OwnUser instance) { - final val = { - 'id': instance.id, - }; - - void writeNotNull(String key, dynamic value) { - if (value != null) { - val[key] = value; - } - } - - writeNotNull('role', readonly(instance.role)); - writeNotNull('created_at', readonly(instance.createdAt)); - writeNotNull('updated_at', readonly(instance.updatedAt)); - writeNotNull('last_active', readonly(instance.lastActive)); - writeNotNull('online', readonly(instance.online)); - writeNotNull('banned', readonly(instance.banned)); - writeNotNull('extra_data', instance.extraData); - writeNotNull('devices', readonly(instance.devices)); - writeNotNull('mutes', readonly(instance.mutes)); - writeNotNull('channel_mutes', readonly(instance.channelMutes)); - writeNotNull('total_unread_count', readonly(instance.totalUnreadCount)); - writeNotNull('unread_channels', readonly(instance.unreadChannels)); - return val; -} diff --git a/packages/stream_chat/lib/src/models/reaction.dart b/packages/stream_chat/lib/src/models/reaction.dart deleted file mode 100644 index 6792bf3f..00000000 --- a/packages/stream_chat/lib/src/models/reaction.dart +++ /dev/null @@ -1,98 +0,0 @@ -import 'package:json_annotation/json_annotation.dart'; -import 'package:stream_chat/src/models/serialization.dart'; -import 'package:stream_chat/src/models/user.dart'; - -part 'reaction.g.dart'; - -/// The class that defines a reaction -@JsonSerializable() -class Reaction { - /// Constructor used for json serialization - Reaction({ - this.messageId, - this.createdAt, - this.type, - this.user, - String userId, - this.score, - this.extraData, - }) : userId = userId ?? user?.id; - - /// Create a new instance from a json - factory Reaction.fromJson(Map json) => _$ReactionFromJson( - Serialization.moveToExtraDataFromRoot(json, topLevelFields)); - - /// The messageId to which the reaction belongs - final String messageId; - - /// The type of the reaction - final String type; - - /// The date of the reaction - @JsonKey(includeIfNull: false, toJson: Serialization.readOnly) - final DateTime createdAt; - - /// The user that sent the reaction - @JsonKey(includeIfNull: false, toJson: Serialization.readOnly) - final User user; - - /// The score of the reaction (ie. number of reactions sent) - final int score; - - /// The userId that sent the reaction - @JsonKey(includeIfNull: false, toJson: Serialization.readOnly) - final String userId; - - /// Reaction custom extraData - @JsonKey(includeIfNull: false) - final Map extraData; - - /// Map of custom user extraData - static const topLevelFields = [ - 'message_id', - 'created_at', - 'type', - 'user', - 'user_id', - 'score', - ]; - - /// Serialize to json - Map toJson() => Serialization.moveFromExtraDataToRoot( - _$ReactionToJson(this), topLevelFields); - - /// Creates a copy of [Reaction] with specified attributes overridden. - Reaction copyWith({ - String messageId, - DateTime createdAt, - String type, - User user, - String userId, - int score, - Map extraData, - }) => - Reaction( - messageId: messageId ?? this.messageId, - createdAt: createdAt ?? this.createdAt, - type: type ?? this.type, - user: user ?? this.user, - userId: userId ?? this.userId, - score: score ?? this.score, - extraData: extraData ?? this.extraData, - ); - - /// Returns a new [Reaction] that is a combination of this reaction and the - /// given [other] reaction. - Reaction merge(Reaction other) { - if (other == null) return this; - return copyWith( - messageId: other.messageId, - createdAt: other.createdAt, - type: other.type, - user: other.user, - userId: other.userId, - score: other.score, - extraData: other.extraData, - ); - } -} diff --git a/packages/stream_chat/lib/src/models/read.g.dart b/packages/stream_chat/lib/src/models/read.g.dart deleted file mode 100644 index d04ae146..00000000 --- a/packages/stream_chat/lib/src/models/read.g.dart +++ /dev/null @@ -1,27 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND - -part of 'read.dart'; - -// ************************************************************************** -// JsonSerializableGenerator -// ************************************************************************** - -Read _$ReadFromJson(Map json) { - return Read( - lastRead: json['last_read'] == null - ? null - : DateTime.parse(json['last_read'] as String), - user: json['user'] == null - ? null - : User.fromJson((json['user'] as Map)?.map( - (k, e) => MapEntry(k as String, e), - )), - unreadMessages: json['unread_messages'] as int, - ); -} - -Map _$ReadToJson(Read instance) => { - 'last_read': instance.lastRead?.toIso8601String(), - 'user': instance.user?.toJson(), - 'unread_messages': instance.unreadMessages, - }; diff --git a/packages/stream_chat/lib/src/models/user.dart b/packages/stream_chat/lib/src/models/user.dart deleted file mode 100644 index 3b9f9add..00000000 --- a/packages/stream_chat/lib/src/models/user.dart +++ /dev/null @@ -1,127 +0,0 @@ -import 'package:json_annotation/json_annotation.dart'; -import 'package:stream_chat/src/models/serialization.dart'; - -part 'user.g.dart'; - -/// The class that defines the user model -@JsonSerializable() -class User { - /// Constructor used for json serialization - User({ - this.id, - this.role, - this.createdAt, - this.updatedAt, - this.lastActive, - this.online, - this.extraData, - this.banned, - this.teams, - }); - - /// Create a new instance from a json - factory User.fromJson(Map json) => _$UserFromJson( - Serialization.moveToExtraDataFromRoot(json, topLevelFields)); - - /// Use this named constructor to create a new user instance - User.init( - this.id, { - this.online, - this.extraData, - }) : createdAt = null, - updatedAt = null, - lastActive = null, - banned = null, - teams = null, - role = null; - - /// Known top level fields. - /// Useful for [Serialization] methods. - static const topLevelFields = [ - 'id', - 'role', - 'created_at', - 'updated_at', - 'last_active', - 'online', - 'banned', - 'teams', - ]; - - /// User id - final String id; - - /// User role - @JsonKey(includeIfNull: false, toJson: Serialization.readOnly) - final String role; - - /// User role - @JsonKey(includeIfNull: false, toJson: Serialization.readOnly) - final List teams; - - /// Date of user creation - @JsonKey(includeIfNull: false, toJson: Serialization.readOnly) - final DateTime createdAt; - - /// Date of last user update - @JsonKey(includeIfNull: false, toJson: Serialization.readOnly) - final DateTime updatedAt; - - /// Date of last user connection - @JsonKey(includeIfNull: false, toJson: Serialization.readOnly) - final DateTime lastActive; - - /// True if user is online - @JsonKey(includeIfNull: false, toJson: Serialization.readOnly) - final bool online; - - /// True if user is banned from the chat - @JsonKey(includeIfNull: false, toJson: Serialization.readOnly) - final bool banned; - - /// Map of custom user extraData - @JsonKey(includeIfNull: false) - final Map extraData; - - @override - int get hashCode => id.hashCode; - - /// Shortcut for user name - String get name => - (extraData?.containsKey('name') == true && extraData['name'] != '') - ? extraData['name'] - : id; - - @override - bool operator ==(Object other) => - identical(this, other) || - other is User && runtimeType == other.runtimeType && id == other.id; - - /// Serialize to json - Map toJson() => - Serialization.moveFromExtraDataToRoot(_$UserToJson(this), topLevelFields); - - /// Creates a copy of [User] with specified attributes overridden. - User copyWith({ - String id, - String role, - DateTime createdAt, - DateTime updatedAt, - DateTime lastActive, - bool online, - Map extraData, - bool banned, - List teams, - }) => - User( - id: id ?? this.id, - role: role ?? this.role, - createdAt: createdAt ?? this.createdAt, - updatedAt: updatedAt ?? this.updatedAt, - lastActive: lastActive ?? this.lastActive, - online: online ?? this.online, - extraData: extraData ?? this.extraData, - banned: banned ?? this.banned, - teams: teams ?? this.teams, - ); -} diff --git a/packages/stream_chat/lib/src/api/connection_status.dart b/packages/stream_chat/lib/src/ws/connection_status.dart similarity index 100% rename from packages/stream_chat/lib/src/api/connection_status.dart rename to packages/stream_chat/lib/src/ws/connection_status.dart diff --git a/packages/stream_chat/lib/src/ws/timer_helper.dart b/packages/stream_chat/lib/src/ws/timer_helper.dart new file mode 100644 index 00000000..0974ee80 --- /dev/null +++ b/packages/stream_chat/lib/src/ws/timer_helper.dart @@ -0,0 +1,51 @@ +import 'dart:async'; +import 'package:uuid/uuid.dart'; + +/// +class TimerHelper { + final _uuid = const Uuid(); + late final _timers = {}; + + /// + String setTimer( + Duration duration, + void Function() callback, { + bool immediate = false, + }) { + final id = _uuid.v1(); + final timer = Timer(duration, callback); + if (immediate) callback(); + _timers[id] = timer; + return id; + } + + /// + String setPeriodicTimer( + Duration duration, + void Function(Timer) callback, { + bool immediate = false, + }) { + final id = _uuid.v1(); + final timer = Timer.periodic(duration, callback); + if (immediate) callback.call(timer); + _timers[id] = timer; + return id; + } + + /// + void cancelTimer(String id) { + final timer = _timers.remove(id); + return timer?.cancel(); + } + + /// + void cancelAllTimers() { + for (final t in _timers.values) { + t.cancel(); + } + _timers.clear(); + } + + /// + bool get hasTimers => _timers.isNotEmpty; +} diff --git a/packages/stream_chat/lib/src/ws/websocket.dart b/packages/stream_chat/lib/src/ws/websocket.dart new file mode 100644 index 00000000..f9d8a210 --- /dev/null +++ b/packages/stream_chat/lib/src/ws/websocket.dart @@ -0,0 +1,425 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:math' as math; + +import 'package:logging/logging.dart'; +import 'package:meta/meta.dart'; +import 'package:rxdart/rxdart.dart'; +import 'package:stream_chat/src/core/error/error.dart'; +import 'package:stream_chat/src/ws/connection_status.dart'; +import 'package:stream_chat/src/core/http/token.dart'; +import 'package:stream_chat/src/core/http/token_manager.dart'; +import 'package:stream_chat/src/core/models/event.dart'; +import 'package:stream_chat/src/core/models/user.dart'; +import 'package:stream_chat/src/event_type.dart'; +import 'package:stream_chat/src/ws/timer_helper.dart'; +import 'package:web_socket_channel/web_socket_channel.dart'; +import 'package:web_socket_channel/status.dart' as status; + +/// Typedef which exposes an [Event] as the only parameter. +typedef EventHandler = void Function(Event); + +/// Typedef used for connecting to a websocket. Method returns a +/// [WebSocketChannel] and accepts a connection [url] and an optional +/// [Iterable] of `protocols`. +typedef WebSocketChannelProvider = WebSocketChannel Function( + Uri uri, { + Iterable? protocols, +}); + +/// A WebSocket connection that reconnects upon failure. +class WebSocket with TimerHelper { + /// Creates a new websocket + /// To connect the WS call [connect] + WebSocket({ + required this.apiKey, + required this.baseUrl, + required this.tokenManager, + this.handler, + Logger? logger, + this.webSocketChannelProvider, + this.reconnectionMonitorInterval = 10, + this.healthCheckInterval = 20, + this.reconnectionMonitorTimeout = 40, + }) : _logger = logger; + + /// + final String apiKey; + + /// WS base url + final String baseUrl; + + /// + final TokenManager tokenManager; + + /// Functions that will be called every time a new event is received from the + /// connection + final EventHandler? handler; + + final Logger? _logger; + + /// Connection function + /// Used only for testing purpose + @visibleForTesting + final WebSocketChannelProvider? webSocketChannelProvider; + + /// Interval of the reconnection monitor timer + /// This checks that it received a new event in the last + /// [reconnectionMonitorTimeout] seconds, otherwise it considers the + /// connection unhealthy and reconnects the WS + final int reconnectionMonitorInterval; + + /// Interval of the health event sending timer + /// This sends a health event every [healthCheckInterval] seconds in order to + /// make the server aware that the client is still listening + final int healthCheckInterval; + + /// The timeout that uses the reconnection monitor timer to consider the + /// connection unhealthy + final int reconnectionMonitorTimeout; + + User? _user; + String? _connectionId; + DateTime? _lastEventAt; + WebSocketChannel? _webSocketChannel; + StreamSubscription? _webSocketChannelSubscription; + + /// + Completer? connectionCompleter; + + /// + String? get connectionId => _connectionId; + + final _connectionStatusController = + BehaviorSubject.seeded(ConnectionStatus.disconnected); + + set _connectionStatus(ConnectionStatus status) => + _connectionStatusController.add(status); + + /// The current connection status value + ConnectionStatus get connectionStatus => _connectionStatusController.value; + + /// This notifies of connection status changes + Stream get connectionStatusStream => + _connectionStatusController.stream.distinct(); + + void _initWebSocketChannel(Uri uri) { + _logger?.info('Initiating connection with $baseUrl'); + if (_webSocketChannel != null) { + _closeWebSocketChannel(); + } + _webSocketChannel = + webSocketChannelProvider?.call(uri) ?? WebSocketChannel.connect(uri); + _subscribeToWebSocketChannel(); + } + + void _closeWebSocketChannel() { + _logger?.info('Closing connection with $baseUrl'); + if (_webSocketChannel != null) { + _unsubscribeFromWebSocketChannel(); + _webSocketChannel?.sink.close(status.goingAway); + _webSocketChannel = null; + } + } + + void _subscribeToWebSocketChannel() { + _logger?.info('Started listening to $baseUrl'); + if (_webSocketChannelSubscription != null) { + _unsubscribeFromWebSocketChannel(); + } + _webSocketChannelSubscription = _webSocketChannel?.stream.listen( + _onDataReceived, + onError: _onConnectionError, + onDone: _onConnectionClosed, + ); + } + + void _unsubscribeFromWebSocketChannel() { + _logger?.info('Stopped listening to $baseUrl'); + if (_webSocketChannelSubscription != null) { + _webSocketChannelSubscription?.cancel(); + _webSocketChannelSubscription = null; + } + } + + Future _buildUri({bool refreshToken = false}) async { + final user = _user!; + final token = await tokenManager.loadToken(refresh: refreshToken); + final params = { + 'user_id': user.id, + 'user_details': user, + 'user_token': token.rawValue, + 'server_determines_connection_id': true, + }; + final qs = { + 'json': jsonEncode(params), + 'api_key': apiKey, + 'authorization': token.rawValue, + 'stream-auth-type': token.authType.raw, + }; + final scheme = baseUrl.startsWith('https') ? 'wss' : 'ws'; + final host = baseUrl.replaceAll(RegExp(r'(^\w+:|^)\/\/'), ''); + return Uri( + scheme: scheme, + host: host, + pathSegments: ['connect'], + queryParameters: qs, + ); + } + + bool _connectRequestInProgress = false; + + /// Connect the WS using the parameters passed in the constructor + Future connect(User user) async { + if (_connectRequestInProgress) { + throw const StreamWebSocketError(''' + You've called connect twice, + can only attempt 1 connection at the time, + '''); + } + _connectRequestInProgress = true; + _manuallyClosed = false; + + _user = user; + _connectionStatus = ConnectionStatus.connecting; + connectionCompleter = Completer(); + + final uri = await _buildUri(); + _initWebSocketChannel(uri); + + return connectionCompleter!.future; + } + + int _reconnectAttempt = 0; + bool _reconnectRequestInProgress = false; + + void _reconnect({bool refreshToken = false}) async { + _logger?.info('Retrying connection : $_reconnectAttempt'); + if (_reconnectRequestInProgress) return; + _reconnectRequestInProgress = true; + + _stopMonitoringEvents(); + // Closing any previously opened web-socket + _closeWebSocketChannel(); + + _reconnectAttempt += 1; + _connectionStatus = ConnectionStatus.connecting; + + final delay = _getReconnectInterval(_reconnectAttempt); + setTimer( + Duration(milliseconds: delay), + () async { + final uri = await _buildUri(refreshToken: refreshToken); + _initWebSocketChannel(uri); + }, + ); + } + + // returns the reconnect interval based on `reconnectAttempt` in milliseconds + int _getReconnectInterval(int reconnectAttempt) { + // try to reconnect in 0.25-25 seconds + // (random to spread out the load from failures) + final max = math.min(500 + reconnectAttempt * 2000, 25000); + final min = math.min( + math.max(250, (reconnectAttempt - 1) * 2000), + 25000, + ); + return (math.Random().nextDouble() * (max - min) + min).floor(); + } + + void _startMonitoringEvents() { + _logger?.info('Starting monitoring events'); + // cancel all previous timers + cancelAllTimers(); + + _startHealthCheck(); + _startReconnectionMonitor(); + } + + void _stopMonitoringEvents() { + _logger?.info('Stopped monitoring events'); + // reset lastEvent + _lastEventAt = null; + + cancelAllTimers(); + } + + void _handleConnectedEvent(Event event) { + // updating connectionId and status + _connectionId = event.connectionId; + _connectionStatus = ConnectionStatus.connected; + + _logger?.info('Connection successful: $_connectionId'); + + // notify user that connection is completed + final completer = connectionCompleter; + if (completer != null && !completer.isCompleted) { + completer.complete(event); + } + + // start monitoring health-check events + _startMonitoringEvents(); + } + + void _handleHealthCheckEvent(Event event) { + _logger?.info('HealthCheck received : ${event.connectionId}'); + + _connectionId = event.connectionId; + _connectionStatus = ConnectionStatus.connected; + } + + void _handleStreamError(Map errorResponse) { + // resetting connect, reconnect request flag + _resetRequestFlags(); + + final error = StreamWebSocketError.fromStreamError(errorResponse); + final isTokenExpired = error.errorCode == ChatErrorCode.tokenExpired; + if (isTokenExpired && !tokenManager.isStatic) { + _logger?.warning('Connection failed, token expired'); + return _reconnect(refreshToken: true); + } + + _logger?.severe('Connection failed', error); + + final completer = connectionCompleter; + // complete with error if not yet completed + if (completer != null && !completer.isCompleted) { + // complete the connection with error + completer.completeError(error); + // disconnect the web-socket connection + return disconnect(); + } + + return _reconnect(); + } + + void _onDataReceived(dynamic data) { + final jsonData = json.decode(data) as Map; + final error = jsonData['error'] as Map?; + if (error != null) return _handleStreamError(error); + + // resetting connect, reconnect request flag + _resetRequestFlags(resetAttempts: true); + + Event? event; + try { + event = Event.fromJson(jsonData); + } catch (_) {} + + if (event == null) return; + + _lastEventAt = DateTime.now(); + _logger?.info('Event received: ${event.type}'); + + if (event.type == EventType.healthCheck) { + if (event.me != null) { + _handleConnectedEvent(event); + } else { + _handleHealthCheckEvent(event); + } + } + + return handler?.call(event); + } + + void _onConnectionError(error, [stacktrace]) { + _logger?.warning('Error occurred', error, stacktrace); + + StreamWebSocketError wsError; + if (error is WebSocketChannelException) { + wsError = StreamWebSocketError.fromWebSocketChannelError(error); + } else { + wsError = StreamWebSocketError(error.toString()); + } + + final completer = connectionCompleter; + // complete with error if not yet completed + if (completer != null && !completer.isCompleted) { + // complete the connection with error + completer.completeError(wsError); + } + + // resetting connect, reconnect request flag + _resetRequestFlags(); + + _reconnect(); + } + + bool _manuallyClosed = false; + + void _onConnectionClosed() { + _logger?.warning('Connection closed : $connectionId'); + + // resetting connect, reconnect request flag + _resetRequestFlags(); + + // resetting connection + _connectionId = null; + + // check if we manually closed the connection + if (_manuallyClosed) return; + _reconnect(); + } + + bool get _needsToReconnect { + final lastEventAt = _lastEventAt; + // means not yet connected or disconnected + if (lastEventAt == null) return false; + + // means we missed a health check + final now = DateTime.now(); + return now.difference(lastEventAt).inSeconds > reconnectionMonitorTimeout; + } + + void _resetRequestFlags({bool resetAttempts = false}) { + _connectRequestInProgress = false; + _reconnectRequestInProgress = false; + if (resetAttempts) _reconnectAttempt = 0; + } + + void _startReconnectionMonitor() { + _logger?.info('Starting reconnection monitor'); + setPeriodicTimer( + Duration(seconds: reconnectionMonitorInterval), + (_) { + final needsToReconnect = _needsToReconnect; + _logger?.info('Needs to reconnect : $needsToReconnect'); + if (needsToReconnect) _reconnect(); + }, + immediate: true, + ); + } + + void _startHealthCheck() { + _logger?.info('Starting health check monitor'); + setPeriodicTimer( + Duration(seconds: healthCheckInterval), + (_) { + _logger?.info('Sending Event: ${EventType.healthCheck}'); + final event = Event( + type: EventType.healthCheck, + connectionId: connectionId, + ); + _webSocketChannel?.sink.add(jsonEncode(event)); + }, + immediate: true, + ); + } + + /// Disconnects the WS and releases eventual resources + void disconnect() { + if (connectionStatus == ConnectionStatus.disconnected) return; + _connectionStatus = ConnectionStatus.disconnected; + + _logger?.info('Disconnecting web-socket connection'); + + // resetting user + _user = null; + connectionCompleter = null; + + _stopMonitoringEvents(); + + _manuallyClosed = true; + _closeWebSocketChannel(); + } +} diff --git a/packages/stream_chat/lib/stream_chat.dart b/packages/stream_chat/lib/stream_chat.dart index 0e1c9ed2..571d78f5 100644 --- a/packages/stream_chat/lib/stream_chat.dart +++ b/packages/stream_chat/lib/stream_chat.dart @@ -3,33 +3,39 @@ library stream_chat; export 'package:async/async.dart'; export 'package:dio/src/dio_error.dart'; export 'package:dio/src/multipart_file.dart'; +export 'package:dio/src/options.dart'; export 'package:dio/src/options.dart' show ProgressCallback; export 'package:logging/logging.dart' show Logger, Level; +export 'package:rate_limiter/rate_limiter.dart'; -export './src/api/channel.dart'; -export './src/api/connection_status.dart'; -export './src/api/requests.dart'; -export './src/api/requests.dart'; -export './src/api/responses.dart'; -export './src/attachment_file_uploader.dart' show AttachmentFileUploader; -export './src/client.dart'; +export './src/core/api/attachment_file_uploader.dart' + show AttachmentFileUploader; +export './src/core/api/requests.dart'; +export './src/core/api/requests.dart'; +export './src/core/api/responses.dart'; +export './src/core/api/stream_chat_api.dart' show PushProvider; +export './src/core/error/error.dart'; +export './src/core/models/action.dart'; +export './src/core/models/attachment.dart'; +export './src/core/models/attachment_file.dart'; +export './src/core/models/channel_config.dart'; +export './src/core/models/channel_model.dart'; +export './src/core/models/channel_state.dart'; +export './src/core/models/command.dart'; +export './src/core/models/device.dart'; +export './src/core/models/event.dart'; +export './src/core/models/filter.dart' show Filter; +export './src/core/models/member.dart'; +export './src/core/models/message.dart'; +export './src/core/models/mute.dart'; +export './src/core/models/own_user.dart'; +export './src/core/models/reaction.dart'; +export './src/core/models/read.dart'; +export './src/core/models/user.dart'; +export './src/core/util/extension.dart'; export './src/db/chat_persistence_client.dart'; export './src/event_type.dart'; -export './src/extensions/rate_limit.dart'; -export './src/extensions/string_extension.dart'; -export './src/models/action.dart'; -export './src/models/attachment.dart'; -export './src/models/attachment_file.dart'; -export './src/models/channel_config.dart'; -export './src/models/channel_model.dart'; -export './src/models/channel_state.dart'; -export './src/models/command.dart'; -export './src/models/device.dart'; -export './src/models/event.dart'; -export './src/models/member.dart'; -export './src/models/message.dart'; -export './src/models/mute.dart'; -export './src/models/own_user.dart'; -export './src/models/reaction.dart'; -export './src/models/read.dart'; -export './src/models/user.dart'; +export './src/location.dart'; +export './src/ws/connection_status.dart'; +export 'src/client/channel.dart'; +export 'src/client/client.dart'; diff --git a/packages/stream_chat/lib/version.dart b/packages/stream_chat/lib/version.dart index a18852e1..d09a2ccc 100644 --- a/packages/stream_chat/lib/version.dart +++ b/packages/stream_chat/lib/version.dart @@ -1,6 +1,6 @@ -import 'package:stream_chat/src/client.dart'; +import 'package:stream_chat/src/client/client.dart'; /// Current package version /// Used in [StreamChatClient] to build the `x-stream-client` header // ignore: constant_identifier_names -const PACKAGE_VERSION = '1.5.3'; +const PACKAGE_VERSION = '2.0.0'; diff --git a/packages/stream_chat/peanut.yaml b/packages/stream_chat/peanut.yaml deleted file mode 100644 index 97d20f52..00000000 --- a/packages/stream_chat/peanut.yaml +++ /dev/null @@ -1,3 +0,0 @@ -# Configuration for https://pub.dev/packages/peanut -directories: - - example/web diff --git a/packages/stream_chat/pubspec.yaml b/packages/stream_chat/pubspec.yaml index c2d5f2db..1f145992 100644 --- a/packages/stream_chat/pubspec.yaml +++ b/packages/stream_chat/pubspec.yaml @@ -1,30 +1,33 @@ name: stream_chat homepage: https://getstream.io/ description: The official Dart client for Stream Chat, a service for building chat applications. -version: 1.5.3 +version: 2.0.0 repository: https://github.com/GetStream/stream-chat-flutter issue_tracker: https://github.com/GetStream/stream-chat-flutter/issues environment: - sdk: ">=2.7.0 <3.0.0" + sdk: '>=2.12.0 <3.0.0' dependencies: - async: ^2.4.2 - collection: ^1.14.13 - dio: ^3.0.10 - freezed_annotation: ^0.12.0 - http_parser: ^3.1.4 - json_annotation: ^3.0.1 - logging: ^0.11.4 - meta: ^1.2.4 - mime: ^0.9.7 - rxdart: ^0.25.0 - uuid: ^2.2.2 - web_socket_channel: ^1.2.0 + async: ^2.5.0 + collection: ^1.15.0 + dio: ^4.0.0 + equatable: ^2.0.0 + freezed_annotation: ^0.14.0 + http_parser: ^4.0.0 + jose: ^0.3.2 + json_annotation: ^4.0.1 + logging: ^1.0.1 + meta: ^1.3.0 + mime: ^1.0.0 + rate_limiter: ^0.1.1 + rxdart: ^0.27.0 + uuid: ^3.0.4 + web_socket_channel: ^2.0.0 dev_dependencies: - build_runner: ^1.10.0 - freezed: ^0.12.7 - json_serializable: ^3.3.0 - mockito: ^4.1.1 - test: ^1.15.7 + build_runner: ^2.0.1 + freezed: ^0.14.1+3 + json_serializable: ^4.1.0 + mocktail: ^0.1.1 + test: ^1.17.7 \ No newline at end of file diff --git a/packages/stream_chat/test/assets/example.pdf b/packages/stream_chat/test/assets/example.pdf new file mode 100644 index 00000000..d736dedc --- /dev/null +++ b/packages/stream_chat/test/assets/example.pdf @@ -0,0 +1,57 @@ +%PDF-1.7 +% +3 0 obj +<< /Length 4 0 R >> +stream +/DeviceRGB cs /DeviceRGB CS +0 0 0.972549 SC +21.68 194 136.64 26 re +10 10 m 20 20 l S +BT +/F0 24 Tf +25.68 200 Td +(Hello World!) Tj +ET +endstream +endobj +4 0 obj +132 +endobj +5 0 obj +<< /Type /Font /Subtype /Type1 /BaseFont /Times-Roman /Encoding /WinAnsiEncoding >> +endobj +6 0 obj +<< /Type /Page + /Parent 2 0 R + /Resources << /Font << /F0 5 0 R >> >> + /MediaBox [ 0 0 180 240 ] + /Contents 3 0 R +>> +endobj +2 0 obj +<< /Type /Pages + /Count 1 + /Kids [ 6 0 R ] +>> +endobj +1 0 obj +<< /Type /Catalog + /Pages 2 0 R +>> +endobj +xref +0 7 +0000000000 65535 f +0000000522 00000 n +0000000457 00000 n +0000000015 00000 n +0000000199 00000 n +0000000218 00000 n +0000000317 00000 n +trailer +<< /Size 7 + /Root 1 0 R +>> +startxref +574 +%%EOF diff --git a/packages/stream_chat/test/assets/test_image.jpeg b/packages/stream_chat/test/assets/test_image.jpeg new file mode 100644 index 00000000..aeaccec2 Binary files /dev/null and b/packages/stream_chat/test/assets/test_image.jpeg differ diff --git a/packages/stream_chat/test/fixtures/action.json b/packages/stream_chat/test/fixtures/action.json new file mode 100644 index 00000000..59da5684 --- /dev/null +++ b/packages/stream_chat/test/fixtures/action.json @@ -0,0 +1,7 @@ +{ + "name": "name", + "style": "style", + "text": "text", + "type": "type", + "value": "value" +} \ No newline at end of file diff --git a/packages/stream_chat/test/fixtures/attachment.json b/packages/stream_chat/test/fixtures/attachment.json new file mode 100644 index 00000000..faf38b90 --- /dev/null +++ b/packages/stream_chat/test/fixtures/attachment.json @@ -0,0 +1,29 @@ +{ + "type": "giphy", + "title": "awesome", + "title_link": "https://giphy.com/gifs/nrkp3-dance-happy-3o7TKnCdBx5cMg0qti", + "thumb_url": "https://media0.giphy.com/media/3o7TKnCdBx5cMg0qti/giphy.gif", + "actions": [ + { + "name": "image_action", + "text": "Send", + "style": "primary", + "type": "button", + "value": "send" + }, + { + "name": "image_action", + "text": "Shuffle", + "style": "default", + "type": "button", + "value": "shuffle" + }, + { + "name": "image_action", + "text": "Cancel", + "style": "default", + "type": "button", + "value": "cancel" + } + ] +} \ No newline at end of file diff --git a/packages/stream_chat/test/fixtures/channel.json b/packages/stream_chat/test/fixtures/channel.json new file mode 100644 index 00000000..2184cd6e --- /dev/null +++ b/packages/stream_chat/test/fixtures/channel.json @@ -0,0 +1,7 @@ +{ + "id": "test", + "type": "livestream", + "cid": "livestream:test", + "cats": true, + "fruit": ["bananas", "apples"] +} \ No newline at end of file diff --git a/packages/stream_chat/test/fixtures/channel_state.json b/packages/stream_chat/test/fixtures/channel_state.json new file mode 100644 index 00000000..1e6af605 --- /dev/null +++ b/packages/stream_chat/test/fixtures/channel_state.json @@ -0,0 +1,832 @@ + +{ + "channel": { + "id": "dev", + "type": "team", + "cid": "team:dev", + "last_message_at": "2020-01-30T13:43:41.062362Z", + "created_at": "2019-04-03T18:43:33.213373Z", + "updated_at": "2019-04-03T18:43:33.213374Z", + "team": "test", + "created_by": { + "id": "guido", + "role": "user", + "created_at": "2019-04-03T18:43:33.201036Z", + "updated_at": "2019-04-03T18:43:33.204713Z", + "banned": false, + "online": false, + "name": "Guido" + }, + "frozen": true, + "config": { + "created_at": "2019-11-07T22:29:26.776526Z", + "updated_at": "2019-11-07T22:29:48.286746Z", + "name": "team", + "typing_events": true, + "read_events": true, + "connect_events": true, + "search": true, + "reactions": true, + "replies": true, + "mutes": true, + "uploads": true, + "url_enrichment": true, + "message_retention": "infinite", + "max_message_length": 5000, + "automod": "disabled", + "automod_behavior": "flag", + "commands": [ + { + "name": "giphy", + "description": "Post a random gif to the channel", + "args": "[text]", + "set": "fun_set" + } + ] + }, + "name": "#dev", + "image": "https://cdn.chrisshort.net/testing-certificate-chains-in-go/GOPHER_MIC_DROP.png", + "example": 1 + }, + "messages": [ + { + "id": "dry-meadow-0-2b73cc8b-cd86-4a01-8d40-bd82ad07a030", + "text": "fasdfa", + "type": "regular", + "status": "SENT", + "silent": false, + "user": { + "id": "dry-meadow-0", + "role": "user", + "created_at": "2019-03-27T17:40:17.155892Z", + "updated_at": "2020-01-29T03:22:47.641589Z", + "last_active": "2020-01-29T03:22:47.63613Z", + "banned": false, + "online": false, + "image": "https://getstream.io/random_svg/?name=Dry+meadow", + "name": "Dry meadow" + }, + "attachments": [], + "latest_reactions": [], + "own_reactions": [], + "reaction_counts": {}, + "reaction_scores": {}, + "reply_count": 0, + "created_at": "2020-01-29T03:23:02.843948Z", + "updated_at": "2020-01-29T03:23:02.843949Z", + "mentioned_users": [], + "status": "SENT", + "silent": false, + "pinned": false, + "pinned_at": null, + "pin_expires": null, + "pinned_by": null + }, + { + "id": "dry-meadow-0-e8e74482-b4cd-48db-9d1e-30e6c191786f", + "text": "test message", + "type": "regular", + "user": { + "id": "dry-meadow-0", + "role": "user", + "created_at": "2019-03-27T17:40:17.155892Z", + "updated_at": "2020-01-29T03:22:47.641589Z", + "last_active": "2020-01-29T03:22:47.63613Z", + "banned": false, + "online": false, + "image": "https://getstream.io/random_svg/?name=Dry+meadow", + "name": "Dry meadow" + }, + "attachments": [], + "latest_reactions": [], + "own_reactions": [], + "reaction_counts": {}, + "reaction_scores": {}, + "reply_count": 0, + "created_at": "2020-01-29T03:23:07.981091Z", + "updated_at": "2020-01-29T03:23:07.981091Z", + "mentioned_users": [], + "status": "SENT", + "silent": false, + "pinned": false, + "pinned_at": null, + "pin_expires": null, + "pinned_by": null + }, + { + "id": "dry-meadow-0-53e6299f-9b97-4a9c-a27e-7e2dde49b7e0", + "text": "test message", + "type": "regular", + "user": { + "id": "dry-meadow-0", + "role": "user", + "created_at": "2019-03-27T17:40:17.155892Z", + "updated_at": "2020-01-29T03:22:47.641589Z", + "last_active": "2020-01-29T03:22:47.63613Z", + "banned": false, + "online": false, + "image": "https://getstream.io/random_svg/?name=Dry+meadow", + "name": "Dry meadow" + }, + "attachments": [], + "latest_reactions": [], + "own_reactions": [], + "reaction_counts": {}, + "reaction_scores": {}, + "reply_count": 0, + "created_at": "2020-01-29T03:23:11.568022Z", + "updated_at": "2020-01-29T03:23:11.568022Z", + "mentioned_users": [], + "status": "SENT", + "silent": false, + "pinned": false, + "pinned_at": null, + "pin_expires": null, + "pinned_by": null + }, + { + "id": "dry-meadow-0-80925be0-786e-40a5-b225-486518dafd35", + "text": "asdfadf", + "type": "regular", + "user": { + "id": "dry-meadow-0", + "role": "user", + "created_at": "2019-03-27T17:40:17.155892Z", + "updated_at": "2020-01-29T03:22:47.641589Z", + "last_active": "2020-01-29T03:22:47.63613Z", + "banned": false, + "online": false, + "image": "https://getstream.io/random_svg/?name=Dry+meadow", + "name": "Dry meadow" + }, + "attachments": [], + "latest_reactions": [], + "own_reactions": [], + "reaction_counts": {}, + "reaction_scores": {}, + "reply_count": 0, + "created_at": "2020-01-29T03:32:57.403566Z", + "updated_at": "2020-01-29T03:32:57.403566Z", + "mentioned_users": [], + "status": "SENT", + "silent": false, + "pinned": false, + "pinned_at": null, + "pin_expires": null, + "pinned_by": null + }, + { + "id": "dry-meadow-0-64d7970f-ede8-4b31-9738-1bc1756d2bfe", + "text": "test", + "type": "regular", + "user": { + "id": "dry-meadow-0", + "role": "user", + "created_at": "2019-03-27T17:40:17.155892Z", + "updated_at": "2020-01-29T03:22:47.641589Z", + "last_active": "2020-01-29T03:22:47.63613Z", + "banned": false, + "online": false, + "image": "https://getstream.io/random_svg/?name=Dry+meadow", + "name": "Dry meadow" + }, + "attachments": [], + "latest_reactions": [], + "own_reactions": [], + "reaction_counts": {}, + "reaction_scores": {}, + "reply_count": 0, + "created_at": "2020-01-29T03:33:35.294802Z", + "updated_at": "2020-01-29T03:33:35.294802Z", + "mentioned_users": [], + "status": "SENT", + "silent": false, + "pinned": false, + "pinned_at": null, + "pin_expires": null, + "pinned_by": null + }, + { + "id": "withered-cell-0-84cbd760-cf55-4f7e-9207-c5f66cccc6dc", + "text": "hi", + "type": "regular", + "user": { + "id": "withered-cell-0", + "role": "user", + "created_at": "2020-01-29T03:34:01.698106Z", + "updated_at": "2020-01-29T03:34:01.708808Z", + "last_active": "2020-01-29T03:34:01.70353Z", + "banned": false, + "online": false, + "name": "Withered cell", + "image": "https://getstream.io/random_svg/?name=Withered+cell" + }, + "attachments": [], + "latest_reactions": [], + "own_reactions": [], + "reaction_counts": {}, + "reaction_scores": {}, + "reply_count": 0, + "created_at": "2020-01-29T03:34:27.393296Z", + "updated_at": "2020-01-29T03:34:27.393296Z", + "mentioned_users": [], + "status": "SENT", + "silent": false, + "pinned": false, + "pinned_at": null, + "pin_expires": null, + "pinned_by": null + }, + { + "id": "dry-meadow-0-e9203588-43c3-40b1-91f7-f217fc42aa53", + "text": "fantastic", + "type": "regular", + "user": { + "id": "dry-meadow-0", + "role": "user", + "created_at": "2019-03-27T17:40:17.155892Z", + "updated_at": "2020-01-29T03:22:47.641589Z", + "last_active": "2020-01-29T03:22:47.63613Z", + "banned": false, + "online": false, + "image": "https://getstream.io/random_svg/?name=Dry+meadow", + "name": "Dry meadow" + }, + "attachments": [], + "latest_reactions": [], + "own_reactions": [], + "reaction_counts": {}, + "reaction_scores": {}, + "reply_count": 0, + "created_at": "2020-01-29T03:34:37.638376Z", + "updated_at": "2020-01-29T03:34:37.638376Z", + "mentioned_users": [], + "status": "SENT", + "silent": false, + "pinned": false, + "pinned_at": null, + "pin_expires": null, + "pinned_by": null + }, + { + "id": "withered-cell-0-7e3552d7-7a0d-45f2-a856-e91b23a7e240", + "text": "nice to meet you", + "type": "regular", + "user": { + "id": "withered-cell-0", + "role": "user", + "created_at": "2020-01-29T03:34:01.698106Z", + "updated_at": "2020-01-29T03:34:01.708808Z", + "last_active": "2020-01-29T03:34:01.70353Z", + "banned": false, + "online": false, + "image": "https://getstream.io/random_svg/?name=Withered+cell", + "name": "Withered cell" + }, + "attachments": [], + "latest_reactions": [], + "own_reactions": [], + "reaction_counts": {}, + "reaction_scores": {}, + "reply_count": 0, + "created_at": "2020-01-29T03:35:04.301566Z", + "updated_at": "2020-01-29T03:35:04.301566Z", + "mentioned_users": [], + "status": "SENT", + "silent": false, + "pinned": false, + "pinned_at": null, + "pin_expires": null, + "pinned_by": null + }, + { + "id": "dry-meadow-0-1ffeafd4-e4fc-4c84-9394-9d7cb10fff42", + "text": "hey", + "type": "regular", + "user": { + "id": "dry-meadow-0", + "role": "user", + "created_at": "2019-03-27T17:40:17.155892Z", + "updated_at": "2020-01-29T03:22:47.641589Z", + "last_active": "2020-01-29T03:22:47.63613Z", + "banned": false, + "online": false, + "image": "https://getstream.io/random_svg/?name=Dry+meadow", + "name": "Dry meadow" + }, + "attachments": [], + "latest_reactions": [], + "own_reactions": [], + "reaction_counts": {}, + "reaction_scores": {}, + "reply_count": 0, + "created_at": "2020-01-29T03:35:24.939084Z", + "updated_at": "2020-01-29T03:35:24.939085Z", + "mentioned_users": [], + "status": "SENT", + "silent": false, + "pinned": false, + "pinned_at": null, + "pin_expires": null, + "pinned_by": null + }, + { + "id": "dry-meadow-0-3f147324-12c8-4b41-9fb5-2db88d065efa", + "text": "hello, everyone", + "type": "regular", + "user": { + "id": "dry-meadow-0", + "role": "user", + "created_at": "2019-03-27T17:40:17.155892Z", + "updated_at": "2020-01-29T03:22:47.641589Z", + "last_active": "2020-01-29T03:22:47.63613Z", + "banned": false, + "online": false, + "name": "Dry meadow", + "image": "https://getstream.io/random_svg/?name=Dry+meadow" + }, + "attachments": [], + "latest_reactions": [], + "own_reactions": [], + "reaction_counts": {}, + "reaction_scores": {}, + "reply_count": 0, + "created_at": "2020-01-29T03:35:33.101566Z", + "updated_at": "2020-01-29T03:35:33.101566Z", + "mentioned_users": [], + "status": "SENT", + "silent": false, + "pinned": false, + "pinned_at": null, + "pin_expires": null, + "pinned_by": null + }, + { + "id": "dry-meadow-0-51a348ae-0c0a-44de-a556-eac7891c0cf0", + "text": "who is there?", + "type": "regular", + "user": { + "id": "dry-meadow-0", + "role": "user", + "created_at": "2019-03-27T17:40:17.155892Z", + "updated_at": "2020-01-29T03:22:47.641589Z", + "last_active": "2020-01-29T03:22:47.63613Z", + "banned": false, + "online": false, + "name": "Dry meadow", + "image": "https://getstream.io/random_svg/?name=Dry+meadow" + }, + "attachments": [], + "latest_reactions": [], + "own_reactions": [], + "reaction_counts": {}, + "reaction_scores": {}, + "reply_count": 0, + "created_at": "2020-01-29T03:35:45.458685Z", + "updated_at": "2020-01-29T03:35:45.458685Z", + "mentioned_users": [], + "status": "SENT", + "silent": false, + "pinned": false, + "pinned_at": null, + "pin_expires": null, + "pinned_by": null + }, + { + "id": "icy-recipe-7-a29e237b-8d81-4a97-9bc8-d42bca3f1356", + "text": "하이", + "type": "regular", + "user": { + "id": "icy-recipe-7", + "role": "user", + "created_at": "2020-01-21T11:36:22.284503Z", + "updated_at": "2020-01-29T07:01:59.69882Z", + "last_active": "2020-01-29T07:01:59.693378Z", + "banned": false, + "online": false, + "image": "https://getstream.io/random_svg/?name=Icy+recipe", + "name": "Icy recipe" + }, + "attachments": [], + "latest_reactions": [], + "own_reactions": [], + "reaction_counts": {}, + "reaction_scores": {}, + "reply_count": 0, + "created_at": "2020-01-29T07:02:11.535395Z", + "updated_at": "2020-01-29T07:02:11.535395Z", + "mentioned_users": [], + "status": "SENT", + "silent": false, + "pinned": false, + "pinned_at": null, + "pin_expires": null, + "pinned_by": null + }, + { + "id": "icy-recipe-7-935c396e-ddf8-4a9a-951c-0a12fa5bf055", + "text": "what are you doing?", + "type": "regular", + "user": { + "id": "icy-recipe-7", + "role": "user", + "created_at": "2020-01-21T11:36:22.284503Z", + "updated_at": "2020-01-29T07:01:59.69882Z", + "last_active": "2020-01-29T07:01:59.693378Z", + "banned": false, + "online": false, + "image": "https://getstream.io/random_svg/?name=Icy+recipe", + "name": "Icy recipe" + }, + "attachments": [], + "latest_reactions": [], + "own_reactions": [], + "reaction_counts": {}, + "reaction_scores": {}, + "reply_count": 0, + "created_at": "2020-01-29T07:02:22.485136Z", + "updated_at": "2020-01-29T07:02:22.485136Z", + "mentioned_users": [], + "status": "SENT", + "silent": false, + "pinned": false, + "pinned_at": null, + "pin_expires": null, + "pinned_by": null + }, + { + "id": "throbbing-boat-5-1e4d5730-5ff0-4d25-9948-9f34ffda43e4", + "text": "👍", + "type": "regular", + "user": { + "id": "throbbing-boat-5", + "role": "user", + "created_at": "2019-07-30T06:29:53.060413Z", + "updated_at": "2020-01-29T14:11:27.80176Z", + "last_active": "2020-01-29T14:11:27.7963Z", + "banned": false, + "online": false, + "image": "https://getstream.io/random_svg/?name=Throbbing+boat", + "name": "Throbbing boat" + }, + "attachments": [], + "latest_reactions": [], + "own_reactions": [], + "reaction_counts": {}, + "reaction_scores": {}, + "reply_count": 0, + "created_at": "2020-01-29T14:12:04.688552Z", + "updated_at": "2020-01-29T14:12:04.688552Z", + "mentioned_users": [], + "status": "SENT", + "silent": false, + "pinned": false, + "pinned_at": null, + "pin_expires": null, + "pinned_by": null + }, + { + "id": "snowy-credit-3-3e0c1a0d-d22f-42ee-b2a1-f9f49477bf21", + "text": "sdasas", + "type": "regular", + "user": { + "id": "snowy-credit-3", + "role": "user", + "created_at": "2020-01-29T15:29:03.693312Z", + "updated_at": "2020-01-29T15:29:03.702648Z", + "last_active": "2020-01-29T15:29:03.696144Z", + "banned": false, + "online": false, + "image": "https://getstream.io/random_svg/?name=Snowy+credit", + "name": "Snowy credit" + }, + "attachments": [], + "latest_reactions": [], + "own_reactions": [], + "reaction_counts": {}, + "reaction_scores": {}, + "reply_count": 0, + "created_at": "2020-01-29T15:29:36.011315Z", + "updated_at": "2020-01-29T15:29:36.011316Z", + "mentioned_users": [], + "status": "SENT", + "silent": false, + "pinned": false, + "pinned_at": null, + "pin_expires": null, + "pinned_by": null + }, + { + "id": "snowy-credit-3-3319537e-2d0e-4876-8170-a54f046e4b7d", + "text": "cjshsa", + "type": "regular", + "user": { + "id": "snowy-credit-3", + "role": "user", + "created_at": "2020-01-29T15:29:03.693312Z", + "updated_at": "2020-01-29T15:29:03.702648Z", + "last_active": "2020-01-29T15:29:03.696144Z", + "banned": false, + "online": false, + "image": "https://getstream.io/random_svg/?name=Snowy+credit", + "name": "Snowy credit" + }, + "attachments": [], + "latest_reactions": [], + "own_reactions": [], + "reaction_counts": {}, + "reaction_scores": {}, + "reply_count": 0, + "created_at": "2020-01-29T15:29:41.677819Z", + "updated_at": "2020-01-29T15:29:41.677819Z", + "mentioned_users": [], + "status": "SENT", + "silent": false, + "pinned": false, + "pinned_at": null, + "pin_expires": null, + "pinned_by": null + }, + { + "id": "snowy-credit-3-cfaf0b46-1daa-49c5-947c-b16d6697487d", + "text": "nhisagdhsadz", + "type": "regular", + "user": { + "id": "snowy-credit-3", + "role": "user", + "created_at": "2020-01-29T15:29:03.693312Z", + "updated_at": "2020-01-29T15:29:03.702648Z", + "last_active": "2020-01-29T15:29:03.696144Z", + "banned": false, + "online": false, + "image": "https://getstream.io/random_svg/?name=Snowy+credit", + "name": "Snowy credit" + }, + "attachments": [], + "latest_reactions": [], + "own_reactions": [], + "reaction_counts": {}, + "reaction_scores": {}, + "reply_count": 0, + "created_at": "2020-01-29T15:29:43.354177Z", + "updated_at": "2020-01-29T15:29:43.354177Z", + "mentioned_users": [], + "status": "SENT", + "silent": false, + "pinned": false, + "pinned_at": null, + "pin_expires": null, + "pinned_by": null + }, + { + "id": "snowy-credit-3-cebe25a7-a3a3-49fc-9919-91c6725e81f3", + "text": "hvadhsahzd", + "type": "regular", + "user": { + "id": "snowy-credit-3", + "role": "user", + "created_at": "2020-01-29T15:29:03.693312Z", + "updated_at": "2020-01-29T15:29:03.702648Z", + "last_active": "2020-01-29T15:29:03.696144Z", + "banned": false, + "online": false, + "image": "https://getstream.io/random_svg/?name=Snowy+credit", + "name": "Snowy credit" + }, + "attachments": [], + "latest_reactions": [], + "own_reactions": [], + "reaction_counts": {}, + "reaction_scores": {}, + "reply_count": 0, + "created_at": "2020-01-29T15:29:44.754713Z", + "updated_at": "2020-01-29T15:29:44.754713Z", + "mentioned_users": [], + "status": "SENT", + "silent": false, + "pinned": false, + "pinned_at": null, + "pin_expires": null, + "pinned_by": null + }, + { + "id": "divine-glade-9-0cea9262-5766-48e9-8b22-311870aed3bf", + "text": "hello", + "type": "regular", + "user": { + "id": "divine-glade-9", + "role": "user", + "created_at": "2020-01-29T17:02:18.312524Z", + "updated_at": "2020-01-29T17:02:18.320187Z", + "last_active": "2020-01-29T17:02:18.315074Z", + "banned": false, + "online": false, + "image": "https://getstream.io/random_svg/?name=Divine+glade", + "name": "Divine glade" + }, + "attachments": [], + "latest_reactions": [], + "own_reactions": [], + "reaction_counts": {}, + "reaction_scores": {}, + "reply_count": 0, + "created_at": "2020-01-29T17:02:36.933852Z", + "updated_at": "2020-01-29T17:02:36.933852Z", + "mentioned_users": [], + "status": "SENT", + "silent": false, + "pinned": false, + "pinned_at": null, + "pin_expires": null, + "pinned_by": null + }, + { + "id": "red-firefly-9-c4e9007b-bb7d-4238-ae08-5f8e3cd03d73", + "text": "hello", + "type": "regular", + "user": { + "id": "red-firefly-9", + "role": "user", + "created_at": "2019-08-02T18:56:39.366516Z", + "updated_at": "2020-01-29T22:13:50.491769Z", + "last_active": "2020-01-29T22:13:50.450215Z", + "banned": false, + "online": false, + "image": "https://getstream.io/random_svg/?name=Red+firefly", + "name": "Red firefly" + }, + "attachments": [], + "latest_reactions": [], + "own_reactions": [], + "reaction_counts": {}, + "reaction_scores": {}, + "reply_count": 0, + "created_at": "2020-01-29T22:14:08.54062Z", + "updated_at": "2020-01-29T22:14:08.54062Z", + "mentioned_users": [], + "status": "SENT", + "silent": false, + "pinned": false, + "pinned_at": null, + "pin_expires": null, + "pinned_by": null + }, + { + "id": "bitter-glade-2-02aee4eb-4093-4736-808b-2de75820e854", + "text": "hello", + "type": "regular", + "user": { + "id": "bitter-glade-2", + "role": "user", + "created_at": "2020-01-30T13:08:56.190678Z", + "updated_at": "2020-01-30T13:08:56.200333Z", + "last_active": "2020-01-30T13:08:56.193882Z", + "banned": false, + "online": false, + "image": "https://getstream.io/random_svg/?name=Bitter+glade", + "name": "Bitter glade" + }, + "attachments": [], + "latest_reactions": [], + "own_reactions": [], + "reaction_counts": {}, + "reaction_scores": {}, + "reply_count": 0, + "created_at": "2020-01-30T13:11:37.191293Z", + "updated_at": "2020-01-30T13:11:37.191293Z", + "mentioned_users": [], + "status": "SENT", + "silent": false, + "pinned": false, + "pinned_at": null, + "pin_expires": null, + "pinned_by": null + }, + { + "id": "morning-sea-1-0c700bcb-46dd-4224-b590-e77bdbccc480", + "text": "http://jaeger.ui.gtstrm.com/", + "type": "regular", + "user": { + "id": "morning-sea-1", + "role": "user", + "created_at": "2019-07-22T09:19:07.505207Z", + "updated_at": "2020-01-30T13:33:05.831856Z", + "last_active": "2020-01-30T13:33:05.825369Z", + "banned": false, + "online": false, + "image": "https://getstream.io/random_svg/?name=Morning+sea", + "name": "Morning sea" + }, + "attachments": [], + "latest_reactions": [], + "own_reactions": [], + "reaction_counts": {}, + "reaction_scores": {}, + "reply_count": 0, + "created_at": "2020-01-30T13:33:16.853116Z", + "updated_at": "2020-01-30T13:33:16.853116Z", + "mentioned_users": [], + "status": "SENT", + "silent": false, + "pinned": false, + "pinned_at": null, + "pin_expires": null, + "pinned_by": null + }, + { + "id": "ancient-salad-0-53e8b4e6-5b7b-43ad-aeee-8bfb6a9ed0be", + "text": "hi", + "type": "regular", + "user": { + "id": "ancient-salad-0", + "role": "user", + "created_at": "2020-01-30T13:34:29.286813Z", + "updated_at": "2020-01-30T13:34:29.296196Z", + "last_active": "2020-01-30T13:34:29.289964Z", + "banned": false, + "online": true, + "image": "https://getstream.io/random_svg/?name=Ancient+salad", + "name": "Ancient salad" + }, + "attachments": [], + "latest_reactions": [], + "own_reactions": [], + "reaction_counts": {}, + "reaction_scores": {}, + "reply_count": 0, + "created_at": "2020-01-30T13:36:52.749731Z", + "updated_at": "2020-01-30T13:36:52.749732Z", + "mentioned_users": [], + "status": "SENT", + "silent": false, + "pinned": false, + "pinned_at": null, + "pin_expires": null, + "pinned_by": null + }, + { + "id": "ancient-salad-0-8c225075-bd4c-42e2-8024-530aae13cd40", + "text": "hi", + "type": "regular", + "user": { + "id": "ancient-salad-0", + "role": "user", + "created_at": "2020-01-30T13:34:29.286813Z", + "updated_at": "2020-01-30T13:34:29.296196Z", + "last_active": "2020-01-30T13:34:29.289964Z", + "banned": false, + "online": true, + "image": "https://getstream.io/random_svg/?name=Ancient+salad", + "name": "Ancient salad" + }, + "attachments": [], + "latest_reactions": [], + "own_reactions": [], + "reaction_counts": {}, + "reaction_scores": {}, + "reply_count": 0, + "created_at": "2020-01-30T13:37:41.631056Z", + "updated_at": "2020-01-30T13:37:41.631056Z", + "mentioned_users": [], + "status": "SENT", + "silent": false, + "pinned": false, + "pinned_at": null, + "pin_expires": null, + "pinned_by": null + }, + { + "id": "proud-sea-7-17802096-cbf8-4e3c-addd-4ee31f4c8b5c", + "text": "😃", + "type": "regular", + "user": { + "id": "proud-sea-7", + "role": "user", + "created_at": "2020-01-30T13:43:03.903006Z", + "updated_at": "2020-01-30T13:43:03.912307Z", + "last_active": "2020-01-30T13:43:03.906236Z", + "banned": false, + "online": false, + "image": "https://getstream.io/random_svg/?name=Proud+sea", + "name": "Proud sea" + }, + "attachments": [], + "latest_reactions": [], + "own_reactions": [], + "reaction_counts": {}, + "reaction_scores": {}, + "reply_count": 0, + "created_at": "2020-01-30T13:43:41.062362Z", + "updated_at": "2020-01-30T13:43:41.062362Z", + "mentioned_users": [], + "status": "SENT", + "silent": false, + "pinned": false, + "pinned_at": null, + "pin_expires": null, + "pinned_by": null + } + ], + "watcher_count": 5, + "members": [] +} \ No newline at end of file diff --git a/packages/stream_chat/test/fixtures/channel_state_to_json.json b/packages/stream_chat/test/fixtures/channel_state_to_json.json new file mode 100644 index 00000000..4b8a0369 --- /dev/null +++ b/packages/stream_chat/test/fixtures/channel_state_to_json.json @@ -0,0 +1,418 @@ + +{ + "channel": { + "id": "dev", + "type": "team", + "frozen": true, + "name": "#dev", + "image": "https://cdn.chrisshort.net/testing-certificate-chains-in-go/GOPHER_MIC_DROP.png", + "example": 1 + }, + "watchers": [], + "read": [], + "messages": [ + { + "id": "dry-meadow-0-2b73cc8b-cd86-4a01-8d40-bd82ad07a030", + "text": "fasdfa", + "attachments": [], + "parent_id": null, + "quoted_message": null, + "quoted_message_id": null, + "show_in_channel": null, + "mentioned_users": [], + "status": "SENT", + "silent": false, + "pinned": false, + "pinned_at": null, + "pin_expires": null, + "pinned_by": null + }, + { + "id": "dry-meadow-0-e8e74482-b4cd-48db-9d1e-30e6c191786f", + "text": "test message", + "attachments": [], + "parent_id": null, + "quoted_message": null, + "quoted_message_id": null, + "show_in_channel": null, + "mentioned_users": [], + "status": "SENT", + "silent": false, + "pinned": false, + "pinned_at": null, + "pin_expires": null, + "pinned_by": null + }, + { + "id": "dry-meadow-0-53e6299f-9b97-4a9c-a27e-7e2dde49b7e0", + "text": "test message", + "attachments": [], + "parent_id": null, + "quoted_message": null, + "quoted_message_id": null, + "show_in_channel": null, + "mentioned_users": [], + "status": "SENT", + "silent": false, + "pinned": false, + "pinned_at": null, + "pin_expires": null, + "pinned_by": null + }, + { + "id": "dry-meadow-0-80925be0-786e-40a5-b225-486518dafd35", + "text": "asdfadf", + "attachments": [], + "parent_id": null, + "quoted_message": null, + "quoted_message_id": null, + "show_in_channel": null, + "mentioned_users": [], + "status": "SENT", + "silent": false, + "pinned": false, + "pinned_at": null, + "pin_expires": null, + "pinned_by": null + }, + { + "id": "dry-meadow-0-64d7970f-ede8-4b31-9738-1bc1756d2bfe", + "text": "test", + "attachments": [], + "parent_id": null, + "quoted_message": null, + "quoted_message_id": null, + "show_in_channel": null, + "mentioned_users": [], + "status": "SENT", + "silent": false, + "pinned": false, + "pinned_at": null, + "pin_expires": null, + "pinned_by": null + }, + { + "id": "withered-cell-0-84cbd760-cf55-4f7e-9207-c5f66cccc6dc", + "text": "hi", + "attachments": [], + "parent_id": null, + "quoted_message": null, + "quoted_message_id": null, + "show_in_channel": null, + "mentioned_users": [], + "status": "SENT", + "silent": false, + "pinned": false, + "pinned_at": null, + "pin_expires": null, + "pinned_by": null + }, + { + "id": "dry-meadow-0-e9203588-43c3-40b1-91f7-f217fc42aa53", + "text": "fantastic", + "attachments": [], + "parent_id": null, + "quoted_message": null, + "quoted_message_id": null, + "show_in_channel": null, + "mentioned_users": [], + "status": "SENT", + "silent": false, + "pinned": false, + "pinned_at": null, + "pin_expires": null, + "pinned_by": null + }, + { + "id": "withered-cell-0-7e3552d7-7a0d-45f2-a856-e91b23a7e240", + "text": "nice to meet you", + "attachments": [], + "parent_id": null, + "quoted_message": null, + "quoted_message_id": null, + "show_in_channel": null, + "mentioned_users": [], + "status": "SENT", + "silent": false, + "pinned": false, + "pinned_at": null, + "pin_expires": null, + "pinned_by": null + }, + { + "id": "dry-meadow-0-1ffeafd4-e4fc-4c84-9394-9d7cb10fff42", + "text": "hey", + "attachments": [], + "parent_id": null, + "quoted_message": null, + "quoted_message_id": null, + "show_in_channel": null, + "mentioned_users": [], + "status": "SENT", + "silent": false, + "pinned": false, + "pinned_at": null, + "pin_expires": null, + "pinned_by": null + }, + { + "id": "dry-meadow-0-3f147324-12c8-4b41-9fb5-2db88d065efa", + "text": "hello, everyone", + "attachments": [], + "parent_id": null, + "quoted_message": null, + "quoted_message_id": null, + "show_in_channel": null, + "mentioned_users": [], + "status": "SENT", + "silent": false, + "pinned": false, + "pinned_at": null, + "pin_expires": null, + "pinned_by": null + }, + { + "id": "dry-meadow-0-51a348ae-0c0a-44de-a556-eac7891c0cf0", + "text": "who is there?", + "attachments": [], + "parent_id": null, + "quoted_message": null, + "quoted_message_id": null, + "show_in_channel": null, + "mentioned_users": [], + "status": "SENT", + "silent": false, + "pinned": false, + "pinned_at": null, + "pin_expires": null, + "pinned_by": null + }, + { + "id": "icy-recipe-7-a29e237b-8d81-4a97-9bc8-d42bca3f1356", + "text": "하이", + "attachments": [], + "parent_id": null, + "quoted_message": null, + "quoted_message_id": null, + "show_in_channel": null, + "mentioned_users": [], + "status": "SENT", + "silent": false, + "pinned": false, + "pinned_at": null, + "pin_expires": null, + "pinned_by": null + }, + { + "id": "icy-recipe-7-935c396e-ddf8-4a9a-951c-0a12fa5bf055", + "text": "what are you doing?", + "attachments": [], + "parent_id": null, + "quoted_message": null, + "quoted_message_id": null, + "show_in_channel": null, + "mentioned_users": [], + "status": "SENT", + "silent": false, + "pinned": false, + "pinned_at": null, + "pin_expires": null, + "pinned_by": null + }, + { + "id": "throbbing-boat-5-1e4d5730-5ff0-4d25-9948-9f34ffda43e4", + "text": "👍", + "attachments": [], + "parent_id": null, + "quoted_message": null, + "quoted_message_id": null, + "show_in_channel": null, + "mentioned_users": [], + "status": "SENT", + "silent": false, + "pinned": false, + "pinned_at": null, + "pin_expires": null, + "pinned_by": null + }, + { + "id": "snowy-credit-3-3e0c1a0d-d22f-42ee-b2a1-f9f49477bf21", + "text": "sdasas", + "attachments": [], + "parent_id": null, + "quoted_message": null, + "quoted_message_id": null, + "show_in_channel": null, + "mentioned_users": [], + "status": "SENT", + "silent": false, + "pinned": false, + "pinned_at": null, + "pin_expires": null, + "pinned_by": null + }, + { + "id": "snowy-credit-3-3319537e-2d0e-4876-8170-a54f046e4b7d", + "text": "cjshsa", + "attachments": [], + "parent_id": null, + "quoted_message": null, + "quoted_message_id": null, + "show_in_channel": null, + "mentioned_users": [], + "status": "SENT", + "silent": false, + "pinned": false, + "pinned_at": null, + "pin_expires": null, + "pinned_by": null + }, + { + "id": "snowy-credit-3-cfaf0b46-1daa-49c5-947c-b16d6697487d", + "text": "nhisagdhsadz", + "attachments": [], + "parent_id": null, + "quoted_message": null, + "quoted_message_id": null, + "show_in_channel": null, + "mentioned_users": [], + "status": "SENT", + "silent": false, + "pinned": false, + "pinned_at": null, + "pin_expires": null, + "pinned_by": null + }, + { + "id": "snowy-credit-3-cebe25a7-a3a3-49fc-9919-91c6725e81f3", + "text": "hvadhsahzd", + "attachments": [], + "parent_id": null, + "quoted_message": null, + "quoted_message_id": null, + "show_in_channel": null, + "mentioned_users": [], + "status": "SENT", + "silent": false, + "pinned": false, + "pinned_at": null, + "pin_expires": null, + "pinned_by": null + }, + { + "id": "divine-glade-9-0cea9262-5766-48e9-8b22-311870aed3bf", + "text": "hello", + "attachments": [], + "parent_id": null, + "quoted_message": null, + "quoted_message_id": null, + "show_in_channel": null, + "mentioned_users": [], + "status": "SENT", + "silent": false, + "pinned": false, + "pinned_at": null, + "pin_expires": null, + "pinned_by": null + }, + { + "id": "red-firefly-9-c4e9007b-bb7d-4238-ae08-5f8e3cd03d73", + "text": "hello", + "attachments": [], + "parent_id": null, + "quoted_message": null, + "quoted_message_id": null, + "show_in_channel": null, + "mentioned_users": [], + "status": "SENT", + "silent": false, + "pinned": false, + "pinned_at": null, + "pin_expires": null, + "pinned_by": null + }, + { + "id": "bitter-glade-2-02aee4eb-4093-4736-808b-2de75820e854", + "text": "hello", + "attachments": [], + "parent_id": null, + "quoted_message": null, + "quoted_message_id": null, + "show_in_channel": null, + "mentioned_users": [], + "status": "SENT", + "silent": false, + "pinned": false, + "pinned_at": null, + "pin_expires": null, + "pinned_by": null + }, + { + "id": "morning-sea-1-0c700bcb-46dd-4224-b590-e77bdbccc480", + "text": "http://jaeger.ui.gtstrm.com/", + "attachments": [], + "parent_id": null, + "quoted_message": null, + "quoted_message_id": null, + "show_in_channel": null, + "mentioned_users": [], + "status": "SENT", + "silent": false, + "pinned": false, + "pinned_at": null, + "pin_expires": null, + "pinned_by": null + }, + { + "id": "ancient-salad-0-53e8b4e6-5b7b-43ad-aeee-8bfb6a9ed0be", + "text": "hi", + "attachments": [], + "parent_id": null, + "quoted_message": null, + "quoted_message_id": null, + "show_in_channel": null, + "mentioned_users": [], + "status": "SENT", + "silent": false, + "pinned": false, + "pinned_at": null, + "pin_expires": null, + "pinned_by": null + }, + { + "id": "ancient-salad-0-8c225075-bd4c-42e2-8024-530aae13cd40", + "text": "hi", + "attachments": [], + "parent_id": null, + "quoted_message": null, + "quoted_message_id": null, + "show_in_channel": null, + "mentioned_users": [], + "status": "SENT", + "silent": false, + "pinned": false, + "pinned_at": null, + "pin_expires": null, + "pinned_by": null + }, + { + "id": "proud-sea-7-17802096-cbf8-4e3c-addd-4ee31f4c8b5c", + "text": "😃", + "attachments": [], + "parent_id": null, + "quoted_message": null, + "quoted_message_id": null, + "show_in_channel": null, + "mentioned_users": [], + "status": "SENT", + "silent": false, + "pinned": false, + "pinned_at": null, + "pin_expires": null, + "pinned_by": null + } + ], + "pinned_messages": [], + "members": [], + "watcher_count": 5 +} diff --git a/packages/stream_chat/test/fixtures/command.json b/packages/stream_chat/test/fixtures/command.json new file mode 100644 index 00000000..38a169a5 --- /dev/null +++ b/packages/stream_chat/test/fixtures/command.json @@ -0,0 +1,5 @@ +{ + "name": "giphy", + "description": "Post a random gif to the channel", + "args": "[text]" +} \ No newline at end of file diff --git a/packages/stream_chat/test/fixtures/device.json b/packages/stream_chat/test/fixtures/device.json new file mode 100644 index 00000000..4555dc1e --- /dev/null +++ b/packages/stream_chat/test/fixtures/device.json @@ -0,0 +1,4 @@ +{ + "id": "device-id", + "push_provider": "push-provider" +} \ No newline at end of file diff --git a/packages/stream_chat/test/fixtures/event.json b/packages/stream_chat/test/fixtures/event.json new file mode 100644 index 00000000..2568af02 --- /dev/null +++ b/packages/stream_chat/test/fixtures/event.json @@ -0,0 +1,29 @@ +{ + "type": "type", + "cid": "cid", + "connection_id": "connectionId", + "created_at": "2019-04-03T18:43:33.213374Z", + "me": { + "id": "dry-meadow-0", + "role": "user", + "created_at": "2019-03-27T17:40:17.155892Z", + "updated_at": "2020-01-29T03:22:47.641589Z", + "last_active": "2020-01-29T03:22:47.63613Z", + "banned": false, + "online": false, + "image": "https://getstream.io/random_svg/?name=Dry+meadow", + "name": "Dry meadow" + }, + "parent_id": null, + "user": { + "id": "dry-meadow-0", + "role": "user", + "created_at": "2019-03-27T17:40:17.155892Z", + "updated_at": "2020-01-29T03:22:47.641589Z", + "last_active": "2020-01-29T03:22:47.63613Z", + "banned": false, + "online": false, + "image": "https://getstream.io/random_svg/?name=Dry+meadow", + "name": "Dry meadow" + } +} \ No newline at end of file diff --git a/packages/stream_chat/test/fixtures/event_channel.json b/packages/stream_chat/test/fixtures/event_channel.json new file mode 100644 index 00000000..6078c545 --- /dev/null +++ b/packages/stream_chat/test/fixtures/event_channel.json @@ -0,0 +1,99 @@ +{ + "id": "!members-v9ktpgmYysZA-MjgC-GMoeEawFHSelkOdTu6JGxFZWU", + "type": "messaging", + "cid": "messaging:!members-v9ktpgmYysZA-MjgC-GMoeEawFHSelkOdTu6JGxFZWU", + "last_message_at": "2020-12-02T04:22:16.755334Z", + "created_at": "2020-10-01T08:49:32.90162Z", + "updated_at": "2021-06-17T08:18:48.188996Z", + "created_by": { + "id": "super-band-9", + "role": "user", + "created_at": "2020-03-03T16:48:28.853674Z", + "updated_at": "2021-05-26T03:22:20.296181Z", + "last_active": "2021-06-17T08:12:05.062115Z", + "banned": false, + "online": true, + "image": "https://placehold.jp/150x150.png", + "invisible": false, + "name": "Proud darkness", + "unread_count": 0, + "username": "Rioland" + }, + "frozen": false, + "disabled": false, + "members": [ + { + "user_id": "super-band-9", + "user": { + "id": "super-band-9", + "role": "user", + "created_at": "2020-03-03T16:48:28.853674Z", + "updated_at": "2021-05-26T03:22:20.296181Z", + "last_active": "2021-06-17T08:12:05.062115Z", + "banned": false, + "online": true, + "image": "https://placehold.jp/150x150.png", + "invisible": false, + "name": "Proud darkness", + "unread_count": 0, + "username": "Rioland" + }, + "role": "owner", + "created_at": "2020-10-01T08:49:32.905052Z", + "updated_at": "2020-10-01T08:49:32.905052Z", + "banned": false, + "shadow_banned": false + }, + { + "user_id": "cc48de8e-b2db-48e7-bba5-c03cefd61430", + "user": { + "id": "cc48de8e-b2db-48e7-bba5-c03cefd61430", + "role": "user", + "created_at": "2020-07-22T14:59:38.026809Z", + "updated_at": "2020-07-22T14:59:38.31338Z", + "banned": false, + "online": false, + "image": "https://images-na.ssl-images-amazon.com/images/M/MV5BMjA3NjYzMzE1MV5BMl5BanBnXkFtZTgwNTA4NDY4OTE@._V1_UX172_CR0,0,172,256_AL_.jpg", + "name": "Ana De Armas" + }, + "role": "member", + "created_at": "2020-10-01T08:49:32.905053Z", + "updated_at": "2020-10-01T08:49:32.905053Z", + "banned": false, + "shadow_banned": false + } + ], + "member_count": 2, + "config": { + "created_at": "2020-04-15T14:57:17.00966Z", + "updated_at": "2021-05-25T14:25:30.405621Z", + "name": "messaging", + "typing_events": true, + "read_events": true, + "connect_events": true, + "search": true, + "reactions": true, + "replies": true, + "mutes": true, + "uploads": true, + "url_enrichment": true, + "custom_events": false, + "push_notifications": true, + "message_retention": "infinite", + "max_message_length": 5000, + "automod": "disabled", + "automod_behavior": "flag", + "blocklist": "profanity_en_2020_v1", + "blocklist_behavior": "block", + "automod_thresholds": {}, + "commands": [ + { + "name": "giphy", + "description": "Post a random gif to the channel", + "args": "[text]", + "set": "fun_set" + } + ] + }, + "name": "test" +} \ No newline at end of file diff --git a/packages/stream_chat/test/fixtures/member.json b/packages/stream_chat/test/fixtures/member.json new file mode 100644 index 00000000..a33acf13 --- /dev/null +++ b/packages/stream_chat/test/fixtures/member.json @@ -0,0 +1,15 @@ +{ + "user": { + "id": "bbb19d9a-ee50-45bc-84e5-0584e79d0c9e", + "role": "user", + "created_at": "2020-01-28T22:17:30.826259Z", + "updated_at": "2020-01-28T22:17:31.101222Z", + "banned": false, + "online": false, + "name": "Robin Papa", + "image": "https://pbs.twimg.com/profile_images/669512187778498560/L7wQctBt.jpg" + }, + "role": "member", + "created_at": "2020-01-28T22:17:30.95443Z", + "updated_at": "2020-01-28T22:17:30.95443Z" +} \ No newline at end of file diff --git a/packages/stream_chat/test/fixtures/message.json b/packages/stream_chat/test/fixtures/message.json new file mode 100644 index 00000000..d469639b --- /dev/null +++ b/packages/stream_chat/test/fixtures/message.json @@ -0,0 +1,65 @@ +{ + "id": "4637f7e4-a06b-42db-ba5a-8d8270dd926f", + "text": "https://giphy.com/gifs/the-lion-king-live-action-5zvN79uTGfLMOVfQaA", + "type": "regular", + "silent": false, + "status": "SENT", + "user": { + "id": "c1c9b454-2bcc-402d-8bb0-2f3706ce1680", + "role": "user", + "created_at": "2020-01-28T22:17:30.83015Z", + "updated_at": "2020-01-28T22:17:31.19435Z", + "banned": false, + "online": false, + "image": "https://randomuser.me/api/portraits/women/2.jpg", + "name": "Mia Denys" + }, + "attachments": [ + { + "type": "video", + "author_name": "GIPHY", + "title": "The Lion King Disney GIF - Find \u0026 Share on GIPHY", + "title_link": "https://media.giphy.com/media/5zvN79uTGfLMOVfQaA/giphy.gif", + "text": "Discover \u0026 share this Lion King Live Action GIF with everyone you know. GIPHY is how you search, share, discover, and create GIFs.", + "image_url": "https://media.giphy.com/media/5zvN79uTGfLMOVfQaA/giphy.gif", + "thumb_url": "https://media.giphy.com/media/5zvN79uTGfLMOVfQaA/giphy.gif", + "asset_url": "https://media.giphy.com/media/5zvN79uTGfLMOVfQaA/giphy.mp4", + "og_scrape_url": "https://giphy.com/gifs/the-lion-king-live-action-5zvN79uTGfLMOVfQaA" + } + ], + "latest_reactions": [ + { + "message_id": "4637f7e4-a06b-42db-ba5a-8d8270dd926f", + "user_id": "c1c9b454-2bcc-402d-8bb0-2f3706ce1680", + "user": { + "id": "c1c9b454-2bcc-402d-8bb0-2f3706ce1680", + "role": "user", + "created_at": "2020-01-28T22:17:30.83015Z", + "updated_at": "2020-01-28T22:17:31.19435Z", + "banned": false, + "online": false, + "image": "https://randomuser.me/api/portraits/women/2.jpg", + "name": "Mia Denys" + }, + "type": "love", + "score": 1, + "created_at": "2020-01-28T22:17:31.128376Z", + "updated_at": "2020-01-28T22:17:31.128376Z" + } + ], + "own_reactions": [], + "reaction_counts": { + "love": 1 + }, + "reaction_scores": { + "love": 1 + }, + "pinned": false, + "pinned_at": null, + "pin_expires": null, + "pinned_by": null, + "reply_count": 0, + "created_at": "2020-01-28T22:17:31.107978Z", + "updated_at": "2020-01-28T22:17:31.130506Z", + "mentioned_users": [] +} \ No newline at end of file diff --git a/packages/stream_chat/test/fixtures/message_to_json.json b/packages/stream_chat/test/fixtures/message_to_json.json new file mode 100644 index 00000000..b3b1dd6b --- /dev/null +++ b/packages/stream_chat/test/fixtures/message_to_json.json @@ -0,0 +1,29 @@ +{ + "id": "4637f7e4-a06b-42db-ba5a-8d8270dd926f", + "text": "https://giphy.com/gifs/the-lion-king-live-action-5zvN79uTGfLMOVfQaA", + "silent": false, + "attachments": [ + { + "type": "video", + "title_link": "https://media.giphy.com/media/5zvN79uTGfLMOVfQaA/giphy.gif", + "title": "The Lion King Disney GIF - Find & Share on GIPHY", + "thumb_url": "https://media.giphy.com/media/5zvN79uTGfLMOVfQaA/giphy.gif", + "text": "Discover & share this Lion King Live Action GIF with everyone you know. GIPHY is how you search, share, discover, and create GIFs.", + "og_scrape_url": "https://giphy.com/gifs/the-lion-king-live-action-5zvN79uTGfLMOVfQaA", + "image_url": "https://media.giphy.com/media/5zvN79uTGfLMOVfQaA/giphy.gif", + "author_name": "GIPHY", + "asset_url": "https://media.giphy.com/media/5zvN79uTGfLMOVfQaA/giphy.mp4", + "actions": [] + } + ], + "mentioned_users": [], + "parent_id": "parentId", + "quoted_message": null, + "quoted_message_id": null, + "pinned": false, + "pinned_at": null, + "pin_expires": null, + "pinned_by": null, + "show_in_channel": true, + "hey": "test" +} \ No newline at end of file diff --git a/packages/stream_chat/test/fixtures/mute.json b/packages/stream_chat/test/fixtures/mute.json new file mode 100644 index 00000000..cc6c3a8c --- /dev/null +++ b/packages/stream_chat/test/fixtures/mute.json @@ -0,0 +1,74 @@ +{ + "user": { + "id": "super-band-9", + "role": "user", + "created_at": "2020-03-03T16:48:28.853674Z", + "updated_at": "2021-05-26T03:22:20.296181Z", + "last_active": "2021-06-16T11:42:29.466165498Z", + "banned": false, + "online": true, + "username": "Rioland", + "image": "https://placehold.jp/150x150.png", + "invisible": false, + "name": "Proud darkness", + "unread_count": 0 + }, + "channel": { + "id": "!members-Qsp7PpigdPkW0rJk0603y5GnTiF1iRfoDc4SAngMMmw", + "type": "messaging", + "cid": "messaging:!members-Qsp7PpigdPkW0rJk0603y5GnTiF1iRfoDc4SAngMMmw", + "last_message_at": "2020-12-02T06:56:18.003432Z", + "created_at": "2020-11-30T10:25:32.494601Z", + "updated_at": "2020-11-30T10:25:32.494601Z", + "created_by": { + "id": "super-band-9", + "role": "user", + "created_at": "2020-03-03T16:48:28.853674Z", + "updated_at": "2021-05-26T03:22:20.296181Z", + "last_active": "2021-06-16T11:42:29.466165498Z", + "banned": false, + "online": true, + "image": "https://placehold.jp/150x150.png", + "invisible": false, + "name": "Proud darkness", + "unread_count": 0, + "username": "Rioland" + }, + "frozen": false, + "disabled": false, + "member_count": 2, + "config": { + "created_at": "2020-04-15T14:57:17.00966Z", + "updated_at": "2021-05-25T14:25:30.405621Z", + "name": "messaging", + "typing_events": true, + "read_events": true, + "connect_events": true, + "search": true, + "reactions": true, + "replies": true, + "mutes": true, + "uploads": true, + "url_enrichment": true, + "custom_events": false, + "push_notifications": true, + "message_retention": "infinite", + "max_message_length": 5000, + "automod": "disabled", + "automod_behavior": "flag", + "blocklist": "profanity_en_2020_v1", + "blocklist_behavior": "block", + "automod_thresholds": {}, + "commands": [ + { + "name": "giphy", + "description": "Post a random gif to the channel", + "args": "[text]", + "set": "fun_set" + } + ] + } + }, + "created_at": "2020-12-04T10:39:06.512021Z", + "updated_at": "2020-12-04T10:39:06.512021Z" +} \ No newline at end of file diff --git a/packages/stream_chat/test/fixtures/own_user.json b/packages/stream_chat/test/fixtures/own_user.json new file mode 100644 index 00000000..dc6aabd1 --- /dev/null +++ b/packages/stream_chat/test/fixtures/own_user.json @@ -0,0 +1,100 @@ +{ + "id": "super-band-9", + "role": "user", + "created_at": "2020-03-03T16:48:28.853674Z", + "updated_at": "2021-05-26T03:22:20.296181Z", + "last_active": "2021-06-16T11:59:59.003453014Z", + "banned": false, + "online": true, + "devices": [ + { + "push_provider": "firebase", + "id": "cRS8elU4Q-qqdCAvHR2kSa:APA91bFy7MEgPyXbnFWi3uoanr_x8Vbi42JcWOXlg8p3vyIL5FuW4bjpVfamqQjYCgwDGxPA0C4qavOadE-uiKeGQJp6Sp5D2KDW9Od_BlDqzwEPJnVG9gC1zbj7NKCfXRqbOA2Wh2mW", + "created_at": "2020-04-23T14:36:21.838196Z", + "user_id": "super-band-9" + } + ], + "mutes": [], + "channel_mutes": [ + { + "user": { + "id": "super-band-9", + "role": "user", + "created_at": "2020-03-03T16:48:28.853674Z", + "updated_at": "2021-05-26T03:22:20.296181Z", + "last_active": "2021-06-16T11:59:59.003453014Z", + "banned": false, + "online": true, + "image": "https://placehold.jp/150x150.png", + "invisible": false, + "name": "Proud darkness", + "unread_count": 0, + "username": "Rioland" + }, + "channel": { + "id": "!members-Qsp7PpigdPkW0rJk0603y5GnTiF1iRfoDc4SAngMMmw", + "type": "messaging", + "cid": "messaging:!members-Qsp7PpigdPkW0rJk0603y5GnTiF1iRfoDc4SAngMMmw", + "last_message_at": "2020-12-02T06:56:18.003432Z", + "created_at": "2020-11-30T10:25:32.494601Z", + "updated_at": "2020-11-30T10:25:32.494601Z", + "created_by": { + "id": "super-band-9", + "role": "user", + "created_at": "2020-03-03T16:48:28.853674Z", + "updated_at": "2021-05-26T03:22:20.296181Z", + "last_active": "2021-06-16T11:59:59.003453014Z", + "banned": false, + "online": true, + "unread_count": 0, + "username": "Rioland", + "image": "https://placehold.jp/150x150.png", + "invisible": false, + "name": "Proud darkness" + }, + "frozen": false, + "disabled": false, + "member_count": 2, + "config": { + "created_at": "2020-04-15T14:57:17.00966Z", + "updated_at": "2021-05-25T14:25:30.405621Z", + "name": "messaging", + "typing_events": true, + "read_events": true, + "connect_events": true, + "search": true, + "reactions": true, + "replies": true, + "mutes": true, + "uploads": true, + "url_enrichment": true, + "custom_events": false, + "push_notifications": true, + "message_retention": "infinite", + "max_message_length": 5000, + "automod": "disabled", + "automod_behavior": "flag", + "blocklist": "profanity_en_2020_v1", + "blocklist_behavior": "block", + "automod_thresholds": {}, + "commands": [ + { + "name": "giphy", + "description": "Post a random gif to the channel", + "args": "[text]", + "set": "fun_set" + } + ] + } + }, + "created_at": "2020-12-04T10:39:06.512021Z", + "updated_at": "2020-12-04T10:39:06.512021Z" + } + ], + "total_unread_count": 0, + "unread_channels": 0, + "language": "", + "image": "https://placehold.jp/150x150.png", + "name": "Proud darkness", + "username": "Rioland" +} diff --git a/packages/stream_chat/test/fixtures/reaction.json b/packages/stream_chat/test/fixtures/reaction.json new file mode 100644 index 00000000..ce87ca1d --- /dev/null +++ b/packages/stream_chat/test/fixtures/reaction.json @@ -0,0 +1,18 @@ +{ + "message_id": "76cd8c82-b557-4e48-9d12-87995d3a0e04", + "user_id": "2de0297c-f3f2-489d-b930-ef77342edccf", + "user": { + "id": "2de0297c-f3f2-489d-b930-ef77342edccf", + "role": "user", + "created_at": "2020-01-28T22:17:30.810011Z", + "updated_at": "2020-01-28T22:17:31.077195Z", + "banned": false, + "online": false, + "image": "https://randomuser.me/api/portraits/women/45.jpg", + "name": "Daisy Morgan" + }, + "type": "wow", + "score": 1, + "created_at": "2020-01-28T22:17:31.108742Z", + "updated_at": "2020-01-28T22:17:31.108742Z" +} \ No newline at end of file diff --git a/packages/stream_chat/test/fixtures/read.json b/packages/stream_chat/test/fixtures/read.json new file mode 100644 index 00000000..7dc596d3 --- /dev/null +++ b/packages/stream_chat/test/fixtures/read.json @@ -0,0 +1,7 @@ +{ + "user": { + "id": "bbb19d9a-ee50-45bc-84e5-0584e79d0c9e" + }, + "last_read": "2020-01-28T22:17:30.966485504Z", + "unread_messages": 10 +} \ No newline at end of file diff --git a/packages/stream_chat/test/fixtures/user.json b/packages/stream_chat/test/fixtures/user.json new file mode 100644 index 00000000..49b972a3 --- /dev/null +++ b/packages/stream_chat/test/fixtures/user.json @@ -0,0 +1,5 @@ +{ + "id": "bbb19d9a-ee50-45bc-84e5-0584e79d0c9e", + "role": "test-role", + "name": "John" +} \ No newline at end of file diff --git a/packages/stream_chat/test/src/api/channel_test.dart b/packages/stream_chat/test/src/api/channel_test.dart index 755a3f3b..39003fb7 100644 --- a/packages/stream_chat/test/src/api/channel_test.dart +++ b/packages/stream_chat/test/src/api/channel_test.dart @@ -1,2183 +1,1937 @@ -import 'package:dio/dio.dart'; -import 'package:dio/native_imp.dart'; -import 'package:mockito/mockito.dart'; -import 'package:stream_chat/src/api/requests.dart'; -import 'package:stream_chat/src/client.dart'; -import 'package:stream_chat/src/event_type.dart'; -import 'package:stream_chat/src/models/event.dart'; -import 'package:stream_chat/src/models/message.dart'; -import 'package:stream_chat/src/models/reaction.dart'; -import 'package:stream_chat/src/models/own_user.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:stream_chat/src/client/channel.dart'; +import 'package:stream_chat/src/client/retry_policy.dart'; +import 'package:stream_chat/stream_chat.dart'; import 'package:test/test.dart'; -import 'package:stream_chat/stream_chat.dart'; - -class MockDio extends Mock implements DioForNative {} - -class MockAttachmentUploader extends Mock implements AttachmentFileUploader {} - -class MockHttpClientAdapter extends Mock implements HttpClientAdapter {} +import '../fakes.dart'; +import '../matchers.dart'; +import '../mocks.dart'; void main() { - group('src/api/channel', () { - group('message', () { - test('sendMessage', () async { - final mockDio = MockDio(); + ChannelState _generateChannelState( + String channelId, + String channelType, { + bool mockChannelConfig = false, + }) { + ChannelConfig? config; + if (mockChannelConfig) { + config = MockChannelConfig(); + when(() => config!.readEvents).thenReturn(true); + when(() => config!.typingEvents).thenReturn(true); + } + final channel = ChannelModel( + id: channelId, + type: channelType, + config: config, + ); + final state = ChannelState(channel: channel); + return state; + } - when(mockDio.options).thenReturn(BaseOptions()); - when(mockDio.interceptors).thenReturn(Interceptors()); + Logger _createLogger(String name) { + final logger = Logger.detached(name)..level = Level.ALL; + logger.onRecord.listen(print); + return logger; + } - final client = StreamChatClient( - 'api-key', - httpClient: mockDio, - tokenProvider: (_) async => '', + group('Non-Initialized Channel', () { + late final client = MockStreamChatClient(); + const channelId = 'test-channel-id'; + const channelType = 'test-channel-type'; + late Channel channel; + + setUpAll(() { + // detached loggers + when(() => client.detachedLogger(any())).thenAnswer((invocation) { + final name = invocation.positionalArguments.first; + return _createLogger(name); + }); + + // client logger + when(() => client.logger).thenReturn(_createLogger('mock-client-logger')); + }); + + setUp(() { + channel = Channel(client, channelType, channelId); + }); + + tearDown(() { + channel.dispose(); + }); + + test('should be able to set `extraData`', () { + expect(channel.extraData.isEmpty, isTrue); + + expect( + () => channel.extraData = {'name': 'test-channel-name'}, + returnsNormally, + ); + + expect(channel.extraData.isEmpty, isFalse); + expect(channel.extraData.containsKey('name'), isTrue); + expect(channel.extraData['name'], 'test-channel-name'); + }); + }); + + // TODO : test all persistence related logic in this group + group('Initialized Channel with Persistence', () { + late final client = MockStreamChatClientWithPersistence(); + const channelId = 'test-channel-id'; + const channelType = 'test-channel-type'; + const channelCid = '$channelType:$channelId'; + late Channel channel; + + setUpAll(() { + // Fallback values + registerFallbackValue(FakeMessage()); + registerFallbackValue>([]); + registerFallbackValue(FakeAttachmentFile()); + + // detached loggers + when(() => client.detachedLogger(any())).thenAnswer((invocation) { + final name = invocation.positionalArguments.first; + return _createLogger(name); + }); + + final retryPolicy = RetryPolicy( + shouldRetry: (_, __, ___) => false, + retryTimeout: (_, __, ___) => Duration.zero, + ); + when(() => client.retryPolicy).thenReturn(retryPolicy); + + final event = Event(type: 'event.local'); + when(() => client.on(any(), any(), any(), any())) + .thenAnswer((_) => Stream.value(event)); + + // fake clientState + final clientState = FakeClientState(); + when(() => client.state).thenReturn(clientState); + + // mock persistence client + final channelThreads = >{}; + when(() => client.chatPersistenceClient.getChannelThreads(channelCid)) + .thenAnswer((_) async => channelThreads); + final channelState = _generateChannelState(channelId, channelType); + when(() => client.chatPersistenceClient.getChannelStateByCid(channelCid)) + .thenAnswer((_) async => channelState); + when(() => client.chatPersistenceClient.updateMessages(channelCid, any())) + .thenAnswer((_) => Future.value()); + + // client logger + when(() => client.logger).thenReturn(_createLogger('mock-client-logger')); + }); + + // Setting up a initialized channel + setUp(() { + final channelState = _generateChannelState(channelId, channelType); + channel = Channel.fromState(client, channelState); + }); + + tearDown(() { + channel.dispose(); + }); + }); + + group('Initialized Channel', () { + late final client = MockStreamChatClient(); + const channelId = 'test-channel-id'; + const channelType = 'test-channel-type'; + const channelCid = '$channelType:$channelId'; + late Channel channel; + + setUpAll(() { + // Fallback values + registerFallbackValue(FakeMessage()); + registerFallbackValue(FakeAttachmentFile()); + registerFallbackValue(FakeEvent()); + + // detached loggers + when(() => client.detachedLogger(any())).thenAnswer((invocation) { + final name = invocation.positionalArguments.first; + return _createLogger(name); + }); + + final retryPolicy = RetryPolicy( + shouldRetry: (_, __, ___) => false, + retryTimeout: (_, __, ___) => Duration.zero, + ); + when(() => client.retryPolicy).thenReturn(retryPolicy); + + final event = Event(type: 'event.local'); + when(() => client.on(any(), any(), any(), any())) + .thenAnswer((_) => Stream.value(event)); + + // fake clientState + final clientState = FakeClientState(); + when(() => client.state).thenReturn(clientState); + + // client logger + when(() => client.logger).thenReturn(_createLogger('mock-client-logger')); + }); + + // Setting up a initialized channel + setUp(() { + final channelState = _generateChannelState( + channelId, + channelType, + mockChannelConfig: true, + ); + channel = Channel.fromState(client, channelState); + }); + + tearDown(() { + channel.dispose(); + }); + + test('should throw if trying to set `extraData`', () { + try { + channel.extraData = {'name': 'test-channel-name'}; + } catch (e) { + expect(e, isA()); + } + }); + + group('`.sendMessage`', () { + test('should work fine', () async { + final message = Message(id: 'test-message-id'); + + final sendMessageResponse = SendMessageResponse()..message = message; + + when(() => client.sendMessage( + any(that: isSameMessageAs(message)), + channelId, + channelType, + )).thenAnswer((_) async => sendMessageResponse); + + expectLater( + // skipping first seed message list -> [] messages + channel.state?.messagesStream.skip(1), + emitsInOrder([ + [ + isSameMessageAs( + message.copyWith(status: MessageSendingStatus.sending), + matchSendingStatus: true, + ), + ], + [ + isSameMessageAs( + message.copyWith(status: MessageSendingStatus.sent), + matchSendingStatus: true, + ), + ], + ]), ); - final channelClient = client.channel('messaging', id: 'testid'); + + final res = await channel.sendMessage(message); + + expect(res, isNotNull); + expect(res.message.id, message.id); + + verify(() => client.sendMessage( + any(that: isSameMessageAs(message)), + channelId, + channelType, + )).called(1); + }); + + test('with attachments should work just fine', () async { + final attachments = List.generate( + 3, + (index) => Attachment( + id: 'test-attachment-id-$index', + type: index.isEven ? 'image' : 'file', + file: AttachmentFile(size: 33 * index, path: 'test-file-path'), + ), + ); + final message = Message( - text: 'hey', - id: 'test', + id: 'test-message-id', + attachments: attachments, ); - when(mockDio.post('/channels/messaging/testid/message', data: { - 'message': message.toJson(), - })).thenAnswer((_) async => Response(data: '{}', statusCode: 200)); + final sendImageResponse = SendImageResponse()..file = 'test-image-url'; + final sendFileResponse = SendFileResponse()..file = 'test-file-url'; - await channelClient.sendMessage(message); + when(() => client.sendImage( + any(), + channelId, + channelType, + onSendProgress: any(named: 'onSendProgress'), + cancelToken: any(named: 'cancelToken'), + )).thenAnswer((_) async => sendImageResponse); + + when(() => client.sendFile( + any(), + channelId, + channelType, + onSendProgress: any(named: 'onSendProgress'), + cancelToken: any(named: 'cancelToken'), + )).thenAnswer((_) async => sendFileResponse); + + when(() => client.sendMessage( + any(that: isSameMessageAs(message)), + channelId, + channelType, + )).thenAnswer((_) async => SendMessageResponse() + ..message = message.copyWith( + attachments: attachments + .map((it) => + it.copyWith(uploadState: const UploadState.success())) + .toList(growable: false), + )); + + expectLater( + // skipping first seed message list -> [] messages + channel.state?.messagesStream.skip(1), + emitsInOrder([ + [ + isSameMessageAs( + message.copyWith(status: MessageSendingStatus.sending), + matchSendingStatus: true, + ), + ], + [ + isSameMessageAs( + message.copyWith(status: MessageSendingStatus.sent), + matchSendingStatus: true, + ), + ], + ]), + ); + + final res = await channel.sendMessage(message); + + expect(res, isNotNull); + expect(res.message.id, message.id); + expect(res.message.attachments.length, message.attachments.length); + expect( + res.message.attachments.every( + (it) => it.uploadState == const UploadState.success(), + ), + isTrue, + ); + + verify(() => client.sendImage( + any(), + channelId, + channelType, + onSendProgress: any(named: 'onSendProgress'), + cancelToken: any(named: 'cancelToken'), + )).called(2); + + verify(() => client.sendFile( + any(), + channelId, + channelType, + onSendProgress: any(named: 'onSendProgress'), + cancelToken: any(named: 'cancelToken'), + )).called(1); + + verify(() => client.sendMessage( + any(that: isSameMessageAs(message)), + channelId, + channelType, + )).called(1); + }); + }); + + group('`.updateMessage`', () { + test('should work fine', () async { + final message = Message(id: 'test-message-id'); + + final updateMessageResponse = UpdateMessageResponse() + ..message = message; + + when(() => client.updateMessage(any(that: isSameMessageAs(message)))) + .thenAnswer((_) async => updateMessageResponse); + + expectLater( + // skipping first seed message list -> [] messages + channel.state?.messagesStream.skip(1), + emitsInOrder([ + [ + isSameMessageAs( + message.copyWith(status: MessageSendingStatus.updating), + matchSendingStatus: true, + ), + ], + [ + isSameMessageAs( + message.copyWith(status: MessageSendingStatus.sent), + matchSendingStatus: true, + ), + ], + ]), + ); + + final res = await channel.updateMessage(message); + + expect(res, isNotNull); + expect(res.message.id, message.id); + + verify(() => client.updateMessage( + any(that: isSameMessageAs(message)), + )).called(1); + }); + + test('with attachments should work just fine', () async { + final attachments = List.generate( + 3, + (index) => Attachment( + id: 'test-attachment-id-$index', + type: index.isEven ? 'image' : 'file', + file: AttachmentFile(size: 33 * index, path: 'test-file-path'), + ), + ); + + final message = Message( + id: 'test-message-id', + attachments: attachments, + ); + + final sendImageResponse = SendImageResponse()..file = 'test-image-url'; + final sendFileResponse = SendFileResponse()..file = 'test-file-url'; + + when(() => client.sendImage( + any(), + channelId, + channelType, + onSendProgress: any(named: 'onSendProgress'), + cancelToken: any(named: 'cancelToken'), + )).thenAnswer((_) async => sendImageResponse); + + when(() => client.sendFile( + any(), + channelId, + channelType, + onSendProgress: any(named: 'onSendProgress'), + cancelToken: any(named: 'cancelToken'), + )).thenAnswer((_) async => sendFileResponse); + + when(() => client.updateMessage( + any(that: isSameMessageAs(message)), + )).thenAnswer((_) async => UpdateMessageResponse() + ..message = message.copyWith( + attachments: attachments + .map((it) => + it.copyWith(uploadState: const UploadState.success())) + .toList(growable: false), + )); + + expectLater( + // skipping first seed message list -> [] messages + channel.state?.messagesStream.skip(1), + emitsInOrder([ + [ + isSameMessageAs( + message.copyWith(status: MessageSendingStatus.updating), + matchSendingStatus: true, + ), + ], + [ + isSameMessageAs( + message.copyWith(status: MessageSendingStatus.sent), + matchSendingStatus: true, + ), + ], + ]), + ); + + final res = await channel.updateMessage(message); + + expect(res, isNotNull); + expect(res.message.id, message.id); + expect(res.message.attachments.length, message.attachments.length); + expect( + res.message.attachments.every( + (it) => it.uploadState == const UploadState.success(), + ), + isTrue, + ); + + verify(() => client.sendImage( + any(), + channelId, + channelType, + onSendProgress: any(named: 'onSendProgress'), + cancelToken: any(named: 'cancelToken'), + )).called(2); + + verify(() => client.sendFile( + any(), + channelId, + channelType, + onSendProgress: any(named: 'onSendProgress'), + cancelToken: any(named: 'cancelToken'), + )).called(1); + + verify(() => client.updateMessage( + any(that: isSameMessageAs(message)), + )).called(1); + }); + }); + + test('`.partialUpdateMessage`', () async { + final message = Message(id: 'test-message-id'); + + const set = {'text': 'Update Message text'}; + const unset = ['pinExpires']; + + final updateMessageResponse = UpdateMessageResponse() + ..message = message.copyWith(text: set['text'], pinExpires: null); + + when( + () => client.partialUpdateMessage(message.id, set: set, unset: unset), + ).thenAnswer((_) async => updateMessageResponse); + + channel.state?.messagesStream.skip(1).listen(print); + + expectLater( + // skipping first seed message list -> [] messages + channel.state?.messagesStream.skip(1), + emitsInOrder([ + [ + isSameMessageAs( + updateMessageResponse.message.copyWith( + status: MessageSendingStatus.sent, + ), + matchText: true, + matchSendingStatus: true, + ), + ], + ]), + ); + + final res = await channel.partialUpdateMessage( + message, + set: set, + unset: unset, + ); + + expect(res, isNotNull); + expect(res.message.id, message.id); + expect(res.message.id, message.id); + expect(res.message.text, set['text']); + expect(res.message.pinExpires, isNull); + + verify( + () => client.partialUpdateMessage(message.id, set: set, unset: unset), + ).called(1); + }); + + group('`.deleteMessage`', () { + test('should work fine', () async { + const messageId = 'test-message-id'; + final message = Message(id: messageId); + + when(() => client.deleteMessage(messageId)) + .thenAnswer((_) async => EmptyResponse()); + + expectLater( + // skipping first seed message list -> [] messages + channel.state?.messagesStream.skip(1), + emitsInOrder([ + [ + isSameMessageAs( + message.copyWith(status: MessageSendingStatus.deleting), + matchSendingStatus: true, + ), + ], + [ + isSameMessageAs( + message.copyWith(status: MessageSendingStatus.sent), + matchSendingStatus: true, + ), + ], + ]), + ); + + final res = await channel.deleteMessage(message); + + expect(res, isNotNull); + + verify(() => client.deleteMessage(messageId)).called(1); + }); + + test( + '''should directly update the state with message as deleted if the state is sending or failed''', + () async { + const messageId = 'test-message-id'; + final message = Message( + id: messageId, + status: MessageSendingStatus.sending, + ); + + expectLater( + // skipping first seed message list -> [] messages + channel.state?.messagesStream.skip(1), + emitsInOrder([ + [ + isSameMessageAs( + message.copyWith(status: MessageSendingStatus.sent), + matchSendingStatus: true, + ), + ], + ]), + ); + + final res = await channel.deleteMessage(message); + + expect(res, isNotNull); + }, + ); + }); + + group('`.pinMessage`', () { + test('should work fine without passing timeoutOrExpirationDate', + () async { + final message = Message(id: 'test-message-id'); + + when(() => client.partialUpdateMessage( + message.id, + set: any(named: 'set'), + unset: any(named: 'unset'), + )).thenAnswer((_) async => UpdateMessageResponse() + ..message = message.copyWith( + pinned: true, + pinExpires: null, + status: MessageSendingStatus.sent, + )); + + expectLater( + // skipping first seed message list -> [] messages + channel.state?.messagesStream.skip(1), + emitsInOrder([ + [ + isSameMessageAs( + message.copyWith(status: MessageSendingStatus.sent), + matchSendingStatus: true, + ), + ], + ]), + ); + + final res = await channel.pinMessage(message); + + expect(res, isNotNull); + expect(res.message.pinned, isTrue); + expect(res.message.pinExpires, isNull); + + verify(() => client.partialUpdateMessage( + message.id, + set: any(named: 'set'), + unset: any(named: 'unset'), + )).called(1); + }); + + test( + 'should work fine if passed timeoutOrExpirationDate as num(seconds)', + () async { + final message = Message(id: 'test-message-id'); + const timeoutOrExpirationDate = 300; // 300 seconds + + when(() => client.partialUpdateMessage( + message.id, + set: any(named: 'set'), + unset: any(named: 'unset'), + )).thenAnswer((_) async => UpdateMessageResponse() + ..message = message.copyWith( + pinned: true, + pinExpires: DateTime.now().add( + const Duration(seconds: timeoutOrExpirationDate), + ), + status: MessageSendingStatus.sent, + )); + + expectLater( + // skipping first seed message list -> [] messages + channel.state?.messagesStream.skip(1), + emitsInOrder([ + [ + isSameMessageAs( + message.copyWith(status: MessageSendingStatus.sent), + matchSendingStatus: true, + ), + ], + ]), + ); + + final res = await channel.pinMessage( + message, + timeoutOrExpirationDate: timeoutOrExpirationDate, + ); + + expect(res, isNotNull); + expect(res.message.pinned, isTrue); + expect(res.message.pinExpires, isNotNull); + + verify(() => client.partialUpdateMessage( + message.id, + set: any(named: 'set'), + unset: any(named: 'unset'), + )).called(1); + }, + ); + + test( + 'should work fine if passed timeoutOrExpirationDate as DateTime', + () async { + final message = Message(id: 'test-message-id'); + final timeoutOrExpirationDate = + DateTime.now().add(const Duration(days: 3)); // 3 days + + when(() => client.partialUpdateMessage( + message.id, + set: any(named: 'set'), + unset: any(named: 'unset'), + )).thenAnswer((_) async => UpdateMessageResponse() + ..message = message.copyWith( + pinned: true, + pinExpires: timeoutOrExpirationDate, + status: MessageSendingStatus.sent, + )); + + expectLater( + // skipping first seed message list -> [] messages + channel.state?.messagesStream.skip(1), + emitsInOrder([ + [ + isSameMessageAs( + message.copyWith(status: MessageSendingStatus.sent), + matchSendingStatus: true, + ), + ], + ]), + ); + + final res = await channel.pinMessage( + message, + timeoutOrExpirationDate: timeoutOrExpirationDate, + ); + + expect(res, isNotNull); + expect(res.message.pinned, isTrue); + expect(res.message.pinExpires, isNotNull); + expect(res.message.pinExpires, timeoutOrExpirationDate.toUtc()); + + verify(() => client.partialUpdateMessage( + message.id, + set: any(named: 'set'), + unset: any(named: 'unset'), + )).called(1); + }, + ); + + test( + 'should throw if invalid timeoutOrExpirationDate is passed', + () async { + final message = Message(id: 'test-message-id'); + const timeoutOrExpirationDate = 'invalid-value'; + + try { + await channel.pinMessage( + message, + timeoutOrExpirationDate: timeoutOrExpirationDate, + ); + } catch (e) { + expect(e, isA()); + } + }, + ); + }); + + test('`.unpinMessage`', () async { + final message = Message(id: 'test-message-id', pinned: true); + + when(() => client.partialUpdateMessage( + message.id, + set: {'pinned': false}, + )).thenAnswer((_) async => UpdateMessageResponse() + ..message = message.copyWith( + pinned: false, + status: MessageSendingStatus.sent, + )); + + expectLater( + // skipping first seed message list -> [] messages + channel.state?.messagesStream.skip(1), + emitsInOrder([ + [ + isSameMessageAs( + message.copyWith(status: MessageSendingStatus.sent), + matchSendingStatus: true, + ), + ], + ]), + ); + + final res = await channel.unpinMessage(message); + + expect(res, isNotNull); + expect(res.message.pinned, isFalse); + + verify(() => client.partialUpdateMessage( + message.id, + set: {'pinned': false}, + )).called(1); + }); + + group('`.search`', () { + final filter = Filter.in_('cid', const [channelCid]); + + test('should work fine with `query`', () async { + const query = 'test-search-query'; + const sort = [SortOption('test-sort-field')]; + const pagination = PaginationParams(); + + final results = List.generate(3, (index) => GetMessageResponse()); + + when(() => client.search( + filter, + query: query, + sort: any(named: 'sort'), + paginationParams: any(named: 'paginationParams'), + )).thenAnswer( + (_) async => SearchMessagesResponse()..results = results, + ); + + final res = await channel.search( + query: query, + sort: sort, + paginationParams: pagination, + ); + + expect(res, isNotNull); + expect(res.results.length, results.length); + + verify(() => client.search( + filter, + query: query, + sort: any(named: 'sort'), + paginationParams: any(named: 'paginationParams'), + )).called(1); + }); + + test('should work fine with `messageFilters`', () async { + final messageFilters = Filter.query('key', 'text'); + const sort = [SortOption('test-sort-field')]; + const pagination = PaginationParams(); + + final results = List.generate(3, (index) => GetMessageResponse()); + + when(() => client.search( + filter, + messageFilters: messageFilters, + sort: any(named: 'sort'), + paginationParams: any(named: 'paginationParams'), + )).thenAnswer( + (_) async => SearchMessagesResponse()..results = results, + ); + + final res = await channel.search( + sort: sort, + paginationParams: pagination, + messageFilters: messageFilters, + ); + + expect(res, isNotNull); + expect(res.results.length, results.length); + + verify(() => client.search( + filter, + messageFilters: messageFilters, + sort: any(named: 'sort'), + paginationParams: any(named: 'paginationParams'), + )).called(1); + }); + }); + + test('`.deleteFile`', () async { + const url = 'test-file-url'; + + when(() => client.deleteFile(url, channelId, channelType, + cancelToken: any(named: 'cancelToken'))) + .thenAnswer((_) async => EmptyResponse()); + + final res = await channel.deleteFile(url); + + expect(res, isNotNull); + + verify(() => client.deleteFile(url, channelId, channelType, + cancelToken: any(named: 'cancelToken'))).called(1); + }); + + test('`.deleteImage`', () async { + const url = 'test-image-url'; + + when(() => client.deleteImage(url, channelId, channelType, + cancelToken: any(named: 'cancelToken'))) + .thenAnswer((_) async => EmptyResponse()); + + final res = await channel.deleteImage(url); + + expect(res, isNotNull); + + verify(() => client.deleteImage(url, channelId, channelType, + cancelToken: any(named: 'cancelToken'))).called(1); + }); + + test('`.sendEvent`', () async { + final event = Event(type: 'event.local'); + + when(() => client.sendEvent(channelId, channelType, event)) + .thenAnswer((_) async => EmptyResponse()); + + final res = await channel.sendEvent(event); + + expect(res, isNotNull); + + verify(() => client.sendEvent(channelId, channelType, event)).called(1); + }); + + group('`.sendReaction`', () { + test('should work fine', () async { + const type = 'test-reaction-type'; + final message = Message(id: 'test-message-id'); + + final reaction = Reaction(type: type, messageId: message.id); + + when(() => client.sendReaction(message.id, type)).thenAnswer( + (_) async => SendReactionResponse() + ..message = message + ..reaction = reaction, + ); + + expectLater( + // skipping first seed message list -> [] messages + channel.state?.messagesStream.skip(1), + emitsInOrder([ + [ + isSameMessageAs( + message.copyWith( + status: MessageSendingStatus.sent, + reactionCounts: {type: 1}, + reactionScores: {type: 1}, + latestReactions: [reaction], + ownReactions: [reaction], + ), + matchReactions: true, + matchSendingStatus: true, + ), + ], + ]), + ); + + final res = await channel.sendReaction(message, type); + + expect(res, isNotNull); + expect(res.reaction.type, type); + expect(res.reaction.messageId, message.id); + + verify(() => client.sendReaction(message.id, type)).called(1); + }); + + test( + 'should restore previous message if `client.sendReaction` throws', + () async { + const type = 'test-reaction-type'; + final message = Message(id: 'test-message-id'); + + final reaction = Reaction(type: type, messageId: message.id); + + when(() => client.sendReaction(message.id, type)) + .thenThrow(StreamChatNetworkError(ChatErrorCode.inputError)); + + expectLater( + // skipping first seed message list -> [] messages + channel.state?.messagesStream.skip(1), + emitsInOrder([ + [ + isSameMessageAs( + message.copyWith( + status: MessageSendingStatus.sent, + reactionCounts: {type: 1}, + reactionScores: {type: 1}, + latestReactions: [reaction], + ownReactions: [reaction], + ), + matchReactions: true, + matchSendingStatus: true, + ), + ], + [ + isSameMessageAs( + message, + matchReactions: true, + matchSendingStatus: true, + ), + ], + ]), + ); + + try { + await channel.sendReaction(message, type); + } catch (e) { + expect(e, isA()); + } + + verify(() => client.sendReaction(message.id, type)).called(1); + }, + ); + + test( + '''should override previous reaction if present and `enforceUnique` is true''', + () async { + const userId = 'test-user-id'; + const messageId = 'test-message-id'; + const prevType = 'test-reaction-type'; + final prevReaction = Reaction( + type: prevType, + messageId: messageId, + userId: userId, + ); + final message = Message( + id: messageId, + ownReactions: [prevReaction], + latestReactions: [prevReaction], + reactionScores: const {prevType: 1}, + reactionCounts: const {prevType: 1}, + ); + + const type = 'test-reaction-type-2'; + final newReaction = Reaction( + type: type, + messageId: messageId, + userId: userId, + ); + final newMessage = message.copyWith( + ownReactions: [newReaction], + latestReactions: [newReaction], + ); + + const enforceUnique = true; + + when(() => client.sendReaction( + messageId, + type, + enforceUnique: enforceUnique, + )).thenAnswer( + (_) async => SendReactionResponse() + ..message = newMessage + ..reaction = newReaction, + ); + + expectLater( + // skipping first seed message list -> [] messages + channel.state?.messagesStream.skip(1), + emitsInOrder([ + [ + isSameMessageAs( + newMessage.copyWith(status: MessageSendingStatus.sent), + matchReactions: true, + matchSendingStatus: true, + ), + ], + ]), + ); + + final res = await channel.sendReaction( + message, + type, + enforceUnique: enforceUnique, + ); + + expect(res, isNotNull); + expect(res.reaction.type, type); + expect(res.reaction.messageId, messageId); + + verify(() => client.sendReaction( + messageId, + type, + enforceUnique: enforceUnique, + )).called(1); + }, + ); + }); + + group('`.deleteReaction`', () { + test('should work fine', () async { + const userId = 'test-user-id'; + const messageId = 'test-message-id'; + const type = 'test-reaction-type'; + final reaction = Reaction( + type: type, + messageId: messageId, + userId: userId, + ); + final message = Message( + id: messageId, + ownReactions: [reaction], + latestReactions: [reaction], + reactionScores: const {type: 1}, + reactionCounts: const {type: 1}, + ); + + when(() => client.deleteReaction(messageId, type)) + .thenAnswer((_) async => EmptyResponse()); + + expectLater( + // skipping first seed message list -> [] messages + channel.state?.messagesStream.skip(1), + emitsInOrder([ + [ + isSameMessageAs( + message.copyWith( + status: MessageSendingStatus.sent, + latestReactions: [], + ownReactions: [], + ), + matchReactions: true, + matchSendingStatus: true, + ), + ], + ]), + ); + + final res = await channel.deleteReaction(message, reaction); + + expect(res, isNotNull); + + verify(() => client.deleteReaction(messageId, type)).called(1); + }); + + test( + 'should restore prev message state if `client.deleteReaction` throws', + () async { + const userId = 'test-user-id'; + const messageId = 'test-message-id'; + const type = 'test-reaction-type'; + final reaction = Reaction( + type: type, + messageId: messageId, + userId: userId, + ); + final message = Message( + id: messageId, + ownReactions: [reaction], + latestReactions: [reaction], + reactionScores: const {type: 1}, + reactionCounts: const {type: 1}, + ); + + when(() => client.deleteReaction(messageId, type)) + .thenThrow(StreamChatNetworkError(ChatErrorCode.inputError)); + + expectLater( + // skipping first seed message list -> [] messages + channel.state?.messagesStream.skip(1), + emitsInOrder([ + [ + isSameMessageAs( + message.copyWith( + status: MessageSendingStatus.sent, + latestReactions: [], + ownReactions: [], + ), + matchReactions: true, + matchSendingStatus: true, + ), + ], + [ + isSameMessageAs( + message, + matchReactions: true, + matchSendingStatus: true, + ), + ], + ]), + ); + + try { + await channel.deleteReaction(message, reaction); + } catch (e) { + expect(e, isA()); + } + + verify(() => client.deleteReaction(messageId, type)).called(1); + }, + ); + }); + + test('`.update`', () async { + const channelData = { + 'name': 'Stream Team', + 'profile_image': 'test-profile-image', + }; + final updateMessage = Message( + id: 'test-message-id', + text: 'updated channel', + ); + + final channelModel = ChannelModel( + cid: channelCid, + extraData: channelData, + ); + + when(() => client.updateChannel(channelId, channelType, channelData, + message: any(named: 'message'))).thenAnswer( + (_) async => UpdateChannelResponse() + ..channel = channelModel + ..message = updateMessage, + ); + + final res = await channel.update(channelData, updateMessage); + + expect(res, isNotNull); + expect(res.channel.cid, channelModel.cid); + expect(res.channel.extraData, channelData); + expect(res.message?.id, updateMessage.id); + + verify(() => client.updateChannel(channelId, channelType, channelData, + message: any(named: 'message'))).called(1); + }); + + test('`.updatePartial`', () async { + const set = { + 'name': 'Stream Team', + 'profile_image': 'test-profile-image', + }; + + const unset = ['tag', 'last_name']; + + final channelModel = ChannelModel( + cid: channelCid, + extraData: { + 'coolness': 999, + ...set, + }, + ); + + when(() => client.updateChannelPartial( + channelId, + channelType, + set: set, + unset: unset, + )).thenAnswer( + (_) async => PartialUpdateChannelResponse()..channel = channelModel, + ); + + final res = await channel.updatePartial(set: set, unset: unset); + + expect(res, isNotNull); + expect(res.channel.cid, channelModel.cid); + expect( + res.channel.extraData, + {'coolness': 999, ...set}, + ); + + verify(() => client.updateChannelPartial( + channelId, + channelType, + set: set, + unset: unset, + )).called(1); + }); + + test('`.delete`', () async { + when(() => client.deleteChannel(channelId, channelType)) + .thenAnswer((_) async => EmptyResponse()); + + final res = await channel.delete(); + + expect(res, isNotNull); + + verify(() => client.deleteChannel(channelId, channelType)).called(1); + }); + + test('`.truncate`', () async { + when(() => client.truncateChannel(channelId, channelType)) + .thenAnswer((_) async => EmptyResponse()); + + final res = await channel.truncate(); + + expect(res, isNotNull); + + verify(() => client.truncateChannel(channelId, channelType)).called(1); + }); + + test('`.acceptInvite`', () async { + final message = Message(id: 'test-message-id', text: 'Invite Accepted'); + + final channelModel = ChannelModel(cid: channelCid); + + when(() => client.acceptChannelInvite(channelId, channelType, + message: any(named: 'message'))).thenAnswer( + (_) async => AcceptInviteResponse() + ..channel = channelModel + ..message = message, + ); + + final res = await channel.acceptInvite(message); + + expect(res, isNotNull); + expect(res.channel.cid, channelModel.cid); + expect(res.message?.id, message.id); + + verify(() => client.acceptChannelInvite(channelId, channelType, + message: any(named: 'message'))).called(1); + }); + + test('`.rejectInvite`', () async { + final message = Message(id: 'test-message-id', text: 'Invite Rejected'); + + final channelModel = ChannelModel(cid: channelCid); + + when(() => client.rejectChannelInvite(channelId, channelType, + message: any(named: 'message'))).thenAnswer( + (_) async => RejectInviteResponse() + ..channel = channelModel + ..message = message, + ); + + final res = await channel.rejectInvite(message); + + expect(res, isNotNull); + expect(res.channel.cid, channelModel.cid); + expect(res.message?.id, message.id); + + verify(() => client.rejectChannelInvite(channelId, channelType, + message: any(named: 'message'))).called(1); + }); + + test('`.addMembers`', () async { + final members = List.generate( + 3, + (index) => Member(userId: 'test-member-id-$index'), + ); + final memberIds = members + .map((it) => it.userId) + .whereType() + .toList(growable: false); + final message = Message(id: 'test-message-id', text: 'Members Added'); + + final channelModel = ChannelModel(cid: channelCid); + + when(() => client.addChannelMembers(channelId, channelType, memberIds, + message: any(named: 'message'))).thenAnswer( + (_) async => AddMembersResponse() + ..channel = channelModel + ..members = members + ..message = message, + ); + + final res = await channel.addMembers(memberIds, message); + + expect(res, isNotNull); + expect(res.channel.cid, channelModel.cid); + expect(res.members.length, members.length); + expect(res.message?.id, message.id); + + verify(() => client.addChannelMembers(channelId, channelType, memberIds, + message: any(named: 'message'))).called(1); + }); + + test('`.inviteMembers`', () async { + final members = List.generate( + 3, + (index) => Member(userId: 'test-member-id-$index'), + ); + final memberIds = members + .map((it) => it.userId) + .whereType() + .toList(growable: false); + final message = Message(id: 'test-message-id', text: 'Members Invited'); + + final channelModel = ChannelModel(cid: channelCid); + + when(() => client.inviteChannelMembers(channelId, channelType, memberIds, + message: any(named: 'message'))).thenAnswer( + (_) async => InviteMembersResponse() + ..channel = channelModel + ..members = members + ..message = message, + ); + + final res = await channel.inviteMembers(memberIds, message); + + expect(res, isNotNull); + expect(res.channel.cid, channelModel.cid); + expect(res.members.length, members.length); + expect(res.message?.id, message.id); + + verify(() => client.inviteChannelMembers( + channelId, channelType, memberIds, + message: any(named: 'message'))).called(1); + }); + + test('`.removeMembers`', () async { + final members = List.generate( + 3, + (index) => Member(userId: 'test-member-id-$index'), + ); + final memberIds = members + .map((it) => it.userId) + .whereType() + .toList(growable: false); + final message = Message(id: 'test-message-id', text: 'Members Removed'); + + final channelModel = ChannelModel(cid: channelCid); + + when(() => client.removeChannelMembers(channelId, channelType, memberIds, + message: any(named: 'message'))).thenAnswer( + (_) async => RemoveMembersResponse() + ..channel = channelModel + ..members = members + ..message = message, + ); + + final res = await channel.removeMembers(memberIds, message); + + expect(res, isNotNull); + expect(res.channel.cid, channelModel.cid); + expect(res.members.length, members.length); + expect(res.message?.id, message.id); + + verify(() => client.removeChannelMembers( + channelId, channelType, memberIds, + message: any(named: 'message'))).called(1); + }); + + group('`.sendAction`', () { + test('should work fine', () async { + final message = Message(id: 'test-message-id', text: 'Action Sent'); + const formData = {'key': 'value'}; + + when( + () => client.sendAction(channelId, channelType, message.id, formData), + ).thenAnswer((_) async => SendActionResponse()); + + final res = await channel.sendAction(message, formData); + + expect(res, isNotNull); verify( - mockDio.post('/channels/messaging/testid/message', data: { - 'message': message.toJson(), - })).called(1); + () => client.sendAction(channelId, channelType, message.id, formData), + ).called(1); }); - test('markRead', () async { - final mockDio = MockDio(); - - when(mockDio.options).thenReturn(BaseOptions()); - when(mockDio.interceptors).thenReturn(Interceptors()); - - final client = StreamChatClient( - 'api-key', - httpClient: mockDio, - tokenProvider: (_) async => '', - ); - - final channelClient = client.channel('messaging', id: 'testid'); - - when(mockDio.post( - any, - data: anyNamed('data'), - )).thenAnswer((_) async => Response( - data: '{}', - statusCode: 200, - )); - await channelClient.watch(); - - when(mockDio.post('/channels/messaging/testid/read', data: {})) - .thenAnswer((_) async => Response(data: '{}', statusCode: 200)); - - await channelClient.markRead(); - - verify(mockDio.post('/channels/messaging/testid/read', - data: {})).called(1); - }); - - test('getReplies', () async { - final mockDio = MockDio(); - - when(mockDio.options).thenReturn(BaseOptions()); - when(mockDio.interceptors).thenReturn(Interceptors()); - - final client = StreamChatClient( - 'api-key', - httpClient: mockDio, - tokenProvider: (_) async => '', - ); - final channelClient = client.channel('messaging', id: 'testid'); - final pagination = PaginationParams(); - - when(mockDio.get('/messages/messageid/replies', - queryParameters: pagination.toJson())) - .thenAnswer((_) async => Response(data: '{}', statusCode: 200)); - - await channelClient.getReplies('messageid', pagination); - - verify(mockDio.get('/messages/messageid/replies', - queryParameters: pagination.toJson())) - .called(1); - }); - - test('sendAction', () async { - final mockDio = MockDio(); - - when(mockDio.options).thenReturn(BaseOptions()); - when(mockDio.interceptors).thenReturn(Interceptors()); - - final client = StreamChatClient( - 'api-key', - httpClient: mockDio, - tokenProvider: (_) async => '', - ); - final channelClient = client.channel('messaging', id: 'testid'); - - when(mockDio.post( - any, - data: anyNamed('data'), - )).thenAnswer((_) async => Response( - data: '{}', - statusCode: 200, - )); - await channelClient.watch(); - - final Map data = {'test': true}; - - when(mockDio.post('/messages/messageid/action', data: { - 'id': 'testid', - 'type': 'messaging', - 'form_data': data, - 'message_id': 'messageid', - })).thenAnswer((_) async => Response(data: '{}', statusCode: 200)); - - await channelClient.sendAction(Message(id: 'messageid'), data); - - verify(mockDio.post('/messages/messageid/action', data: { - 'id': 'testid', - 'type': 'messaging', - 'form_data': data, - 'message_id': 'messageid', - })).called(1); - }); - - test('getMessagesById', () async { - final mockDio = MockDio(); - - when(mockDio.options).thenReturn(BaseOptions()); - when(mockDio.interceptors).thenReturn(Interceptors()); - - final client = StreamChatClient( - 'api-key', - httpClient: mockDio, - tokenProvider: (_) async => '', - ); - final channelClient = client.channel('messaging', id: 'testid'); - final messageIds = ['a', 'b']; - - when(mockDio.get('/channels/messaging/testid/messages', - queryParameters: {'ids': messageIds.join(',')})) - .thenAnswer((_) async => Response(data: '{}', statusCode: 200)); - - await channelClient.getMessagesById(messageIds); - - verify(mockDio.get('/channels/messaging/testid/messages', - queryParameters: {'ids': messageIds.join(',')})).called(1); - }); - - test('sendFile', () async { - final mockDio = MockDio(); - final mockUploader = MockAttachmentUploader(); - - final file = AttachmentFile(path: 'filePath/fileName.pdf'); - final channelId = 'testId'; - final channelType = 'messaging'; - - when(mockDio.options).thenReturn(BaseOptions()); - when(mockDio.interceptors).thenReturn(Interceptors()); - - final client = StreamChatClient( - 'api-key', - httpClient: mockDio, - tokenProvider: (_) async => '', - attachmentFileUploader: mockUploader, - ); - final channelClient = client.channel(channelType, id: channelId); - - when(mockUploader.sendFile(file, channelId, channelType)) - .thenAnswer((_) async => SendFileResponse()); - - await channelClient.sendFile(file); - - verify(mockUploader.sendFile(file, channelId, channelType)).called(1); - }); - - test('sendImage', () async { - final mockDio = MockDio(); - final mockUploader = MockAttachmentUploader(); - - final image = AttachmentFile(path: 'imagePath/imageName.jpeg'); - final channelId = 'testId'; - final channelType = 'messaging'; - - when(mockDio.options).thenReturn(BaseOptions()); - when(mockDio.interceptors).thenReturn(Interceptors()); - - final client = StreamChatClient( - 'api-key', - httpClient: mockDio, - tokenProvider: (_) async => '', - attachmentFileUploader: mockUploader, - ); - final channelClient = client.channel(channelType, id: channelId); - - when(mockUploader.sendImage(image, channelId, channelType)) - .thenAnswer((_) async => SendImageResponse()); - - await channelClient.sendImage(image); - - verify(mockUploader.sendImage(image, channelId, channelType)).called(1); - }); - - test('deleteFile', () async { - final mockDio = MockDio(); - - when(mockDio.options).thenReturn(BaseOptions()); - when(mockDio.interceptors).thenReturn(Interceptors()); - - final client = StreamChatClient( - 'api-key', - httpClient: mockDio, - tokenProvider: (_) async => '', - ); - final channelClient = client.channel('messaging', id: 'testid'); - final url = 'url'; - - when(mockDio.delete('/channels/messaging/testid/file', - queryParameters: {'url': url})) - .thenAnswer((_) async => Response(data: '{}', statusCode: 200)); - - await channelClient.deleteFile(url); - - verify(mockDio.delete('/channels/messaging/testid/file', - queryParameters: {'url': url})).called(1); - }); - - test('deleteImage', () async { - final mockDio = MockDio(); - - when(mockDio.options).thenReturn(BaseOptions()); - when(mockDio.interceptors).thenReturn(Interceptors()); - - final client = StreamChatClient( - 'api-key', - httpClient: mockDio, - tokenProvider: (_) async => '', - ); - final channelClient = client.channel('messaging', id: 'testid'); - final url = 'url'; - - when(mockDio.delete('/channels/messaging/testid/image', - queryParameters: {'url': url})) - .thenAnswer((_) async => Response(data: '{}', statusCode: 200)); - - await channelClient.deleteImage(url); - - verify(mockDio.delete('/channels/messaging/testid/image', - queryParameters: {'url': url})).called(1); - }); - - test('pinMessage should throw argument error', () { - final client = StreamChatClient('api-key'); - - final channelClient = client.channel('messaging', id: 'testid'); - - final message = Message(text: 'Hello'); - - expect( - () => channelClient.pinMessage(message, 'InvalidType'), - throwsArgumentError, - ); - }); - - test('should be pinned successfully', () async { - final mockDio = MockDio(); - - when(mockDio.options).thenReturn(BaseOptions()); - when(mockDio.interceptors).thenReturn(Interceptors()); - - final client = StreamChatClient( - 'api-key', - httpClient: mockDio, - tokenProvider: (_) async => '', - ); - - final channelClient = client.channel('messaging', id: 'testid'); - - final message = Message( - text: 'Hello', - id: 'test', - ); - - when(mockDio.post( - '/messages/${message.id}', - data: anything, - )).thenAnswer((_) async => Response(data: '{}', statusCode: 200)); - - await channelClient.pinMessage(message, 30); - - verify(mockDio.post('/messages/${message.id}', data: anything)) - .called(1); - }); - - test('should be unpinned successfully', () async { - final mockDio = MockDio(); - - when(mockDio.options).thenReturn(BaseOptions()); - when(mockDio.interceptors).thenReturn(Interceptors()); - - final client = StreamChatClient( - 'api-key', - httpClient: mockDio, - tokenProvider: (_) async => '', - ); - - final channelClient = client.channel('messaging', id: 'testid'); - - final message = Message( - text: 'Hello', - id: 'test', - ); - - when(mockDio.post( - '/messages/${message.id}', - data: anything, - )).thenAnswer((_) async => Response(data: '{}', statusCode: 200)); - - await channelClient.unpinMessage(message); - - verify(mockDio.post('/messages/${message.id}', data: anything)) - .called(1); - }); - }); - - test('sendEvent', () async { - final mockDio = MockDio(); - - when(mockDio.options).thenReturn(BaseOptions()); - when(mockDio.interceptors).thenReturn(Interceptors()); - - final client = StreamChatClient( - 'api-key', - httpClient: mockDio, - tokenProvider: (_) async => '', - ); - final channelClient = client.channel('messaging', id: 'testid'); - - when(mockDio.post( - any, - data: anyNamed('data'), - )).thenAnswer((_) async => Response( - data: '{}', - statusCode: 200, - )); - await channelClient.watch(); - - final event = Event(type: EventType.any); - - when(mockDio.post('/channels/messaging/testid/event', - data: {'event': event.toJson()})) - .thenAnswer((_) async => Response(data: '{}', statusCode: 200)); - - await channelClient.sendEvent(event); - - verify(mockDio.post('/channels/messaging/testid/event', - data: {'event': event.toJson()})).called(1); - }); - - test('keyStroke', () async { - final mockDio = MockDio(); - - when(mockDio.options).thenReturn(BaseOptions()); - when(mockDio.interceptors).thenReturn(Interceptors()); - - final client = StreamChatClient( - 'api-key', - httpClient: mockDio, - tokenProvider: (_) async => '', - ); - final channelClient = client.channel('messaging', id: 'testid'); - - when(mockDio.post( - any, - data: anyNamed('data'), - )).thenAnswer((_) async => Response( - data: '{}', - statusCode: 200, - )); - await channelClient.watch(); - - final event = Event(type: EventType.typingStart); - - when(mockDio.post('/channels/messaging/testid/event', - data: {'event': event.toJson()})) - .thenAnswer((_) async => Response(data: '{}', statusCode: 200)); - - await channelClient.keyStroke(); - - verify(mockDio.post('/channels/messaging/testid/event', - data: {'event': event.toJson()})).called(1); - }); - - test('stopTyping', () async { - final mockDio = MockDio(); - - when(mockDio.options).thenReturn(BaseOptions()); - when(mockDio.interceptors).thenReturn(Interceptors()); - - final client = StreamChatClient( - 'api-key', - httpClient: mockDio, - tokenProvider: (_) async => '', - ); - final channelClient = client.channel('messaging', id: 'testid'); - - when(mockDio.post( - any, - data: anyNamed('data'), - )).thenAnswer((_) async => Response( - data: '{}', - statusCode: 200, - )); - await channelClient.watch(); - - final event = Event(type: EventType.typingStop); - - when(mockDio.post('/channels/messaging/testid/event', - data: {'event': event.toJson()})) - .thenAnswer((_) async => Response(data: '{}', statusCode: 200)); - - await channelClient.stopTyping(); - - verify(mockDio.post('/channels/messaging/testid/event', - data: {'event': event.toJson()})).called(1); - }); - - group('reactions', () { - test('sendReaction', () async { - final mockDio = MockDio(); - - when(mockDio.options).thenReturn(BaseOptions()); - when(mockDio.interceptors).thenReturn(Interceptors()); - - final client = StreamChatClient( - 'api-key', - httpClient: mockDio, - tokenProvider: (_) async => '', - )..state.user = OwnUser(id: 'test-id'); - - final channelClient = client.channel('messaging', id: 'testid'); - final reactionType = 'test'; - - when(mockDio.post( - '/messages/messageid/reaction', - data: { - 'reaction': { - 'type': reactionType, - }, - 'enforce_unique': false, - }, - )).thenAnswer((_) async => Response(data: '{}', statusCode: 200)); - - await channelClient.sendReaction( - Message( - id: 'messageid', - reactionCounts: const {}, - reactionScores: const {}, - latestReactions: const [], - ownReactions: const [], - ), - reactionType, - ); - - verify(mockDio.post('/messages/messageid/reaction', data: { - 'reaction': { - 'type': reactionType, - }, - 'enforce_unique': false, - })).called(1); - }); - - test('deleteReaction', () async { - final mockDio = MockDio(); - - when(mockDio.options).thenReturn(BaseOptions()); - when(mockDio.interceptors).thenReturn(Interceptors()); - - final client = StreamChatClient( - 'api-key', - httpClient: mockDio, - tokenProvider: (_) async => '', - )..state.user = OwnUser(id: 'test-id'); - - final channelClient = client.channel('messaging', id: 'testid'); - - when(mockDio.delete('/messages/messageid/reaction/test')) - .thenAnswer((_) async => Response(data: '{}', statusCode: 200)); - - await channelClient.deleteReaction( - Message( - id: 'messageid', - reactionCounts: const {}, - reactionScores: const {}, - latestReactions: const [], - ownReactions: const [], - ), - Reaction(type: 'test'), - ); - - verify(mockDio.delete('/messages/messageid/reaction/test')) - .called(1); - }); - - test('getReactions', () async { - final mockDio = MockDio(); - - when(mockDio.options).thenReturn(BaseOptions()); - when(mockDio.interceptors).thenReturn(Interceptors()); - - final client = StreamChatClient( - 'api-key', - httpClient: mockDio, - tokenProvider: (_) async => '', - ); - final channelClient = client.channel('messaging', id: 'testid'); - final pagination = PaginationParams(); - - when(mockDio.get('/messages/messageid/reactions', - queryParameters: pagination.toJson())) - .thenAnswer((_) async => Response(data: '{}', statusCode: 200)); - - await channelClient.getReactions('messageid', pagination); - - verify(mockDio.get('/messages/messageid/reactions', - queryParameters: pagination.toJson())) - .called(1); - }); - }); - - group('channel', () { - test('addMembers', () async { - final mockDio = MockDio(); - - when(mockDio.options).thenReturn(BaseOptions()); - when(mockDio.interceptors).thenReturn(Interceptors()); - - final client = StreamChatClient( - 'api-key', - httpClient: mockDio, - tokenProvider: (_) async => '', - ); - final channelClient = client.channel('messaging', id: 'testid'); - final members = ['vishal']; - final message = Message(text: 'test'); - - when(mockDio.post('/channels/messaging/testid', - data: {'add_members': members, 'message': message.toJson()})) - .thenAnswer((_) async => Response(data: '{}', statusCode: 200)); - - await channelClient.addMembers(members, message); - - verify(mockDio.post('/channels/messaging/testid', - data: {'add_members': members, 'message': message.toJson()})) - .called(1); - }); - - test('acceptInvite', () async { - final mockDio = MockDio(); - - when(mockDio.options).thenReturn(BaseOptions()); - when(mockDio.interceptors).thenReturn(Interceptors()); - - final client = StreamChatClient( - 'api-key', - httpClient: mockDio, - tokenProvider: (_) async => '', - ); - final channelClient = client.channel('messaging', id: 'testid'); - final message = Message(text: 'test'); - - when(mockDio.post('/channels/messaging/testid', - data: {'accept_invite': true, 'message': message.toJson()})) - .thenAnswer((_) async => Response(data: '{}', statusCode: 200)); - - await channelClient.acceptInvite(message); - - verify(mockDio.post('/channels/messaging/testid', - data: {'accept_invite': true, 'message': message.toJson()})) - .called(1); - }); - - group('query', () { - test('without id', () async { - final mockDio = MockDio(); - - when(mockDio.options).thenReturn(BaseOptions()); - when(mockDio.interceptors).thenReturn(Interceptors()); - - final client = StreamChatClient( - 'api-key', - httpClient: mockDio, - tokenProvider: (_) async => '', - ); - final channelClient = client.channel('messaging'); - final Map options = { - 'watch': true, - 'state': false, - 'presence': true, - }; - - when(mockDio.post('/channels/messaging/query', data: options)) - .thenAnswer((_) async { - return Response(data: r''' - { - "channel": { - "id": "!members-0LOcD0mZtTan60zHobLmELjdndXsonnBVNzZnB5mTt0", - "type": "messaging", - "cid": "messaging:!members-0LOcD0mZtTan60zHobLmELjdndXsonnBVNzZnB5mTt0", - "last_message_at": "2020-01-28T22:17:31.204287Z", - "created_at": "2020-01-28T22:17:31.00187Z", - "updated_at": "2020-01-28T22:17:31.00187Z", - "created_by": { - "id": "spring-voice-7", - "role": "user", - "created_at": "2020-01-28T22:17:30.834135Z", - "updated_at": "2020-01-28T22:17:31.186771Z", - "banned": false, - "online": false, - "image": "https://getstream.io/random_svg/?id=spring-voice-7\u0026name=Spring+voice", - "name": "Spring voice" - }, - "frozen": false, - "member_count": 2, - "config": { - "created_at": "2020-01-29T12:59:14.291912835Z", - "updated_at": "2020-01-29T12:59:14.291912991Z", - "name": "messaging", - "typing_events": true, - "read_events": true, - "connect_events": true, - "search": true, - "reactions": true, - "replies": true, - "mutes": true, - "uploads": true, - "url_enrichment": true, - "message_retention": "infinite", - "max_message_length": 5000, - "automod": "disabled", - "automod_behavior": "flag", - "commands": [ - { - "name": "giphy", - "description": "Post a random gif to the channel", - "args": "[text]", - "set": "fun_set" - } - ] - }, - "name": "Mia Denys", - "image": "https://randomuser.me/api/portraits/women/2.jpg" - }, - "messages": [ - { - "id": "4637f7e4-a06b-42db-ba5a-8d8270dd926f", - "text": "https://giphy.com/gifs/the-lion-king-live-action-5zvN79uTGfLMOVfQaA", - "html": "\u003cp\u003e\u003ca href=\"https://giphy.com/gifs/the-lion-king-live-action-5zvN79uTGfLMOVfQaA\" rel=\"nofollow\"\u003ehttps://giphy.com/gifs/the-lion-king-live-action-5zvN79uTGfLMOVfQaA\u003c/a\u003e\u003c/p\u003e\n", - "type": "regular", - "user": { - "id": "c1c9b454-2bcc-402d-8bb0-2f3706ce1680", - "role": "user", - "created_at": "2020-01-28T22:17:30.83015Z", - "updated_at": "2020-01-28T22:17:31.19435Z", - "banned": false, - "online": false, - "image": "https://randomuser.me/api/portraits/women/2.jpg", - "name": "Mia Denys" - }, - "attachments": [ - { - "type": "video", - "author_name": "GIPHY", - "title": "The Lion King Disney GIF - Find \u0026 Share on GIPHY", - "title_link": "https://media.giphy.com/media/5zvN79uTGfLMOVfQaA/giphy.gif", - "text": "Discover \u0026 share this Lion King Live Action GIF with everyone you know. GIPHY is how you search, share, discover, and create GIFs.", - "image_url": "https://media.giphy.com/media/5zvN79uTGfLMOVfQaA/giphy.gif", - "thumb_url": "https://media.giphy.com/media/5zvN79uTGfLMOVfQaA/giphy.gif", - "asset_url": "https://media.giphy.com/media/5zvN79uTGfLMOVfQaA/giphy.mp4", - "og_scrape_url": "https://giphy.com/gifs/the-lion-king-live-action-5zvN79uTGfLMOVfQaA" - } - ], - "latest_reactions": [ - { - "message_id": "4637f7e4-a06b-42db-ba5a-8d8270dd926f", - "user_id": "c1c9b454-2bcc-402d-8bb0-2f3706ce1680", - "user": { - "id": "c1c9b454-2bcc-402d-8bb0-2f3706ce1680", - "role": "user", - "created_at": "2020-01-28T22:17:30.83015Z", - "updated_at": "2020-01-28T22:17:31.19435Z", - "banned": false, - "online": false, - "image": "https://randomuser.me/api/portraits/women/2.jpg", - "name": "Mia Denys" - }, - "type": "love", - "score": 1, - "created_at": "2020-01-28T22:17:31.128376Z", - "updated_at": "2020-01-28T22:17:31.128376Z" - } - ], - "own_reactions": [], - "reaction_counts": { - "love": 1 - }, - "reaction_scores": { - "love": 1 - }, - "reply_count": 0, - "created_at": "2020-01-28T22:17:31.107978Z", - "updated_at": "2020-01-28T22:17:31.130506Z", - "mentioned_users": [] - }, - { - "id": "16e19c46-fb96-4f89-8031-d5bd2ce3bd64", - "text": "Few can name a topfull mother that isn't a breezeless damage.", - "html": "\u003cp\u003eFew can name a topfull mother that isn’t a breezeless damage.\u003c/p\u003e\n", - "type": "regular", - "user": { - "id": "spring-voice-7", - "role": "user", - "created_at": "2020-01-28T22:17:30.834135Z", - "updated_at": "2020-01-28T22:17:31.186771Z", - "banned": false, - "online": false, - "image": "https://getstream.io/random_svg/?id=spring-voice-7\u0026name=Spring+voice", - "name": "Spring voice" - }, - "attachments": [], - "latest_reactions": [], - "own_reactions": [], - "reaction_counts": {}, - "reaction_scores": {}, - "reply_count": 0, - "created_at": "2020-01-28T22:17:31.153518Z", - "updated_at": "2020-01-28T22:17:31.153518Z", - "mentioned_users": [] - }, - { - "id": "38b1b252-c9a6-4aea-a39e-8de3b7f7604b", - "text": "https://giphy.com/gifs/beard-muscle-73Sjhw0N4hyog", - "html": "\u003cp\u003e\u003ca href=\"https://giphy.com/gifs/beard-muscle-73Sjhw0N4hyog\" rel=\"nofollow\"\u003ehttps://giphy.com/gifs/beard-muscle-73Sjhw0N4hyog\u003c/a\u003e\u003c/p\u003e\n", - "type": "regular", - "user": { - "id": "spring-voice-7", - "role": "user", - "created_at": "2020-01-28T22:17:30.834135Z", - "updated_at": "2020-01-28T22:17:31.186771Z", - "banned": false, - "online": false, - "image": "https://getstream.io/random_svg/?id=spring-voice-7\u0026name=Spring+voice", - "name": "Spring voice" - }, - "attachments": [ - { - "type": "video", - "author_name": "GIPHY", - "title": "Moustache Thumbs Up GIF - Find \u0026 Share on GIPHY", - "title_link": "https://media.giphy.com/media/73Sjhw0N4hyog/giphy.gif", - "text": "Discover \u0026 share this Pouce Leve GIF with everyone you know. GIPHY is how you search, share, discover, and create GIFs.", - "image_url": "https://media.giphy.com/media/73Sjhw0N4hyog/giphy.gif", - "thumb_url": "https://media.giphy.com/media/73Sjhw0N4hyog/giphy.gif", - "asset_url": "https://media.giphy.com/media/73Sjhw0N4hyog/giphy.mp4", - "og_scrape_url": "https://giphy.com/gifs/beard-muscle-73Sjhw0N4hyog" - } - ], - "latest_reactions": [], - "own_reactions": [], - "reaction_counts": {}, - "reaction_scores": {}, - "reply_count": 0, - "created_at": "2020-01-28T22:17:31.155428Z", - "updated_at": "2020-01-28T22:17:31.155428Z", - "mentioned_users": [] - }, - { - "id": "5c7d0c68-72d5-4027-b6b7-711b342d0fc4", - "text": "The carbons could be said to resemble smartish hoods.", - "html": "\u003cp\u003eThe carbons could be said to resemble smartish hoods.\u003c/p\u003e\n", - "type": "regular", - "user": { - "id": "c1c9b454-2bcc-402d-8bb0-2f3706ce1680", - "role": "user", - "created_at": "2020-01-28T22:17:30.83015Z", - "updated_at": "2020-01-28T22:17:31.19435Z", - "banned": false, - "online": false, - "image": "https://randomuser.me/api/portraits/women/2.jpg", - "name": "Mia Denys" - }, - "attachments": [], - "latest_reactions": [], - "own_reactions": [], - "reaction_counts": {}, - "reaction_scores": {}, - "reply_count": 1, - "created_at": "2020-01-28T22:17:31.157811Z", - "updated_at": "2020-01-28T22:17:31.157811Z", - "mentioned_users": [] - }, - { - "id": "5b02535a-0c3c-45fa-9cf8-16f840c5123f", - "text": "Their software was, in this moment, a prolix feature.", - "html": "\u003cp\u003eTheir software was, in this moment, a prolix feature.\u003c/p\u003e\n", - "type": "regular", - "user": { - "id": "c1c9b454-2bcc-402d-8bb0-2f3706ce1680", - "role": "user", - "created_at": "2020-01-28T22:17:30.83015Z", - "updated_at": "2020-01-28T22:17:31.19435Z", - "banned": false, - "online": false, - "image": "https://randomuser.me/api/portraits/women/2.jpg", - "name": "Mia Denys" - }, - "attachments": [], - "latest_reactions": [], - "own_reactions": [], - "reaction_counts": {}, - "reaction_scores": {}, - "reply_count": 1, - "created_at": "2020-01-28T22:17:31.158391Z", - "updated_at": "2020-01-28T22:17:31.158391Z", - "mentioned_users": [] - } + test('should emit received message if not null', () async { + final message = Message(id: 'test-message-id', text: 'Action Sent'); + const formData = {'key': 'value'}; + + when( + () => client.sendAction(channelId, channelType, message.id, formData), + ).thenAnswer((_) async => SendActionResponse()..message = message); + + expectLater( + // skipping first seed message list -> [] messages + channel.state?.messagesStream.skip(1), + emitsInOrder([ + [ + isSameMessageAs( + message, + matchSendingStatus: true, + ), ], - "watcher_count": 1, - "read": [ - { - "user": { - "id": "c1c9b454-2bcc-402d-8bb0-2f3706ce1680", - "role": "user", - "created_at": "2020-01-28T22:17:30.83015Z", - "updated_at": "2020-01-28T22:17:31.19435Z", - "banned": false, - "online": false, - "image": "https://randomuser.me/api/portraits/women/2.jpg", - "name": "Mia Denys" - }, - "last_read": "2020-01-28T22:17:31.016937728Z" - }, - { - "user": { - "id": "spring-voice-7", - "role": "user", - "created_at": "2020-01-28T22:17:30.834135Z", - "updated_at": "2020-01-28T22:17:31.186771Z", - "banned": false, - "online": false, - "image": "https://getstream.io/random_svg/?id=spring-voice-7\u0026name=Spring+voice", - "name": "Spring voice" - }, - "last_read": "2020-01-28T22:17:31.018856448Z" - } - ], - "members": [ - { - "user": { - "id": "c1c9b454-2bcc-402d-8bb0-2f3706ce1680", - "role": "user", - "created_at": "2020-01-28T22:17:30.83015Z", - "updated_at": "2020-01-28T22:17:31.19435Z", - "banned": false, - "online": false, - "image": "https://randomuser.me/api/portraits/women/2.jpg", - "name": "Mia Denys" - }, - "role": "member", - "created_at": "2020-01-28T22:17:31.005135Z", - "updated_at": "2020-01-28T22:17:31.005135Z" - }, - { - "user": { - "id": "spring-voice-7", - "role": "user", - "created_at": "2020-01-28T22:17:30.834135Z", - "updated_at": "2020-01-28T22:17:31.186771Z", - "banned": false, - "online": false, - "image": "https://getstream.io/random_svg/?id=spring-voice-7\u0026name=Spring+voice", - "name": "Spring voice" - }, - "role": "owner", - "created_at": "2020-01-28T22:17:31.005135Z", - "updated_at": "2020-01-28T22:17:31.005135Z" - } - ] + ]), + ); + + final res = await channel.sendAction(message, formData); + + expect(res, isNotNull); + expect(res.message?.id, message.id); + + verify( + () => client.sendAction(channelId, channelType, message.id, formData), + ).called(1); + }); + }); + + test('`.markRead`', () async { + const messageId = 'test-message-id'; + + when(() => client.markChannelRead(channelId, channelType, + messageId: messageId)).thenAnswer((_) async => EmptyResponse()); + + final res = await channel.markRead(messageId: messageId); + + expect(res, isNotNull); + expect(client.state.totalUnreadCount, 0); + + verify(() => client.markChannelRead(channelId, channelType, + messageId: messageId)).called(1); + }); + + group('`.watch`', () { + test('should work fine', () async { + when(() => client.queryChannel( + channelType, + channelId: channelId, + watch: true, + channelData: any(named: 'channelData'), + messagesPagination: any(named: 'messagesPagination'), + membersPagination: any(named: 'membersPagination'), + watchersPagination: any(named: 'watchersPagination'), + )).thenAnswer( + (_) async => _generateChannelState(channelId, channelType), + ); + + final res = await channel.watch(); + + expect(res, isNotNull); + expect(res.channel, isNotNull); + expect(res.channel?.cid, channelCid); + + verify(() => client.queryChannel( + channelType, + channelId: channelId, + watch: true, + channelData: any(named: 'channelData'), + messagesPagination: any(named: 'messagesPagination'), + membersPagination: any(named: 'membersPagination'), + watchersPagination: any(named: 'watchersPagination'), + )).called(1); + }); + + test('should rethrow if `.query` throws', () async { + when(() => client.queryChannel( + channelType, + channelId: channelId, + watch: true, + channelData: any(named: 'channelData'), + messagesPagination: any(named: 'messagesPagination'), + membersPagination: any(named: 'membersPagination'), + watchersPagination: any(named: 'watchersPagination'), + )).thenThrow(StreamChatNetworkError(ChatErrorCode.inputError)); + + try { + await channel.watch(); + } catch (e) { + expect(e, isA()); } - ''', statusCode: 200); - }); - final response = await channelClient.query(options: options); + verify(() => client.queryChannel( + channelType, + channelId: channelId, + watch: true, + channelData: any(named: 'channelData'), + messagesPagination: any(named: 'messagesPagination'), + membersPagination: any(named: 'membersPagination'), + watchersPagination: any(named: 'watchersPagination'), + )).called(1); + }); + }); - verify(mockDio.post('/channels/messaging/query', - data: options)) - .called(1); - expect(channelClient.id, response.channel.id); - expect(channelClient.cid, response.channel.cid); + test('`.stopWatching`', () async { + when(() => client.stopChannelWatching(channelId, channelType)) + .thenAnswer((_) async => EmptyResponse()); + + final res = await channel.stopWatching(); + + expect(res, isNotNull); + + verify(() => client.stopChannelWatching(channelId, channelType)) + .called(1); + }); + + test('`.getReplies`', () async { + const parentId = 'test-parent-id'; + + final messages = List.generate( + 3, + (index) => Message( + id: 'test-message-id-$index', + parentId: parentId, + ), + ); + + when(() => client.getReplies(parentId)).thenAnswer( + (_) async => QueryRepliesResponse()..messages = messages, + ); + + final res = await channel.getReplies(parentId); + + expect(res, isNotNull); + expect(res.messages.length, messages.length); + expect(res.messages.every((it) => it.parentId == parentId), isTrue); + + verify(() => client.getReplies(parentId)).called(1); + }); + + test('`.getReactions`', () async { + const messageId = 'test-message-id'; + + final reactions = List.generate( + 3, + (index) => Reaction( + type: 'test-reaction-type-$index', + messageId: messageId, + ), + ); + + when(() => client.getReactions(messageId)).thenAnswer( + (_) async => QueryReactionsResponse()..reactions = reactions, + ); + + final res = await channel.getReactions(messageId); + + expect(res, isNotNull); + expect(res.reactions.length, reactions.length); + expect(res.reactions.every((it) => it.messageId == messageId), isTrue); + + verify(() => client.getReactions(messageId)).called(1); + }); + + test('`.getMessagesById`', () async { + final messages = List.generate( + 3, + (index) => Message(id: 'test-message-id-$index'), + ); + + final messageIds = messages.map((it) => it.id).toList(growable: false); + + when(() => client.getMessagesById(channelId, channelType, messageIds)) + .thenAnswer( + (_) async => GetMessagesByIdResponse()..messages = messages, + ); + + final res = await channel.getMessagesById(messageIds); + + expect(res, isNotNull); + expect(res.messages.length, messageIds.length); + + verify( + () => client.getMessagesById(channelId, channelType, messageIds), + ).called(1); + }); + + test('`.translateMessage`', () async { + const messageId = 'test-message-id'; + const language = 'hi'; // Hindi + const translatedMessageText = 'नमस्ते'; + final translatedMessage = TranslatedMessage(const { + language: translatedMessageText, + }); + + when(() => client.translateMessage(messageId, language)).thenAnswer( + (_) async => TranslateMessageResponse()..message = translatedMessage, + ); + + final res = await channel.translateMessage(messageId, language); + + expect(res, isNotNull); + expect(res.message.i18n, translatedMessage.i18n); + + verify(() => client.translateMessage(messageId, language)).called(1); + }); + + group('`.query`', () { + test('should work fine', () async { + final channelState = _generateChannelState(channelId, channelType); + + when( + () => client.queryChannel( + channelType, + channelId: channelId, + channelData: any(named: 'channelData'), + messagesPagination: any(named: 'messagesPagination'), + membersPagination: any(named: 'membersPagination'), + watchersPagination: any(named: 'watchersPagination'), + ), + ).thenAnswer((_) async => channelState); + + final res = await channel.query(); + + expect(res, isNotNull); + + verify( + () => client.queryChannel( + channelType, + channelId: channelId, + channelData: any(named: 'channelData'), + messagesPagination: any(named: 'messagesPagination'), + membersPagination: any(named: 'membersPagination'), + watchersPagination: any(named: 'watchersPagination'), + ), + ).called(1); + }); + + test('should rethrow if `client.queryChannel` throws', () async { + when( + () => client.queryChannel( + channelType, + channelId: channelId, + channelData: any(named: 'channelData'), + messagesPagination: any(named: 'messagesPagination'), + membersPagination: any(named: 'membersPagination'), + watchersPagination: any(named: 'watchersPagination'), + ), + ).thenThrow(StreamChatNetworkError(ChatErrorCode.inputError)); + + try { + await channel.query(); + } catch (e) { + expect(e, isA()); + } + + verify( + () => client.queryChannel( + channelType, + channelId: channelId, + channelData: any(named: 'channelData'), + messagesPagination: any(named: 'messagesPagination'), + membersPagination: any(named: 'membersPagination'), + watchersPagination: any(named: 'watchersPagination'), + ), + ).called(1); + }); + }); + + test('`.queryMembers`', () async { + final filter = Filter.in_('cid', const [channelCid]); + + final members = List.generate( + 3, + (index) => Member(userId: 'test-user-id-$index'), + ); + + when(() => client.queryMembers( + channelType, + channelId: channelId, + filter: filter, + members: any(named: 'members'), + sort: any(named: 'sort'), + pagination: any(named: 'pagination'), + )).thenAnswer((_) async => QueryMembersResponse()..members = members); + + final res = await channel.queryMembers(filter: filter); + + expect(res, isNotNull); + expect(res.members.length, members.length); + + verify(() => client.queryMembers( + channelType, + channelId: channelId, + filter: filter, + members: any(named: 'members'), + sort: any(named: 'sort'), + pagination: any(named: 'pagination'), + )).called(1); + }); + + test('`.mute`', () async { + when(() => client.muteChannel( + channelCid, + expiration: any(named: 'expiration'), + )).thenAnswer((_) async => EmptyResponse()); + + final res = await channel.mute(); + + expect(res, isNotNull); + + verify(() => client.muteChannel( + channelCid, + expiration: any(named: 'expiration'), + )).called(1); + }); + + test('`.unmute`', () async { + when( + () => client.unmuteChannel(channelCid), + ).thenAnswer((_) async => EmptyResponse()); + + final res = await channel.unmute(); + + expect(res, isNotNull); + + verify( + () => client.unmuteChannel(channelCid), + ).called(1); + }); + + test('`.banUser`', () async { + const userId = 'test-user-id'; + const options = {'key': 'value'}; + + when(() => client.banUser( + userId, + {'type': channelType, 'id': channelId, ...options}, + )).thenAnswer((_) async => EmptyResponse()); + + final res = await channel.banUser(userId, options); + + expect(res, isNotNull); + + verify(() => client.banUser( + userId, + {'type': channelType, 'id': channelId, ...options}, + )).called(1); + }); + + test('`.unbanUser`', () async { + const userId = 'test-user-id'; + + when(() => client.unbanUser(userId, any())) + .thenAnswer((_) async => EmptyResponse()); + + final res = await channel.unbanUser(userId); + + expect(res, isNotNull); + + verify(() => client.unbanUser(userId, any())).called(1); + }); + + test('`.shadowBan`', () async { + const userId = 'test-user-id'; + const options = {'key': 'value'}; + + when(() => client.shadowBan( + userId, + {'type': channelType, 'id': channelId, ...options}, + )).thenAnswer((_) async => EmptyResponse()); + + final res = await channel.shadowBan(userId, options); + + expect(res, isNotNull); + + verify(() => client.shadowBan( + userId, + {'type': channelType, 'id': channelId, ...options}, + )).called(1); + }); + + test('`.removeShadowBan`', () async { + const userId = 'test-user-id'; + + when(() => client.removeShadowBan(userId, any())) + .thenAnswer((_) async => EmptyResponse()); + + final res = await channel.removeShadowBan(userId); + + expect(res, isNotNull); + + verify(() => client.removeShadowBan(userId, any())).called(1); + }); + + test('`.hide`', () async { + const clearHistory = true; + + when(() => client.hideChannel( + channelId, + channelType, + clearHistory: clearHistory, + )).thenAnswer((_) async => EmptyResponse()); + + final res = await channel.hide(clearHistory: clearHistory); + + expect(res, isNotNull); + + verify(() => client.hideChannel( + channelId, + channelType, + clearHistory: clearHistory, + )).called(1); + }); + + test('`.show`', () async { + when(() => client.showChannel(channelId, channelType)) + .thenAnswer((_) async => EmptyResponse()); + + final res = await channel.show(); + + expect(res, isNotNull); + + verify(() => client.showChannel(channelId, channelType)).called(1); + }); + + test('`.on`', () async { + const eventType = 'test.event'; + final event = Event(type: eventType, cid: channelCid); + + when(() => client.on(eventType, any(), any(), any())) + .thenAnswer((_) => Stream.value(event)); + + expectLater(channel.on(eventType), emitsInOrder([event])); + + verify(() => client.on(eventType, any(), any(), any())).called(1); + }); + + group( + '`.keyStroke`', + () { + test('should return if `config.typingEvents` is false', () async { + when(() => channel.config?.typingEvents).thenReturn(false); + + final typingEvent = Event(type: EventType.typingStart); + + await channel.keyStroke(); + + verifyNever(() => client.sendEvent( + channelId, + channelType, + any(that: isSameEventAs(typingEvent)), + )); }); - test('with id', () async { - final mockDio = MockDio(); + test( + '''should send `typingStart` event if there is not already a typingEvent or the difference between the two is >= 2 seconds''', + () async { + final typingEvent = Event(type: EventType.typingStart); - when(mockDio.options).thenReturn(BaseOptions()); - when(mockDio.interceptors).thenReturn(Interceptors()); + when(() => channel.config?.typingEvents).thenReturn(true); - final client = StreamChatClient( - 'api-key', - httpClient: mockDio, - tokenProvider: (_) async => '', - ); - final channelClient = client.channel('messaging', id: 'testid'); - final Map options = { - 'state': false, - }; + when(() => client.sendEvent( + channelId, + channelType, + any(that: isSameEventAs(typingEvent)), + )).thenAnswer((_) async => EmptyResponse()); - when(mockDio.post('/channels/messaging/testid/query', - data: options)) - .thenAnswer((_) async => Response(data: r''' - { - "channel": { - "id": "!members-0LOcD0mZtTan60zHobLmELjdndXsonnBVNzZnB5mTt0", - "type": "messaging", - "cid": "messaging:!members-0LOcD0mZtTan60zHobLmELjdndXsonnBVNzZnB5mTt0", - "last_message_at": "2020-01-28T22:17:31.204287Z", - "created_at": "2020-01-28T22:17:31.00187Z", - "updated_at": "2020-01-28T22:17:31.00187Z", - "created_by": { - "id": "spring-voice-7", - "role": "user", - "created_at": "2020-01-28T22:17:30.834135Z", - "updated_at": "2020-01-28T22:17:31.186771Z", - "banned": false, - "online": false, - "image": "https://getstream.io/random_svg/?id=spring-voice-7\u0026name=Spring+voice", - "name": "Spring voice" - }, - "frozen": false, - "member_count": 2, - "config": { - "created_at": "2020-01-29T12:59:14.291912835Z", - "updated_at": "2020-01-29T12:59:14.291912991Z", - "name": "messaging", - "typing_events": true, - "read_events": true, - "connect_events": true, - "search": true, - "reactions": true, - "replies": true, - "mutes": true, - "uploads": true, - "url_enrichment": true, - "message_retention": "infinite", - "max_message_length": 5000, - "automod": "disabled", - "automod_behavior": "flag", - "commands": [ - { - "name": "giphy", - "description": "Post a random gif to the channel", - "args": "[text]", - "set": "fun_set" - } - ] - }, - "name": "Mia Denys", - "image": "https://randomuser.me/api/portraits/women/2.jpg" - }, - "messages": [ - { - "id": "4637f7e4-a06b-42db-ba5a-8d8270dd926f", - "text": "https://giphy.com/gifs/the-lion-king-live-action-5zvN79uTGfLMOVfQaA", - "html": "\u003cp\u003e\u003ca href=\"https://giphy.com/gifs/the-lion-king-live-action-5zvN79uTGfLMOVfQaA\" rel=\"nofollow\"\u003ehttps://giphy.com/gifs/the-lion-king-live-action-5zvN79uTGfLMOVfQaA\u003c/a\u003e\u003c/p\u003e\n", - "type": "regular", - "user": { - "id": "c1c9b454-2bcc-402d-8bb0-2f3706ce1680", - "role": "user", - "created_at": "2020-01-28T22:17:30.83015Z", - "updated_at": "2020-01-28T22:17:31.19435Z", - "banned": false, - "online": false, - "image": "https://randomuser.me/api/portraits/women/2.jpg", - "name": "Mia Denys" - }, - "attachments": [ - { - "type": "video", - "author_name": "GIPHY", - "title": "The Lion King Disney GIF - Find \u0026 Share on GIPHY", - "title_link": "https://media.giphy.com/media/5zvN79uTGfLMOVfQaA/giphy.gif", - "text": "Discover \u0026 share this Lion King Live Action GIF with everyone you know. GIPHY is how you search, share, discover, and create GIFs.", - "image_url": "https://media.giphy.com/media/5zvN79uTGfLMOVfQaA/giphy.gif", - "thumb_url": "https://media.giphy.com/media/5zvN79uTGfLMOVfQaA/giphy.gif", - "asset_url": "https://media.giphy.com/media/5zvN79uTGfLMOVfQaA/giphy.mp4", - "og_scrape_url": "https://giphy.com/gifs/the-lion-king-live-action-5zvN79uTGfLMOVfQaA" - } - ], - "latest_reactions": [ - { - "message_id": "4637f7e4-a06b-42db-ba5a-8d8270dd926f", - "user_id": "c1c9b454-2bcc-402d-8bb0-2f3706ce1680", - "user": { - "id": "c1c9b454-2bcc-402d-8bb0-2f3706ce1680", - "role": "user", - "created_at": "2020-01-28T22:17:30.83015Z", - "updated_at": "2020-01-28T22:17:31.19435Z", - "banned": false, - "online": false, - "image": "https://randomuser.me/api/portraits/women/2.jpg", - "name": "Mia Denys" - }, - "type": "love", - "score": 1, - "created_at": "2020-01-28T22:17:31.128376Z", - "updated_at": "2020-01-28T22:17:31.128376Z" - } - ], - "own_reactions": [], - "reaction_counts": { - "love": 1 - }, - "reaction_scores": { - "love": 1 - }, - "reply_count": 0, - "created_at": "2020-01-28T22:17:31.107978Z", - "updated_at": "2020-01-28T22:17:31.130506Z", - "mentioned_users": [] - }, - { - "id": "16e19c46-fb96-4f89-8031-d5bd2ce3bd64", - "text": "Few can name a topfull mother that isn't a breezeless damage.", - "html": "\u003cp\u003eFew can name a topfull mother that isn’t a breezeless damage.\u003c/p\u003e\n", - "type": "regular", - "user": { - "id": "spring-voice-7", - "role": "user", - "created_at": "2020-01-28T22:17:30.834135Z", - "updated_at": "2020-01-28T22:17:31.186771Z", - "banned": false, - "online": false, - "image": "https://getstream.io/random_svg/?id=spring-voice-7\u0026name=Spring+voice", - "name": "Spring voice" - }, - "attachments": [], - "latest_reactions": [], - "own_reactions": [], - "reaction_counts": {}, - "reaction_scores": {}, - "reply_count": 0, - "created_at": "2020-01-28T22:17:31.153518Z", - "updated_at": "2020-01-28T22:17:31.153518Z", - "mentioned_users": [] - }, - { - "id": "38b1b252-c9a6-4aea-a39e-8de3b7f7604b", - "text": "https://giphy.com/gifs/beard-muscle-73Sjhw0N4hyog", - "html": "\u003cp\u003e\u003ca href=\"https://giphy.com/gifs/beard-muscle-73Sjhw0N4hyog\" rel=\"nofollow\"\u003ehttps://giphy.com/gifs/beard-muscle-73Sjhw0N4hyog\u003c/a\u003e\u003c/p\u003e\n", - "type": "regular", - "user": { - "id": "spring-voice-7", - "role": "user", - "created_at": "2020-01-28T22:17:30.834135Z", - "updated_at": "2020-01-28T22:17:31.186771Z", - "banned": false, - "online": false, - "image": "https://getstream.io/random_svg/?id=spring-voice-7\u0026name=Spring+voice", - "name": "Spring voice" - }, - "attachments": [ - { - "type": "video", - "author_name": "GIPHY", - "title": "Moustache Thumbs Up GIF - Find \u0026 Share on GIPHY", - "title_link": "https://media.giphy.com/media/73Sjhw0N4hyog/giphy.gif", - "text": "Discover \u0026 share this Pouce Leve GIF with everyone you know. GIPHY is how you search, share, discover, and create GIFs.", - "image_url": "https://media.giphy.com/media/73Sjhw0N4hyog/giphy.gif", - "thumb_url": "https://media.giphy.com/media/73Sjhw0N4hyog/giphy.gif", - "asset_url": "https://media.giphy.com/media/73Sjhw0N4hyog/giphy.mp4", - "og_scrape_url": "https://giphy.com/gifs/beard-muscle-73Sjhw0N4hyog" - } - ], - "latest_reactions": [], - "own_reactions": [], - "reaction_counts": {}, - "reaction_scores": {}, - "reply_count": 0, - "created_at": "2020-01-28T22:17:31.155428Z", - "updated_at": "2020-01-28T22:17:31.155428Z", - "mentioned_users": [] - }, - { - "id": "5c7d0c68-72d5-4027-b6b7-711b342d0fc4", - "text": "The carbons could be said to resemble smartish hoods.", - "html": "\u003cp\u003eThe carbons could be said to resemble smartish hoods.\u003c/p\u003e\n", - "type": "regular", - "user": { - "id": "c1c9b454-2bcc-402d-8bb0-2f3706ce1680", - "role": "user", - "created_at": "2020-01-28T22:17:30.83015Z", - "updated_at": "2020-01-28T22:17:31.19435Z", - "banned": false, - "online": false, - "image": "https://randomuser.me/api/portraits/women/2.jpg", - "name": "Mia Denys" - }, - "attachments": [], - "latest_reactions": [], - "own_reactions": [], - "reaction_counts": {}, - "reaction_scores": {}, - "reply_count": 1, - "created_at": "2020-01-28T22:17:31.157811Z", - "updated_at": "2020-01-28T22:17:31.157811Z", - "mentioned_users": [] - }, - { - "id": "5b02535a-0c3c-45fa-9cf8-16f840c5123f", - "text": "Their software was, in this moment, a prolix feature.", - "html": "\u003cp\u003eTheir software was, in this moment, a prolix feature.\u003c/p\u003e\n", - "type": "regular", - "user": { - "id": "c1c9b454-2bcc-402d-8bb0-2f3706ce1680", - "role": "user", - "created_at": "2020-01-28T22:17:30.83015Z", - "updated_at": "2020-01-28T22:17:31.19435Z", - "banned": false, - "online": false, - "image": "https://randomuser.me/api/portraits/women/2.jpg", - "name": "Mia Denys" - }, - "attachments": [], - "latest_reactions": [], - "own_reactions": [], - "reaction_counts": {}, - "reaction_scores": {}, - "reply_count": 1, - "created_at": "2020-01-28T22:17:31.158391Z", - "updated_at": "2020-01-28T22:17:31.158391Z", - "mentioned_users": [] - } - ], - "watcher_count": 1, - "read": [ - { - "user": { - "id": "c1c9b454-2bcc-402d-8bb0-2f3706ce1680", - "role": "user", - "created_at": "2020-01-28T22:17:30.83015Z", - "updated_at": "2020-01-28T22:17:31.19435Z", - "banned": false, - "online": false, - "image": "https://randomuser.me/api/portraits/women/2.jpg", - "name": "Mia Denys" - }, - "last_read": "2020-01-28T22:17:31.016937728Z" - }, - { - "user": { - "id": "spring-voice-7", - "role": "user", - "created_at": "2020-01-28T22:17:30.834135Z", - "updated_at": "2020-01-28T22:17:31.186771Z", - "banned": false, - "online": false, - "image": "https://getstream.io/random_svg/?id=spring-voice-7\u0026name=Spring+voice", - "name": "Spring voice" - }, - "last_read": "2020-01-28T22:17:31.018856448Z" - } - ], - "members": [ - { - "user": { - "id": "c1c9b454-2bcc-402d-8bb0-2f3706ce1680", - "role": "user", - "created_at": "2020-01-28T22:17:30.83015Z", - "updated_at": "2020-01-28T22:17:31.19435Z", - "banned": false, - "online": false, - "image": "https://randomuser.me/api/portraits/women/2.jpg", - "name": "Mia Denys" - }, - "role": "member", - "created_at": "2020-01-28T22:17:31.005135Z", - "updated_at": "2020-01-28T22:17:31.005135Z" - }, - { - "user": { - "id": "spring-voice-7", - "role": "user", - "created_at": "2020-01-28T22:17:30.834135Z", - "updated_at": "2020-01-28T22:17:31.186771Z", - "banned": false, - "online": false, - "image": "https://getstream.io/random_svg/?id=spring-voice-7\u0026name=Spring+voice", - "name": "Spring voice" - }, - "role": "owner", - "created_at": "2020-01-28T22:17:31.005135Z", - "updated_at": "2020-01-28T22:17:31.005135Z" - } - ] - } - ''', statusCode: 200)); + await channel.keyStroke(); - await channelClient.query(options: options); - - verify(mockDio.post('/channels/messaging/testid/query', - data: options)) - .called(1); - }); - }); - - test('create', () async { - final mockDio = MockDio(); - - when(mockDio.options).thenReturn(BaseOptions()); - when(mockDio.interceptors).thenReturn(Interceptors()); - - final client = StreamChatClient( - 'api-key', - httpClient: mockDio, - tokenProvider: (_) async => '', + verify(() => client.sendEvent( + channelId, + channelType, + any(that: isSameEventAs(typingEvent)), + )).called(1); + }, ); - final channelClient = client.channel('messaging'); - final Map options = { - 'watch': false, - 'state': false, - 'presence': false, - }; + }, + ); - when(mockDio.post('/channels/messaging/query', data: options)) - .thenAnswer((_) async => Response(data: r''' - { - "channel": { - "id": "!members-0LOcD0mZtTan60zHobLmELjdndXsonnBVNzZnB5mTt0", - "type": "messaging", - "cid": "messaging:!members-0LOcD0mZtTan60zHobLmELjdndXsonnBVNzZnB5mTt0", - "last_message_at": "2020-01-28T22:17:31.204287Z", - "created_at": "2020-01-28T22:17:31.00187Z", - "updated_at": "2020-01-28T22:17:31.00187Z", - "created_by": { - "id": "spring-voice-7", - "role": "user", - "created_at": "2020-01-28T22:17:30.834135Z", - "updated_at": "2020-01-28T22:17:31.186771Z", - "banned": false, - "online": false, - "image": "https://getstream.io/random_svg/?id=spring-voice-7\u0026name=Spring+voice", - "name": "Spring voice" - }, - "frozen": false, - "member_count": 2, - "config": { - "created_at": "2020-01-29T12:59:14.291912835Z", - "updated_at": "2020-01-29T12:59:14.291912991Z", - "name": "messaging", - "typing_events": true, - "read_events": true, - "connect_events": true, - "search": true, - "reactions": true, - "replies": true, - "mutes": true, - "uploads": true, - "url_enrichment": true, - "message_retention": "infinite", - "max_message_length": 5000, - "automod": "disabled", - "automod_behavior": "flag", - "commands": [ - { - "name": "giphy", - "description": "Post a random gif to the channel", - "args": "[text]", - "set": "fun_set" - } - ] - }, - "name": "Mia Denys", - "image": "https://randomuser.me/api/portraits/women/2.jpg" - }, - "messages": [ - { - "id": "4637f7e4-a06b-42db-ba5a-8d8270dd926f", - "text": "https://giphy.com/gifs/the-lion-king-live-action-5zvN79uTGfLMOVfQaA", - "html": "\u003cp\u003e\u003ca href=\"https://giphy.com/gifs/the-lion-king-live-action-5zvN79uTGfLMOVfQaA\" rel=\"nofollow\"\u003ehttps://giphy.com/gifs/the-lion-king-live-action-5zvN79uTGfLMOVfQaA\u003c/a\u003e\u003c/p\u003e\n", - "type": "regular", - "user": { - "id": "c1c9b454-2bcc-402d-8bb0-2f3706ce1680", - "role": "user", - "created_at": "2020-01-28T22:17:30.83015Z", - "updated_at": "2020-01-28T22:17:31.19435Z", - "banned": false, - "online": false, - "image": "https://randomuser.me/api/portraits/women/2.jpg", - "name": "Mia Denys" - }, - "attachments": [ - { - "type": "video", - "author_name": "GIPHY", - "title": "The Lion King Disney GIF - Find \u0026 Share on GIPHY", - "title_link": "https://media.giphy.com/media/5zvN79uTGfLMOVfQaA/giphy.gif", - "text": "Discover \u0026 share this Lion King Live Action GIF with everyone you know. GIPHY is how you search, share, discover, and create GIFs.", - "image_url": "https://media.giphy.com/media/5zvN79uTGfLMOVfQaA/giphy.gif", - "thumb_url": "https://media.giphy.com/media/5zvN79uTGfLMOVfQaA/giphy.gif", - "asset_url": "https://media.giphy.com/media/5zvN79uTGfLMOVfQaA/giphy.mp4", - "og_scrape_url": "https://giphy.com/gifs/the-lion-king-live-action-5zvN79uTGfLMOVfQaA" - } - ], - "latest_reactions": [ - { - "message_id": "4637f7e4-a06b-42db-ba5a-8d8270dd926f", - "user_id": "c1c9b454-2bcc-402d-8bb0-2f3706ce1680", - "user": { - "id": "c1c9b454-2bcc-402d-8bb0-2f3706ce1680", - "role": "user", - "created_at": "2020-01-28T22:17:30.83015Z", - "updated_at": "2020-01-28T22:17:31.19435Z", - "banned": false, - "online": false, - "image": "https://randomuser.me/api/portraits/women/2.jpg", - "name": "Mia Denys" - }, - "type": "love", - "score": 1, - "created_at": "2020-01-28T22:17:31.128376Z", - "updated_at": "2020-01-28T22:17:31.128376Z" - } - ], - "own_reactions": [], - "reaction_counts": { - "love": 1 - }, - "reaction_scores": { - "love": 1 - }, - "reply_count": 0, - "created_at": "2020-01-28T22:17:31.107978Z", - "updated_at": "2020-01-28T22:17:31.130506Z", - "mentioned_users": [] - }, - { - "id": "16e19c46-fb96-4f89-8031-d5bd2ce3bd64", - "text": "Few can name a topfull mother that isn't a breezeless damage.", - "html": "\u003cp\u003eFew can name a topfull mother that isn’t a breezeless damage.\u003c/p\u003e\n", - "type": "regular", - "user": { - "id": "spring-voice-7", - "role": "user", - "created_at": "2020-01-28T22:17:30.834135Z", - "updated_at": "2020-01-28T22:17:31.186771Z", - "banned": false, - "online": false, - "image": "https://getstream.io/random_svg/?id=spring-voice-7\u0026name=Spring+voice", - "name": "Spring voice" - }, - "attachments": [], - "latest_reactions": [], - "own_reactions": [], - "reaction_counts": {}, - "reaction_scores": {}, - "reply_count": 0, - "created_at": "2020-01-28T22:17:31.153518Z", - "updated_at": "2020-01-28T22:17:31.153518Z", - "mentioned_users": [] - }, - { - "id": "38b1b252-c9a6-4aea-a39e-8de3b7f7604b", - "text": "https://giphy.com/gifs/beard-muscle-73Sjhw0N4hyog", - "html": "\u003cp\u003e\u003ca href=\"https://giphy.com/gifs/beard-muscle-73Sjhw0N4hyog\" rel=\"nofollow\"\u003ehttps://giphy.com/gifs/beard-muscle-73Sjhw0N4hyog\u003c/a\u003e\u003c/p\u003e\n", - "type": "regular", - "user": { - "id": "spring-voice-7", - "role": "user", - "created_at": "2020-01-28T22:17:30.834135Z", - "updated_at": "2020-01-28T22:17:31.186771Z", - "banned": false, - "online": false, - "image": "https://getstream.io/random_svg/?id=spring-voice-7\u0026name=Spring+voice", - "name": "Spring voice" - }, - "attachments": [ - { - "type": "video", - "author_name": "GIPHY", - "title": "Moustache Thumbs Up GIF - Find \u0026 Share on GIPHY", - "title_link": "https://media.giphy.com/media/73Sjhw0N4hyog/giphy.gif", - "text": "Discover \u0026 share this Pouce Leve GIF with everyone you know. GIPHY is how you search, share, discover, and create GIFs.", - "image_url": "https://media.giphy.com/media/73Sjhw0N4hyog/giphy.gif", - "thumb_url": "https://media.giphy.com/media/73Sjhw0N4hyog/giphy.gif", - "asset_url": "https://media.giphy.com/media/73Sjhw0N4hyog/giphy.mp4", - "og_scrape_url": "https://giphy.com/gifs/beard-muscle-73Sjhw0N4hyog" - } - ], - "latest_reactions": [], - "own_reactions": [], - "reaction_counts": {}, - "reaction_scores": {}, - "reply_count": 0, - "created_at": "2020-01-28T22:17:31.155428Z", - "updated_at": "2020-01-28T22:17:31.155428Z", - "mentioned_users": [] - }, - { - "id": "5c7d0c68-72d5-4027-b6b7-711b342d0fc4", - "text": "The carbons could be said to resemble smartish hoods.", - "html": "\u003cp\u003eThe carbons could be said to resemble smartish hoods.\u003c/p\u003e\n", - "type": "regular", - "user": { - "id": "c1c9b454-2bcc-402d-8bb0-2f3706ce1680", - "role": "user", - "created_at": "2020-01-28T22:17:30.83015Z", - "updated_at": "2020-01-28T22:17:31.19435Z", - "banned": false, - "online": false, - "image": "https://randomuser.me/api/portraits/women/2.jpg", - "name": "Mia Denys" - }, - "attachments": [], - "latest_reactions": [], - "own_reactions": [], - "reaction_counts": {}, - "reaction_scores": {}, - "reply_count": 1, - "created_at": "2020-01-28T22:17:31.157811Z", - "updated_at": "2020-01-28T22:17:31.157811Z", - "mentioned_users": [] - }, - { - "id": "5b02535a-0c3c-45fa-9cf8-16f840c5123f", - "text": "Their software was, in this moment, a prolix feature.", - "html": "\u003cp\u003eTheir software was, in this moment, a prolix feature.\u003c/p\u003e\n", - "type": "regular", - "user": { - "id": "c1c9b454-2bcc-402d-8bb0-2f3706ce1680", - "role": "user", - "created_at": "2020-01-28T22:17:30.83015Z", - "updated_at": "2020-01-28T22:17:31.19435Z", - "banned": false, - "online": false, - "image": "https://randomuser.me/api/portraits/women/2.jpg", - "name": "Mia Denys" - }, - "attachments": [], - "latest_reactions": [], - "own_reactions": [], - "reaction_counts": {}, - "reaction_scores": {}, - "reply_count": 1, - "created_at": "2020-01-28T22:17:31.158391Z", - "updated_at": "2020-01-28T22:17:31.158391Z", - "mentioned_users": [] - } - ], - "watcher_count": 1, - "read": [ - { - "user": { - "id": "c1c9b454-2bcc-402d-8bb0-2f3706ce1680", - "role": "user", - "created_at": "2020-01-28T22:17:30.83015Z", - "updated_at": "2020-01-28T22:17:31.19435Z", - "banned": false, - "online": false, - "image": "https://randomuser.me/api/portraits/women/2.jpg", - "name": "Mia Denys" - }, - "last_read": "2020-01-28T22:17:31.016937728Z" - }, - { - "user": { - "id": "spring-voice-7", - "role": "user", - "created_at": "2020-01-28T22:17:30.834135Z", - "updated_at": "2020-01-28T22:17:31.186771Z", - "banned": false, - "online": false, - "image": "https://getstream.io/random_svg/?id=spring-voice-7\u0026name=Spring+voice", - "name": "Spring voice" - }, - "last_read": "2020-01-28T22:17:31.018856448Z" - } - ], - "members": [ - { - "user": { - "id": "c1c9b454-2bcc-402d-8bb0-2f3706ce1680", - "role": "user", - "created_at": "2020-01-28T22:17:30.83015Z", - "updated_at": "2020-01-28T22:17:31.19435Z", - "banned": false, - "online": false, - "image": "https://randomuser.me/api/portraits/women/2.jpg", - "name": "Mia Denys" - }, - "role": "member", - "created_at": "2020-01-28T22:17:31.005135Z", - "updated_at": "2020-01-28T22:17:31.005135Z" - }, - { - "user": { - "id": "spring-voice-7", - "role": "user", - "created_at": "2020-01-28T22:17:30.834135Z", - "updated_at": "2020-01-28T22:17:31.186771Z", - "banned": false, - "online": false, - "image": "https://getstream.io/random_svg/?id=spring-voice-7\u0026name=Spring+voice", - "name": "Spring voice" - }, - "role": "owner", - "created_at": "2020-01-28T22:17:31.005135Z", - "updated_at": "2020-01-28T22:17:31.005135Z" - } - ] - } - ''', statusCode: 200)); + group('`.stopTyping`', () { + test('should return if `config.typingEvents` is false', () async { + when(() => channel.config?.typingEvents).thenReturn(false); - final response = await channelClient.create(); + final typingStopEvent = Event(type: EventType.typingStop); - verify(mockDio.post('/channels/messaging/query', data: options)) - .called(1); - expect(channelClient.id, response.channel.id); - expect(channelClient.cid, response.channel.cid); - }); + await channel.keyStroke(); - test('watch', () async { - final mockDio = MockDio(); - - when(mockDio.options).thenReturn(BaseOptions()); - when(mockDio.interceptors).thenReturn(Interceptors()); - - final client = StreamChatClient( - 'api-key', - httpClient: mockDio, - tokenProvider: (_) async => '', - ); - final channelClient = client.channel('messaging'); - final options = { - 'watch': true, - 'state': true, - 'presence': true, - }; - - when(mockDio.post('/channels/messaging/query', data: options)) - .thenAnswer((_) async => Response(data: r''' - { - "channel": { - "id": "!members-0LOcD0mZtTan60zHobLmELjdndXsonnBVNzZnB5mTt0", - "type": "messaging", - "cid": "messaging:!members-0LOcD0mZtTan60zHobLmELjdndXsonnBVNzZnB5mTt0", - "last_message_at": "2020-01-28T22:17:31.204287Z", - "created_at": "2020-01-28T22:17:31.00187Z", - "updated_at": "2020-01-28T22:17:31.00187Z", - "created_by": { - "id": "spring-voice-7", - "role": "user", - "created_at": "2020-01-28T22:17:30.834135Z", - "updated_at": "2020-01-28T22:17:31.186771Z", - "banned": false, - "online": false, - "image": "https://getstream.io/random_svg/?id=spring-voice-7\u0026name=Spring+voice", - "name": "Spring voice" - }, - "frozen": false, - "member_count": 2, - "config": { - "created_at": "2020-01-29T12:59:14.291912835Z", - "updated_at": "2020-01-29T12:59:14.291912991Z", - "name": "messaging", - "typing_events": true, - "read_events": true, - "connect_events": true, - "search": true, - "reactions": true, - "replies": true, - "mutes": true, - "uploads": true, - "url_enrichment": true, - "message_retention": "infinite", - "max_message_length": 5000, - "automod": "disabled", - "automod_behavior": "flag", - "commands": [ - { - "name": "giphy", - "description": "Post a random gif to the channel", - "args": "[text]", - "set": "fun_set" - } - ] - }, - "name": "Mia Denys", - "image": "https://randomuser.me/api/portraits/women/2.jpg" - }, - "messages": [ - { - "id": "4637f7e4-a06b-42db-ba5a-8d8270dd926f", - "text": "https://giphy.com/gifs/the-lion-king-live-action-5zvN79uTGfLMOVfQaA", - "html": "\u003cp\u003e\u003ca href=\"https://giphy.com/gifs/the-lion-king-live-action-5zvN79uTGfLMOVfQaA\" rel=\"nofollow\"\u003ehttps://giphy.com/gifs/the-lion-king-live-action-5zvN79uTGfLMOVfQaA\u003c/a\u003e\u003c/p\u003e\n", - "type": "regular", - "user": { - "id": "c1c9b454-2bcc-402d-8bb0-2f3706ce1680", - "role": "user", - "created_at": "2020-01-28T22:17:30.83015Z", - "updated_at": "2020-01-28T22:17:31.19435Z", - "banned": false, - "online": false, - "image": "https://randomuser.me/api/portraits/women/2.jpg", - "name": "Mia Denys" - }, - "attachments": [ - { - "type": "video", - "author_name": "GIPHY", - "title": "The Lion King Disney GIF - Find \u0026 Share on GIPHY", - "title_link": "https://media.giphy.com/media/5zvN79uTGfLMOVfQaA/giphy.gif", - "text": "Discover \u0026 share this Lion King Live Action GIF with everyone you know. GIPHY is how you search, share, discover, and create GIFs.", - "image_url": "https://media.giphy.com/media/5zvN79uTGfLMOVfQaA/giphy.gif", - "thumb_url": "https://media.giphy.com/media/5zvN79uTGfLMOVfQaA/giphy.gif", - "asset_url": "https://media.giphy.com/media/5zvN79uTGfLMOVfQaA/giphy.mp4", - "og_scrape_url": "https://giphy.com/gifs/the-lion-king-live-action-5zvN79uTGfLMOVfQaA" - } - ], - "latest_reactions": [ - { - "message_id": "4637f7e4-a06b-42db-ba5a-8d8270dd926f", - "user_id": "c1c9b454-2bcc-402d-8bb0-2f3706ce1680", - "user": { - "id": "c1c9b454-2bcc-402d-8bb0-2f3706ce1680", - "role": "user", - "created_at": "2020-01-28T22:17:30.83015Z", - "updated_at": "2020-01-28T22:17:31.19435Z", - "banned": false, - "online": false, - "image": "https://randomuser.me/api/portraits/women/2.jpg", - "name": "Mia Denys" - }, - "type": "love", - "score": 1, - "created_at": "2020-01-28T22:17:31.128376Z", - "updated_at": "2020-01-28T22:17:31.128376Z" - } - ], - "own_reactions": [], - "reaction_counts": { - "love": 1 - }, - "reaction_scores": { - "love": 1 - }, - "reply_count": 0, - "created_at": "2020-01-28T22:17:31.107978Z", - "updated_at": "2020-01-28T22:17:31.130506Z", - "mentioned_users": [] - }, - { - "id": "16e19c46-fb96-4f89-8031-d5bd2ce3bd64", - "text": "Few can name a topfull mother that isn't a breezeless damage.", - "html": "\u003cp\u003eFew can name a topfull mother that isn’t a breezeless damage.\u003c/p\u003e\n", - "type": "regular", - "user": { - "id": "spring-voice-7", - "role": "user", - "created_at": "2020-01-28T22:17:30.834135Z", - "updated_at": "2020-01-28T22:17:31.186771Z", - "banned": false, - "online": false, - "image": "https://getstream.io/random_svg/?id=spring-voice-7\u0026name=Spring+voice", - "name": "Spring voice" - }, - "attachments": [], - "latest_reactions": [], - "own_reactions": [], - "reaction_counts": {}, - "reaction_scores": {}, - "reply_count": 0, - "created_at": "2020-01-28T22:17:31.153518Z", - "updated_at": "2020-01-28T22:17:31.153518Z", - "mentioned_users": [] - }, - { - "id": "38b1b252-c9a6-4aea-a39e-8de3b7f7604b", - "text": "https://giphy.com/gifs/beard-muscle-73Sjhw0N4hyog", - "html": "\u003cp\u003e\u003ca href=\"https://giphy.com/gifs/beard-muscle-73Sjhw0N4hyog\" rel=\"nofollow\"\u003ehttps://giphy.com/gifs/beard-muscle-73Sjhw0N4hyog\u003c/a\u003e\u003c/p\u003e\n", - "type": "regular", - "user": { - "id": "spring-voice-7", - "role": "user", - "created_at": "2020-01-28T22:17:30.834135Z", - "updated_at": "2020-01-28T22:17:31.186771Z", - "banned": false, - "online": false, - "image": "https://getstream.io/random_svg/?id=spring-voice-7\u0026name=Spring+voice", - "name": "Spring voice" - }, - "attachments": [ - { - "type": "video", - "author_name": "GIPHY", - "title": "Moustache Thumbs Up GIF - Find \u0026 Share on GIPHY", - "title_link": "https://media.giphy.com/media/73Sjhw0N4hyog/giphy.gif", - "text": "Discover \u0026 share this Pouce Leve GIF with everyone you know. GIPHY is how you search, share, discover, and create GIFs.", - "image_url": "https://media.giphy.com/media/73Sjhw0N4hyog/giphy.gif", - "thumb_url": "https://media.giphy.com/media/73Sjhw0N4hyog/giphy.gif", - "asset_url": "https://media.giphy.com/media/73Sjhw0N4hyog/giphy.mp4", - "og_scrape_url": "https://giphy.com/gifs/beard-muscle-73Sjhw0N4hyog" - } - ], - "latest_reactions": [], - "own_reactions": [], - "reaction_counts": {}, - "reaction_scores": {}, - "reply_count": 0, - "created_at": "2020-01-28T22:17:31.155428Z", - "updated_at": "2020-01-28T22:17:31.155428Z", - "mentioned_users": [] - }, - { - "id": "5c7d0c68-72d5-4027-b6b7-711b342d0fc4", - "text": "The carbons could be said to resemble smartish hoods.", - "html": "\u003cp\u003eThe carbons could be said to resemble smartish hoods.\u003c/p\u003e\n", - "type": "regular", - "user": { - "id": "c1c9b454-2bcc-402d-8bb0-2f3706ce1680", - "role": "user", - "created_at": "2020-01-28T22:17:30.83015Z", - "updated_at": "2020-01-28T22:17:31.19435Z", - "banned": false, - "online": false, - "image": "https://randomuser.me/api/portraits/women/2.jpg", - "name": "Mia Denys" - }, - "attachments": [], - "latest_reactions": [], - "own_reactions": [], - "reaction_counts": {}, - "reaction_scores": {}, - "reply_count": 1, - "created_at": "2020-01-28T22:17:31.157811Z", - "updated_at": "2020-01-28T22:17:31.157811Z", - "mentioned_users": [] - }, - { - "id": "5b02535a-0c3c-45fa-9cf8-16f840c5123f", - "text": "Their software was, in this moment, a prolix feature.", - "html": "\u003cp\u003eTheir software was, in this moment, a prolix feature.\u003c/p\u003e\n", - "type": "regular", - "user": { - "id": "c1c9b454-2bcc-402d-8bb0-2f3706ce1680", - "role": "user", - "created_at": "2020-01-28T22:17:30.83015Z", - "updated_at": "2020-01-28T22:17:31.19435Z", - "banned": false, - "online": false, - "image": "https://randomuser.me/api/portraits/women/2.jpg", - "name": "Mia Denys" - }, - "attachments": [], - "latest_reactions": [], - "own_reactions": [], - "reaction_counts": {}, - "reaction_scores": {}, - "reply_count": 1, - "created_at": "2020-01-28T22:17:31.158391Z", - "updated_at": "2020-01-28T22:17:31.158391Z", - "mentioned_users": [] - } - ], - "watcher_count": 1, - "read": [ - { - "user": { - "id": "c1c9b454-2bcc-402d-8bb0-2f3706ce1680", - "role": "user", - "created_at": "2020-01-28T22:17:30.83015Z", - "updated_at": "2020-01-28T22:17:31.19435Z", - "banned": false, - "online": false, - "image": "https://randomuser.me/api/portraits/women/2.jpg", - "name": "Mia Denys" - }, - "last_read": "2020-01-28T22:17:31.016937728Z" - }, - { - "user": { - "id": "spring-voice-7", - "role": "user", - "created_at": "2020-01-28T22:17:30.834135Z", - "updated_at": "2020-01-28T22:17:31.186771Z", - "banned": false, - "online": false, - "image": "https://getstream.io/random_svg/?id=spring-voice-7\u0026name=Spring+voice", - "name": "Spring voice" - }, - "last_read": "2020-01-28T22:17:31.018856448Z" - } - ], - "members": [ - { - "user": { - "id": "c1c9b454-2bcc-402d-8bb0-2f3706ce1680", - "role": "user", - "created_at": "2020-01-28T22:17:30.83015Z", - "updated_at": "2020-01-28T22:17:31.19435Z", - "banned": false, - "online": false, - "image": "https://randomuser.me/api/portraits/women/2.jpg", - "name": "Mia Denys" - }, - "role": "member", - "created_at": "2020-01-28T22:17:31.005135Z", - "updated_at": "2020-01-28T22:17:31.005135Z" - }, - { - "user": { - "id": "spring-voice-7", - "role": "user", - "created_at": "2020-01-28T22:17:30.834135Z", - "updated_at": "2020-01-28T22:17:31.186771Z", - "banned": false, - "online": false, - "image": "https://getstream.io/random_svg/?id=spring-voice-7\u0026name=Spring+voice", - "name": "Spring voice" - }, - "role": "owner", - "created_at": "2020-01-28T22:17:31.005135Z", - "updated_at": "2020-01-28T22:17:31.005135Z" - } - ] - } - ''', statusCode: 200)); - - final response = await channelClient.watch({'presence': true}); - - verify(mockDio.post('/channels/messaging/query', data: options)) - .called(1); - expect(channelClient.id, response.channel.id); - expect(channelClient.cid, response.channel.cid); - }); - - test('stopWatching', () async { - final mockDio = MockDio(); - - when(mockDio.options).thenReturn(BaseOptions()); - when(mockDio.interceptors).thenReturn(Interceptors()); - - final client = StreamChatClient( - 'api-key', - httpClient: mockDio, - tokenProvider: (_) async => '', - ); - final channelClient = client.channel('messaging', id: 'testid'); - - when(mockDio.post( - '/channels/messaging/testid/stop-watching', - data: {}, - )).thenAnswer((_) async => Response(data: '{}', statusCode: 200)); - - await channelClient.stopWatching(); - - verify(mockDio.post( - '/channels/messaging/testid/stop-watching', - data: {}, - )).called(1); - }); - - test('update', () async { - final mockDio = MockDio(); - - when(mockDio.options).thenReturn(BaseOptions()); - when(mockDio.interceptors).thenReturn(Interceptors()); - - final client = StreamChatClient( - 'api-key', - httpClient: mockDio, - tokenProvider: (_) async => '', - ); - final channelClient = client.channel('messaging', id: 'testid'); - final message = Message(text: 'test'); - - when(mockDio.post('/channels/messaging/testid', data: { - 'message': message.toJson(), - 'data': {'test': true}, - })).thenAnswer((_) async => Response(data: '{}', statusCode: 200)); - - await channelClient.update({'test': true}, message); - - verify(mockDio.post('/channels/messaging/testid', data: { - 'message': message.toJson(), - 'data': {'test': true}, - })).called(1); - }); - - test('delete', () async { - final mockDio = MockDio(); - - when(mockDio.options).thenReturn(BaseOptions()); - when(mockDio.interceptors).thenReturn(Interceptors()); - - final client = StreamChatClient( - 'api-key', - httpClient: mockDio, - tokenProvider: (_) async => '', - ); - final channelClient = client.channel('messaging', id: 'testid'); - - when(mockDio.delete('/channels/messaging/testid')) - .thenAnswer((_) async => Response(data: '{}', statusCode: 200)); - - await channelClient.delete(); - - verify(mockDio.delete('/channels/messaging/testid')).called(1); - }); - - test('truncate', () async { - final mockDio = MockDio(); - - when(mockDio.options).thenReturn(BaseOptions()); - when(mockDio.interceptors).thenReturn(Interceptors()); - - final client = StreamChatClient( - 'api-key', - httpClient: mockDio, - tokenProvider: (_) async => '', - ); - final channelClient = client.channel('messaging', id: 'testid'); - - when(mockDio.post('/channels/messaging/testid/truncate')) - .thenAnswer((_) async => Response(data: '{}', statusCode: 200)); - - await channelClient.truncate(); - - verify(mockDio.post('/channels/messaging/testid/truncate')) - .called(1); - }); - - test('rejectInvite', () async { - final mockDio = MockDio(); - - when(mockDio.options).thenReturn(BaseOptions()); - when(mockDio.interceptors).thenReturn(Interceptors()); - - final client = StreamChatClient( - 'api-key', - httpClient: mockDio, - tokenProvider: (_) async => '', - ); - final channelClient = client.channel('messaging', id: 'testid'); - final message = Message(text: 'test'); - - when(mockDio.post('/channels/messaging/testid', - data: {'reject_invite': true, 'message': message.toJson()})) - .thenAnswer((_) async => Response(data: '{}', statusCode: 200)); - - await channelClient.rejectInvite(message); - - verify(mockDio.post('/channels/messaging/testid', - data: {'reject_invite': true, 'message': message.toJson()})) - .called(1); - }); - - test('inviteMembers', () async { - final mockDio = MockDio(); - - when(mockDio.options).thenReturn(BaseOptions()); - when(mockDio.interceptors).thenReturn(Interceptors()); - - final client = StreamChatClient( - 'api-key', - httpClient: mockDio, - tokenProvider: (_) async => '', - ); - final channelClient = client.channel('messaging', id: 'testid'); - final members = ['vishal']; - final message = Message(text: 'test'); - - when(mockDio.post('/channels/messaging/testid', - data: {'invites': members, 'message': message.toJson()})) - .thenAnswer((_) async => Response(data: '{}', statusCode: 200)); - - await channelClient.inviteMembers(members, message); - - verify(mockDio.post('/channels/messaging/testid', - data: {'invites': members, 'message': message.toJson()})).called(1); - }); - - test('removeMembers', () async { - final mockDio = MockDio(); - - when(mockDio.options).thenReturn(BaseOptions()); - when(mockDio.interceptors).thenReturn(Interceptors()); - - final client = StreamChatClient( - 'api-key', - httpClient: mockDio, - tokenProvider: (_) async => '', - ); - final channelClient = client.channel('messaging', id: 'testid'); - final members = ['vishal']; - final message = Message(text: 'test'); - - when(mockDio.post('/channels/messaging/testid', - data: {'remove_members': members, 'message': message.toJson()})) - .thenAnswer((_) async => Response(data: '{}', statusCode: 200)); - - await channelClient.removeMembers(members, message); - - verify(mockDio.post('/channels/messaging/testid', - data: {'remove_members': members, 'message': message.toJson()})) - .called(1); - }); - - test('hide', () async { - final mockDio = MockDio(); - - when(mockDio.options).thenReturn(BaseOptions()); - when(mockDio.interceptors).thenReturn(Interceptors()); - - final client = StreamChatClient( - 'api-key', - httpClient: mockDio, - tokenProvider: (_) async => '', - ); - final channelClient = client.channel('messaging', id: 'testid'); - - when(mockDio.post( - any, - data: anyNamed('data'), - )).thenAnswer((_) async => Response( - data: '{}', - statusCode: 200, + verifyNever(() => client.sendEvent( + channelId, + channelType, + any(that: isSameEventAs(typingStopEvent)), )); - await channelClient.watch(); - - when(mockDio.post('/channels/messaging/testid/hide', - data: {'clear_history': true})) - .thenAnswer((_) async => Response(data: '{}', statusCode: 200)); - - await channelClient.hide(clearHistory: true); - - verify(mockDio.post('/channels/messaging/testid/hide', - data: {'clear_history': true})).called(1); }); - test('show', () async { - final mockDio = MockDio(); + test('should send `typingStop` successfully', () async { + final typingStopEvent = Event(type: EventType.typingStop); - when(mockDio.options).thenReturn(BaseOptions()); - when(mockDio.interceptors).thenReturn(Interceptors()); + when(() => channel.config?.typingEvents).thenReturn(true); - final client = StreamChatClient( - 'api-key', - httpClient: mockDio, - tokenProvider: (_) async => '', - ); - final channelClient = client.channel('messaging', id: 'testid'); + when(() => client.sendEvent( + channelId, + channelType, + any(that: isSameEventAs(typingStopEvent)), + )).thenAnswer((_) async => EmptyResponse()); - when(mockDio.post( - any, - data: anyNamed('data'), - )).thenAnswer((_) async => Response( - data: '{}', - statusCode: 200, - )); - await channelClient.watch(); + await channel.stopTyping(); - when(mockDio.post('/channels/messaging/testid/show')) - .thenAnswer((_) async => Response(data: '{}', statusCode: 200)); - - await channelClient.show(); - - verify(mockDio.post('/channels/messaging/testid/show')) - .called(1); - }); - - test('banUser', () async { - final mockDio = MockDio(); - - when(mockDio.options).thenReturn(BaseOptions()); - when(mockDio.interceptors).thenReturn(Interceptors()); - - final client = StreamChatClient( - 'api-key', - httpClient: mockDio, - tokenProvider: (_) async => '', - ); - final channelClient = client.channel('messaging', id: 'testid'); - - when(mockDio.post( - any, - data: anyNamed('data'), - )).thenAnswer((_) async => Response( - data: '{}', - statusCode: 200, - )); - await channelClient.watch(); - - when(mockDio.post('/moderation/ban', data: { - 'test': true, - 'target_user_id': 'test-id', - 'type': 'messaging', - 'id': 'testid', - })).thenAnswer((_) async => Response(data: '{}', statusCode: 200)); - - final Map options = {'test': true}; - await channelClient.banUser('test-id', options); - - verify(mockDio.post('/moderation/ban', data: { - 'test': true, - 'target_user_id': 'test-id', - 'type': 'messaging', - 'id': 'testid', - })).called(1); - }); - - test('unbanUser', () async { - final mockDio = MockDio(); - - when(mockDio.options).thenReturn(BaseOptions()); - when(mockDio.interceptors).thenReturn(Interceptors()); - - final client = StreamChatClient( - 'api-key', - httpClient: mockDio, - tokenProvider: (_) async => '', - ); - final channelClient = client.channel('messaging', id: 'testid'); - - when(mockDio.post( - any, - data: anyNamed('data'), - )).thenAnswer((_) async => Response( - data: '{}', - statusCode: 200, - )); - await channelClient.watch(); - - when(mockDio.delete('/moderation/ban', queryParameters: { - 'target_user_id': 'test-id', - 'type': 'messaging', - 'id': 'testid', - })).thenAnswer((_) async => Response(data: '{}', statusCode: 200)); - - await channelClient.unbanUser('test-id'); - - verify(mockDio.delete('/moderation/ban', queryParameters: { - 'target_user_id': 'test-id', - 'type': 'messaging', - 'id': 'testid', - })).called(1); + verify(() => client.sendEvent( + channelId, + channelType, + any(that: isSameEventAs(typingStopEvent)), + )).called(1); }); }); }); diff --git a/packages/stream_chat/test/src/api/client_test.dart b/packages/stream_chat/test/src/api/client_test.dart new file mode 100644 index 00000000..423445ec --- /dev/null +++ b/packages/stream_chat/test/src/api/client_test.dart @@ -0,0 +1,2315 @@ +import 'package:mocktail/mocktail.dart'; +import 'package:stream_chat/src/client/client.dart'; +import 'package:stream_chat/src/core/api/device_api.dart'; +import 'package:stream_chat/src/core/api/requests.dart'; +import 'package:stream_chat/src/core/api/responses.dart'; +import 'package:stream_chat/src/core/error/error.dart'; +import 'package:stream_chat/src/core/http/token.dart'; +import 'package:stream_chat/src/core/models/channel_model.dart'; +import 'package:stream_chat/src/core/models/event.dart'; +import 'package:stream_chat/src/core/models/filter.dart'; +import 'package:stream_chat/src/core/models/message.dart'; +import 'package:stream_chat/src/core/models/own_user.dart'; +import 'package:stream_chat/src/core/models/user.dart'; +import 'package:stream_chat/src/event_type.dart'; +import 'package:stream_chat/src/ws/connection_status.dart'; +import 'package:stream_chat/stream_chat.dart'; +import 'package:test/scaffolding.dart'; +import 'package:test/test.dart'; + +import '../fakes.dart'; +import '../matchers.dart'; +import '../mocks.dart'; +import '../utils.dart'; + +void main() { + group('Fake web-socket connection functions', () { + const apiKey = 'test-api-key'; + late final api = FakeChatApi(); + + late StreamChatClient client; + + setUpAll(() { + // fallback values + registerFallbackValue(FakeUser()); + }); + + setUp(() { + final ws = FakeWebSocket(); + client = StreamChatClient(apiKey, ws: ws, chatApi: api); + }); + + tearDown(() { + client.dispose(); + }); + + test('`.connectUser` should work fine', () async { + final user = User(id: 'test-user-id'); + final token = Token.development(user.id).rawValue; + + expectLater( + // skipping first seed status -> ConnectionStatus.disconnected + client.wsConnectionStatusStream.skip(1), + emitsInOrder([ + ConnectionStatus.connecting, + ConnectionStatus.connected, + ]), + ); + + final res = await client.connectUser(user, token); + expect(res, isNotNull); + expect(res, isSameUserAs(user)); + }); + + test('`.connectUserWithProvider` should work fine', () async { + final user = User(id: 'test-user-id'); + Future tokenProvider(String userId) async { + expect(userId, user.id); + return Token.development(userId).rawValue; + } + + expectLater( + // skipping first seed status -> ConnectionStatus.disconnected + client.wsConnectionStatusStream.skip(1), + emitsInOrder([ + ConnectionStatus.connecting, + ConnectionStatus.connected, + ]), + ); + + final res = await client.connectUserWithProvider(user, tokenProvider); + expect(res, isNotNull); + expect(res, isSameUserAs(user)); + }); + + group('`.connectGuestUser`', () { + test('should work fine', () async { + final user = User(id: 'test-user-id'); + final token = Token.development(user.id).rawValue; + + when(() => api.guest.getGuestUser(any(that: isSameUserAs(user)))) + .thenAnswer( + (_) async => ConnectGuestUserResponse() + ..user = user + ..accessToken = token, + ); + + expectLater( + // skipping first seed status -> ConnectionStatus.disconnected + client.wsConnectionStatusStream.skip(1), + emitsInOrder([ + ConnectionStatus.connecting, + ConnectionStatus.connected, + ]), + ); + + final res = await client.connectGuestUser(user); + expect(res, isNotNull); + expect(res, isSameUserAs(user)); + + verify( + () => api.guest.getGuestUser(any(that: isSameUserAs(user))), + ).called(1); + }); + + test('should throw if `.getGuestUser` fails', () async { + final user = User(id: 'test-user-id'); + + when(() => api.guest.getGuestUser(any(that: isSameUserAs(user)))) + .thenThrow(StreamChatNetworkError(ChatErrorCode.inputError)); + + expectLater( + client.wsConnectionStatusStream, + emitsInOrder([ + // only emits the seed -> disconnected status + // as the call never reaches `ws.connect` + ConnectionStatus.disconnected, + ]), + ); + + try { + await client.connectGuestUser(user); + } catch (e) { + expect(e, isA()); + } + + verify( + () => api.guest.getGuestUser(any(that: isSameUserAs(user))), + ).called(1); + }); + }); + + test('`.connectAnonymousUser` should work fine', () async { + expectLater( + // skipping first seed status -> ConnectionStatus.disconnected + client.wsConnectionStatusStream.skip(1), + emitsInOrder([ + ConnectionStatus.connecting, + ConnectionStatus.connected, + ]), + ); + + final res = await client.connectAnonymousUser(); + expect(res, isNotNull); + }); + + group('`.openConnection`', () { + test('should throw if state does not contain user', () async { + expect(client.state.user, isNull); + try { + await client.openConnection(); + } catch (e) { + expect(e, isA()); + } + }); + + test('should throw if connection is already in progress', () async { + expect(client.state.user, isNull); + try { + await client.connectAnonymousUser(); + await client.openConnection(); + } catch (e) { + expect(e, isA()); + final err = e as StreamChatError; + expect( + err.message.contains('Connection already in progress for'), + isTrue, + ); + } + }); + + test('should throw if connection is already available', () async { + expect(client.state.user, isNull); + try { + await client.connectAnonymousUser(); + // waiting 300ms for `wsConnectionStatusStream` to emit + await delay(300); + + await client.openConnection(); + } catch (e) { + expect(e, isA()); + final err = e as StreamChatError; + expect( + err.message.contains('Connection already available for'), + isTrue, + ); + } + }); + + test('should open connection for closed connection', () async { + expectLater( + client.wsConnectionStatusStream.skip(1), + emitsInOrder([ + // initial connectUser + ConnectionStatus.connecting, + ConnectionStatus.connected, + // close connection + ConnectionStatus.disconnected, + // open connection + ConnectionStatus.connecting, + ConnectionStatus.connected, + ]), + ); + + await client.connectAnonymousUser(); + // waiting 300ms for `wsConnectionStatusStream` to emit + await delay(300); + + client.closeConnection(); + + await client.openConnection(); + }); + }); + }); + + group('Fake web-socket connection functions failure', () { + const apiKey = 'test-api-key'; + late final api = FakeChatApi(); + + late StreamChatClient client; + + setUpAll(() { + // fallback values + registerFallbackValue(FakeUser()); + }); + + setUp(() { + final ws = FakeWebSocketWithConnectionError(); + client = StreamChatClient(apiKey, chatApi: api, ws: ws); + }); + + tearDown(() { + client.dispose(); + }); + + test('`.connectUser` should throw if `ws.connect` fails', () async { + final user = User(id: 'test-user-id'); + final token = Token.development(user.id).rawValue; + + try { + await client.connectUser(user, token); + } catch (e) { + expect(e, isA()); + } + }); + + test( + '`.connectUserWithProvider` should throw if `ws.connect` fails', + () async { + final user = User(id: 'test-user-id'); + Future tokenProvider(String userId) async { + expect(userId, user.id); + return Token.development(userId).rawValue; + } + + try { + await client.connectUserWithProvider(user, tokenProvider); + } catch (e) { + expect(e, isA()); + } + }, + ); + + test('`.connectGuestUser` should throw if `ws.connect` fails', () async { + final user = User(id: 'test-user-id'); + final token = Token.development(user.id).rawValue; + + when(() => api.guest.getGuestUser(any(that: isSameUserAs(user)))) + .thenAnswer( + (_) async => ConnectGuestUserResponse() + ..user = user + ..accessToken = token, + ); + + try { + await client.connectGuestUser(user); + } catch (e) { + expect(e, isA()); + } + verify( + () => api.guest.getGuestUser(any(that: isSameUserAs(user))), + ).called(1); + }); + + test( + '`.connectAnonymousUser` should throw if `ws.connect` fails', + () async { + try { + await client.connectAnonymousUser(); + } catch (e) { + expect(e, isA()); + } + }, + ); + }); + + group('Connect user calls with `connectWebSocket`: false', () { + const apiKey = 'test-api-key'; + late final api = FakeChatApi(); + + late StreamChatClient client; + + setUpAll(() { + // fallback values + registerFallbackValue(FakeUser()); + }); + + setUp(() { + client = StreamChatClient(apiKey, chatApi: api); + }); + + tearDown(() { + client.dispose(); + }); + + test('`.connectUser` should succeed without connecting', () async { + final user = User(id: 'test-user-id'); + final token = Token.development(user.id).rawValue; + + final res = await client.connectUser( + user, + token, + connectWebSocket: false, + ); + expect(res, isSameUserAs(user)); + expect(client.wsConnectionStatus, ConnectionStatus.disconnected); + }); + + test( + '`.connectUserWithProvider` should succeed without connecting', + () async { + final user = User(id: 'test-user-id'); + Future tokenProvider(String userId) async { + expect(userId, user.id); + return Token.development(userId).rawValue; + } + + final res = await client.connectUserWithProvider( + user, + tokenProvider, + connectWebSocket: false, + ); + expect(res, isSameUserAs(user)); + expect(client.wsConnectionStatus, ConnectionStatus.disconnected); + }, + ); + + test('`.connectGuestUser` should succeed without connecting', () async { + final user = User(id: 'test-user-id'); + final token = Token.development(user.id).rawValue; + + when(() => api.guest.getGuestUser(any(that: isSameUserAs(user)))) + .thenAnswer( + (_) async => ConnectGuestUserResponse() + ..user = user + ..accessToken = token, + ); + + final res = await client.connectGuestUser( + user, + connectWebSocket: false, + ); + + expect(res, isSameUserAs(user)); + expect(client.wsConnectionStatus, ConnectionStatus.disconnected); + verify( + () => api.guest.getGuestUser(any(that: isSameUserAs(user))), + ).called(1); + }); + + test( + '`.connectAnonymousUser` should succeed without connecting', + () async { + final res = await client.connectAnonymousUser( + connectWebSocket: false, + ); + + expect(res, isNotNull); + expect(client.wsConnectionStatus, ConnectionStatus.disconnected); + }, + ); + }); + + group('Fake web-socket connection function with failure and persistence', () { + const apiKey = 'test-api-key'; + late final api = FakeChatApi(); + late final persistence = MockPersistenceClient(); + + late StreamChatClient client; + + setUpAll(() { + // fallback values + registerFallbackValue(FakeUser()); + }); + + setUp(() { + final ws = FakeWebSocketWithConnectionError(); + client = StreamChatClient(apiKey, chatApi: api, ws: ws) + ..chatPersistenceClient = persistence; + }); + + tearDown(() { + client.dispose(); + }); + + test( + '''`.connectUser` should connect successfully if persistence contains event''', + () async { + final user = User(id: 'test-user-id'); + final token = Token.development(user.id).rawValue; + + final event = Event( + type: EventType.healthCheck, + connectionId: 'test-connection-id', + me: OwnUser.fromUser(user)); + when(persistence.getConnectionInfo).thenAnswer((_) async => event); + + final res = await client.connectUser(user, token); + expect(res, isNotNull); + expect(res, isSameUserAs(user)); + + verify(persistence.getConnectionInfo).called(1); + verifyNoMoreInteractions(persistence); + }, + ); + + test( + '''`.connectUserWithProvider` should connect successfully if persistence contains event''', + () async { + final user = User(id: 'test-user-id'); + Future tokenProvider(String userId) async { + expect(userId, user.id); + return Token.development(userId).rawValue; + } + + final event = Event( + type: EventType.healthCheck, + connectionId: 'test-connection-id', + me: OwnUser.fromUser(user)); + when(persistence.getConnectionInfo).thenAnswer((_) async => event); + + final res = await client.connectUserWithProvider(user, tokenProvider); + expect(res, isNotNull); + expect(res, isSameUserAs(user)); + + verify(persistence.getConnectionInfo).called(1); + verifyNoMoreInteractions(persistence); + }, + ); + + test( + '''`.connectGuestUser` should connect successfully if persistence contains event''', + () async { + final user = User(id: 'test-user-id'); + final token = Token.development(user.id).rawValue; + + final event = Event( + type: EventType.healthCheck, + connectionId: 'test-connection-id', + me: OwnUser.fromUser(user)); + when(persistence.getConnectionInfo).thenAnswer((_) async => event); + + when(() => api.guest.getGuestUser(any(that: isSameUserAs(user)))) + .thenAnswer( + (_) async => ConnectGuestUserResponse() + ..user = user + ..accessToken = token, + ); + + final res = await client.connectGuestUser(user); + expect(res, isNotNull); + expect(res, isSameUserAs(user)); + + verify(persistence.getConnectionInfo).called(1); + verifyNoMoreInteractions(persistence); + verify(() => api.guest.getGuestUser(any(that: isSameUserAs(user)))) + .called(1); + verifyNoMoreInteractions(api.guest); + }, + ); + + test( + '''`.connectAnonymousUser` should connect successfully if persistence contains event''', + () async { + final user = User(id: 'test-user-id'); + + when(persistence.getConnectionInfo).thenAnswer( + (invocation) async => Event( + type: EventType.healthCheck, + connectionId: 'test-connection-id', + me: OwnUser.fromUser(user), + ), + ); + + final res = await client.connectAnonymousUser(); + expect(res, isNotNull); + + verify(persistence.getConnectionInfo).called(1); + verifyNoMoreInteractions(persistence); + }, + ); + }); + + group('Client with connected user with persistence', () { + const apiKey = 'test-api-key'; + late final api = FakeChatApi(); + late final ws = FakeWebSocket(); + late final persistence = MockPersistenceClient(); + + final user = User(id: 'test-user-id'); + final token = Token.development(user.id).rawValue; + + late StreamChatClient client; + + setUpAll(() { + // fallback values + registerFallbackValue(FakeEvent()); + registerFallbackValue(const PaginationParams()); + registerFallbackValue(FakeChannelState()); + }); + + setUp(() async { + client = StreamChatClient(apiKey, chatApi: api, ws: ws) + ..chatPersistenceClient = persistence; + await client.connectUser(user, token); + await delay(300); + expect(client.persistenceEnabled, isTrue); + expect(client.wsConnectionStatus, ConnectionStatus.connected); + }); + + tearDown(() { + client.dispose(); + }); + + group('`.sync`', () { + test( + '''should update persistence connectionInfo and lastSync when sync succeeds''', + () async { + const cids = ['test-cid-1', 'test-cid-2', 'test-cid-3']; + final lastSyncAt = DateTime.now(); + + when(() => api.general.sync(cids, lastSyncAt)) + .thenAnswer((_) async => SyncResponse() + ..events = [ + Event( + isLocal: false, + type: EventType.healthCheck, + connectionId: 'test-connection-id', + me: OwnUser.fromUser(user), + ), + Event( + isLocal: false, + type: EventType.messageDeleted, + message: Message(id: 'test-message-id'), + ), + ]); + + when(() => persistence.updateConnectionInfo(any())) + .thenAnswer((_) => Future.value()); + when(() => persistence.updateLastSyncAt(any())) + .thenAnswer((_) => Future.value()); + + await client.sync(cids: cids, lastSyncAt: lastSyncAt); + + verify(() => persistence.updateConnectionInfo(any())).called(1); + verify(() => persistence.updateLastSyncAt(any())).called(1); + verify(() => api.general.sync(cids, lastSyncAt)).called(1); + }, + ); + + test( + 'should work fine if persistence contains sync params', + () async { + const cids = ['test-cid-1', 'test-cid-2', 'test-cid-3']; + final lastSyncAt = DateTime.now(); + + when(persistence.getChannelCids).thenAnswer((_) async => cids); + when(persistence.getLastSyncAt).thenAnswer((_) async => lastSyncAt); + + when(() => api.general.sync(cids, lastSyncAt)) + .thenAnswer((_) async => SyncResponse() + ..events = [ + Event( + isLocal: false, + type: EventType.healthCheck, + connectionId: 'test-connection-id', + me: OwnUser.fromUser(user), + ), + Event( + isLocal: false, + type: EventType.messageDeleted, + message: Message(id: 'test-message-id', text: 'Hey!'), + ), + ]); + + when(() => persistence.updateConnectionInfo(any())) + .thenAnswer((_) => Future.value()); + when(() => persistence.updateLastSyncAt(any())) + .thenAnswer((_) => Future.value()); + + await client.sync(); + + verify(() => persistence.updateConnectionInfo(any())).called(1); + verify(() => persistence.updateLastSyncAt(any())).called(1); + verify(() => api.general.sync(cids, lastSyncAt)).called(1); + verify(persistence.getChannelCids).called(1); + verify(persistence.getLastSyncAt).called(1); + }, + ); + }); + + group('`.queryChannels`', () { + test( + 'should emit channels twice if persistence contains some channels', + () async { + final persistentChannelStates = List.generate( + 3, + (index) => ChannelState( + channel: ChannelModel(cid: 'p-test-type-$index:p-test-id-$index'), + ), + ); + + when(() => persistence.getChannelStates( + filter: any(named: 'filter'), + sort: any(named: 'sort'), + paginationParams: any(named: 'paginationParams'), + )).thenAnswer((_) async => persistentChannelStates); + + final channelStates = List.generate( + 3, + (index) => ChannelState( + channel: ChannelModel(cid: 'test-type-$index:test-id-$index'), + ), + ); + + when(() => api.channel.queryChannels( + filter: any(named: 'filter'), + sort: any(named: 'sort'), + state: any(named: 'state'), + watch: any(named: 'watch'), + presence: any(named: 'presence'), + memberLimit: any(named: 'memberLimit'), + messageLimit: any(named: 'messageLimit'), + paginationParams: any(named: 'paginationParams'), + )).thenAnswer( + (_) async => QueryChannelsResponse()..channels = channelStates, + ); + + when(() => persistence.getChannelThreads(any())) + .thenAnswer((_) async => {}); + when(() => persistence.updateMessages(any(), any())) + .thenAnswer((_) => Future.value()); + when(() => persistence.getChannelStateByCid(any(), + messagePagination: any(named: 'messagePagination'), + pinnedMessagePagination: + any(named: 'pinnedMessagePagination'))).thenAnswer( + (invocation) async => ChannelState( + channel: ChannelModel(cid: invocation.positionalArguments.first), + ), + ); + when(() => persistence.updateChannelQueries(any(), any(), + clearQueryCache: any(named: 'clearQueryCache'))) + .thenAnswer((_) => Future.value()); + + expectLater( + client.queryChannels(), + emitsInOrder([ + // emits persistent channels first + persistentChannelStates.map(isCorrectChannelFor), + // makes api call and emits network fetched channels + channelStates.map(isCorrectChannelFor), + ]), + ); + + // Hack as `teardown` gets called even + // before our stream starts emitting data + await delay(300); + + verify(() => persistence.getChannelStates( + filter: any(named: 'filter'), + sort: any(named: 'sort'), + paginationParams: any(named: 'paginationParams'), + )).called(1); + + verify(() => api.channel.queryChannels( + filter: any(named: 'filter'), + sort: any(named: 'sort'), + state: any(named: 'state'), + watch: any(named: 'watch'), + presence: any(named: 'presence'), + memberLimit: any(named: 'memberLimit'), + messageLimit: any(named: 'messageLimit'), + paginationParams: any(named: 'paginationParams'), + )).called(1); + + verify(() => persistence.getChannelThreads(any())) + .called((persistentChannelStates + channelStates).length); + verify(() => persistence.updateMessages(any(), any())) + .called((persistentChannelStates + channelStates).length); + verify( + () => persistence.getChannelStateByCid(any(), + messagePagination: any(named: 'messagePagination'), + pinnedMessagePagination: any(named: 'pinnedMessagePagination')), + ).called((persistentChannelStates + channelStates).length); + verify(() => persistence.updateChannelQueries(any(), any(), + clearQueryCache: any(named: 'clearQueryCache'))).called(1); + }, + ); + + test( + '''should never rethrow network call if persistence already emitted some channels''', + () async { + final persistentChannelStates = List.generate( + 3, + (index) => ChannelState( + channel: ChannelModel(cid: 'p-test-type-$index:p-test-id-$index'), + ), + ); + + when(() => persistence.getChannelStates( + filter: any(named: 'filter'), + sort: any(named: 'sort'), + paginationParams: any(named: 'paginationParams'), + )).thenAnswer((_) async => persistentChannelStates); + + when(() => api.channel.queryChannels( + filter: any(named: 'filter'), + sort: any(named: 'sort'), + state: any(named: 'state'), + watch: any(named: 'watch'), + presence: any(named: 'presence'), + memberLimit: any(named: 'memberLimit'), + messageLimit: any(named: 'messageLimit'), + paginationParams: any(named: 'paginationParams'), + )).thenThrow(StreamChatNetworkError(ChatErrorCode.inputError)); + + when(() => persistence.getChannelThreads(any())) + .thenAnswer((_) async => {}); + when(() => persistence.updateMessages(any(), any())) + .thenAnswer((_) => Future.value()); + when(() => persistence.getChannelStateByCid(any(), + messagePagination: any(named: 'messagePagination'), + pinnedMessagePagination: + any(named: 'pinnedMessagePagination'))).thenAnswer( + (invocation) async => ChannelState( + channel: ChannelModel(cid: invocation.positionalArguments.first), + ), + ); + + expectLater( + client.queryChannels(), + emitsInOrder([ + // emits persistent channels + persistentChannelStates.map(isCorrectChannelFor), + ]), + ); + + // Hack as `teardown` gets called even + // before our stream starts emitting data + await delay(300); + + verify(() => persistence.getChannelStates( + filter: any(named: 'filter'), + sort: any(named: 'sort'), + paginationParams: any(named: 'paginationParams'), + )).called(1); + + verify(() => api.channel.queryChannels( + filter: any(named: 'filter'), + sort: any(named: 'sort'), + state: any(named: 'state'), + watch: any(named: 'watch'), + presence: any(named: 'presence'), + memberLimit: any(named: 'memberLimit'), + messageLimit: any(named: 'messageLimit'), + paginationParams: any(named: 'paginationParams'), + )).called(1); + + verify(() => persistence.getChannelThreads(any())) + .called(persistentChannelStates.length); + verify(() => persistence.updateMessages(any(), any())) + .called(persistentChannelStates.length); + verify( + () => persistence.getChannelStateByCid(any(), + messagePagination: any(named: 'messagePagination'), + pinnedMessagePagination: any(named: 'pinnedMessagePagination')), + ).called(persistentChannelStates.length); + }, + ); + }); + + test('`.disconnectUser` should reset state and user', () async { + expect(client.state.user, isNotNull); + expect(client.wsConnectionStatus, ConnectionStatus.connected); + + expectLater( + // skipping initial connected value + client.wsConnectionStatusStream.skip(1), + emits(ConnectionStatus.disconnected), + ); + + await client.disconnectUser(); + + expect(client.state.user, isNull); + expect(client.wsConnectionStatus, ConnectionStatus.disconnected); + }); + }); + + group('Client with connected user without persistence', () { + const apiKey = 'test-api-key'; + late final api = FakeChatApi(); + late final ws = FakeWebSocket(); + + final user = User(id: 'test-user-id'); + final token = Token.development(user.id).rawValue; + + late StreamChatClient client; + + setUpAll(() { + // fallback values + registerFallbackValue(FakeEvent()); + registerFallbackValue(FakeMessage()); + registerFallbackValue(const PaginationParams()); + }); + + setUp(() async { + client = StreamChatClient(apiKey, chatApi: api, ws: ws); + await client.connectUser(user, token); + await delay(300); + expect(client.persistenceEnabled, isFalse); + expect(client.wsConnectionStatus, ConnectionStatus.connected); + }); + + tearDown(() { + client.dispose(); + }); + + group('`.sync`', () { + test('should work fine', () async { + const cids = ['test-cid-1', 'test-cid-2', 'test-cid-3']; + final lastSyncAt = DateTime.now(); + + when(() => api.general.sync(cids, lastSyncAt)) + .thenAnswer((_) async => SyncResponse() + ..events = [ + Event( + isLocal: false, + type: EventType.healthCheck, + connectionId: 'test-connection-id', + me: OwnUser.fromUser(user), + ), + Event( + isLocal: false, + type: EventType.messageDeleted, + message: Message(id: 'test-message-id'), + ), + ]); + + await client.sync(cids: cids, lastSyncAt: lastSyncAt); + + verify(() => api.general.sync(cids, lastSyncAt)).called(1); + }); + + test('should return if `cids` is not available', () async { + expect(client.sync, returnsNormally); + verifyNever(() => api.general.sync(any(), any())); + }); + + test('should return if `lastSyncAt` is not available', () async { + expect(() => client.sync(cids: ['test-cid-1']), returnsNormally); + verifyNever(() => api.general.sync(any(), any())); + }); + }); + + group('`.queryChannels`', () { + test('should work fine without persistent channels', () async { + final channelStates = List.generate( + 3, + (index) => ChannelState( + channel: ChannelModel(cid: 'test-type-$index:test-id-$index'), + ), + ); + + when(() => api.channel.queryChannels( + filter: any(named: 'filter'), + sort: any(named: 'sort'), + state: any(named: 'state'), + watch: any(named: 'watch'), + presence: any(named: 'presence'), + memberLimit: any(named: 'memberLimit'), + messageLimit: any(named: 'messageLimit'), + paginationParams: any(named: 'paginationParams'), + )).thenAnswer( + (_) async => QueryChannelsResponse()..channels = channelStates, + ); + + expectLater( + client.queryChannels(), + emitsInOrder([channelStates.map(isCorrectChannelFor)]), + ); + + // Hack as `teardown` gets called even + // before our stream starts emitting data + await delay(300); + + verify(() => api.channel.queryChannels( + filter: any(named: 'filter'), + sort: any(named: 'sort'), + state: any(named: 'state'), + watch: any(named: 'watch'), + presence: any(named: 'presence'), + memberLimit: any(named: 'memberLimit'), + messageLimit: any(named: 'messageLimit'), + paginationParams: any(named: 'paginationParams'), + )).called(1); + }); + + test( + '''should rethrow if `.queryChannelsOnline` throws and persistence channels are empty''', + () async { + when(() => api.channel.queryChannels( + filter: any(named: 'filter'), + sort: any(named: 'sort'), + state: any(named: 'state'), + watch: any(named: 'watch'), + presence: any(named: 'presence'), + memberLimit: any(named: 'memberLimit'), + messageLimit: any(named: 'messageLimit'), + paginationParams: any(named: 'paginationParams'), + )).thenThrow(StreamChatNetworkError(ChatErrorCode.inputError)); + + expectLater( + client.queryChannels(), + emitsError(isA()), + ); + + // Hack as `teardown` gets called even + // before our stream starts emitting data + await delay(300); + + verify(() => api.channel.queryChannels( + filter: any(named: 'filter'), + sort: any(named: 'sort'), + state: any(named: 'state'), + watch: any(named: 'watch'), + presence: any(named: 'presence'), + memberLimit: any(named: 'memberLimit'), + messageLimit: any(named: 'messageLimit'), + paginationParams: any(named: 'paginationParams'), + )).called(1); + }, + ); + }); + + test('`.queryUsers`', () async { + final users = List.generate( + 3, + (index) => User(id: 'test-user-id-$index'), + ); + + when(() => api.user.queryUsers( + presence: any(named: 'presence'), + filter: any(named: 'filter'), + sort: any(named: 'sort'), + pagination: any(named: 'pagination'), + )).thenAnswer((_) async => QueryUsersResponse()..users = users); + + expectLater( + // skipping initial seed event -> {} users + client.state.usersStream.skip(1), + emitsInOrder([ + {for (var user in users) user.id: user}, + ]), + ); + + final res = await client.queryUsers(); + expect(res, isNotNull); + expect(res.users.length, users.length); + + verify(() => api.user.queryUsers( + presence: any(named: 'presence'), + filter: any(named: 'filter'), + sort: any(named: 'sort'), + pagination: any(named: 'pagination'), + )).called(1); + verifyNoMoreInteractions(api.user); + }); + + test('`.search`', () async { + const cid = 'test-type:test-id'; + final filter = Filter.in_('cid', const [cid]); + + final messages = List.generate( + 3, + (index) => GetMessageResponse() + ..channel = ChannelModel(cid: cid) + ..message = Message(id: 'test-message-id-$index'), + ); + + when(() => api.general.searchMessages(filter, + query: any(named: 'query'), + sort: any(named: 'sort'), + pagination: any(named: 'pagination'), + messageFilters: any(named: 'messageFilters'))) + .thenAnswer( + (_) async => SearchMessagesResponse()..results = messages); + + final res = await client.search(filter); + expect(res, isNotNull); + expect(res.results.length, messages.length); + + verify(() => api.general.searchMessages(filter, + query: any(named: 'query'), + sort: any(named: 'sort'), + pagination: any(named: 'pagination'), + messageFilters: any(named: 'messageFilters'))).called(1); + verifyNoMoreInteractions(api.general); + }); + + test('`.sendFile`', () async { + const channelId = 'test-channel-id'; + const channelType = 'test-channel-type'; + final file = AttachmentFile(size: 33, path: 'test-file-path'); + + const fileUrl = 'test-file-url'; + + when(() => api.fileUploader.sendFile(file, channelId, channelType)) + .thenAnswer((_) async => SendFileResponse()..file = fileUrl); + + final res = await client.sendFile(file, channelId, channelType); + expect(res, isNotNull); + expect(res.file, fileUrl); + + verify(() => api.fileUploader.sendFile(file, channelId, channelType)) + .called(1); + verifyNoMoreInteractions(api.fileUploader); + }); + + test('`.sendImage`', () async { + const channelId = 'test-channel-id'; + const channelType = 'test-channel-type'; + final image = AttachmentFile(size: 33, path: 'test-image-path'); + + const fileUrl = 'test-image-url'; + + when(() => api.fileUploader.sendImage(image, channelId, channelType)) + .thenAnswer((_) async => SendImageResponse()..file = fileUrl); + + final res = await client.sendImage(image, channelId, channelType); + expect(res, isNotNull); + expect(res.file, fileUrl); + + verify(() => api.fileUploader.sendImage(image, channelId, channelType)) + .called(1); + verifyNoMoreInteractions(api.fileUploader); + }); + + test('`.deleteFile`', () async { + const channelId = 'test-channel-id'; + const channelType = 'test-channel-type'; + const fileUrl = 'test-file-url'; + + when(() => api.fileUploader.deleteFile(fileUrl, channelId, channelType)) + .thenAnswer((_) async => EmptyResponse()); + + final res = await client.deleteFile(fileUrl, channelId, channelType); + expect(res, isNotNull); + + verify(() => api.fileUploader.deleteFile(fileUrl, channelId, channelType)) + .called(1); + verifyNoMoreInteractions(api.fileUploader); + }); + + test('`.deleteImage`', () async { + const channelId = 'test-channel-id'; + const channelType = 'test-channel-type'; + const imageUrl = 'test-image-url'; + + when(() => api.fileUploader.deleteImage(imageUrl, channelId, channelType)) + .thenAnswer((_) async => EmptyResponse()); + + final res = await client.deleteImage(imageUrl, channelId, channelType); + expect(res, isNotNull); + + verify( + () => api.fileUploader.deleteImage(imageUrl, channelId, channelType), + ).called(1); + verifyNoMoreInteractions(api.fileUploader); + }); + + test('`.updateChannel`', () async { + const channelId = 'test-channel-id'; + const channelType = 'test-channel-type'; + const data = {'name': 'test-channel'}; + + when(() => api.channel.updateChannel(channelId, channelType, data)) + .thenAnswer((invocation) async => UpdateChannelResponse() + ..channel = ChannelModel( + id: channelId, + type: channelType, + extraData: {...data}, + )); + + final res = await client.updateChannel(channelId, channelType, data); + expect(res, isNotNull); + expect(res.channel.cid, '$channelType:$channelId'); + expect(res.channel.extraData['name'], 'test-channel'); + + verify(() => api.channel.updateChannel(channelId, channelType, data)) + .called(1); + verifyNoMoreInteractions(api.channel); + }); + + test('`.updateChannelPartial`', () async { + const channelId = 'test-channel-id'; + const channelType = 'test-channel-type'; + const set = { + 'name': 'Stream Team', + 'profile_image': 'test-profile-image', + }; + const unset = ['tag', 'last_name']; + + when(() => api.channel.updateChannelPartial(channelId, channelType, + set: set, unset: unset)) + .thenAnswer((invocation) async => PartialUpdateChannelResponse() + ..channel = ChannelModel( + id: channelId, + type: channelType, + extraData: {...set}, + )); + + final res = await client.updateChannelPartial( + channelId, + channelType, + set: set, + unset: unset, + ); + expect(res, isNotNull); + expect(res.channel.cid, '$channelType:$channelId'); + expect(res.channel.extraData, set); + + verify(() => api.channel.updateChannelPartial(channelId, channelType, + set: set, unset: unset)).called(1); + verifyNoMoreInteractions(api.channel); + }); + + test('`.addDevice`', () async { + const id = 'test-device-id'; + const provider = PushProvider.firebase; + + when(() => api.device.addDevice(id, provider)) + .thenAnswer((_) async => EmptyResponse()); + + final res = await client.addDevice(id, provider); + expect(res, isNotNull); + + verify(() => api.device.addDevice(id, provider)).called(1); + verifyNoMoreInteractions(api.device); + }); + + test('`.getDevices`', () async { + final devices = List.generate( + 3, + (index) => Device( + id: 'test-device-id-$index', + pushProvider: PushProvider.firebase.name, + ), + ); + + when(() => api.device.getDevices()) + .thenAnswer((_) async => ListDevicesResponse()..devices = devices); + + final res = await client.getDevices(); + expect(res, isNotNull); + expect(res.devices.length, devices.length); + + verify(() => api.device.getDevices()).called(1); + verifyNoMoreInteractions(api.device); + }); + + test('`.removeDevice`', () async { + const deviceId = 'test-device-id'; + + when(() => api.device.removeDevice(deviceId)) + .thenAnswer((_) async => EmptyResponse()); + + final res = await client.removeDevice(deviceId); + expect(res, isNotNull); + + verify(() => api.device.removeDevice(deviceId)).called(1); + verifyNoMoreInteractions(api.device); + }); + + test('`.devToken`', () async { + const userId = 'test-user-id'; + + final token = client.devToken(userId); + + expect(token, isNotNull); + expect(token.userId, userId); + expect(token.authType, AuthType.jwt); + }); + + group('`.channel`', () { + test('should return back a new channel instance', () { + const channelType = 'test-channel-type'; + const channelId = 'test-channel-id'; + const channelData = {'name': 'test-channel-name'}; + + final channel = client.channel( + channelType, + id: channelId, + extraData: channelData, + ); + + expect(channel, isNotNull); + expect(channel.type, channelType); + expect(channel.id, channelId); + expect(channel.cid, '$channelType:$channelId'); + expect(channel.extraData, channelData); + }); + + test('should return back in memory channel instance if available', + () async { + const channelType = 'test-channel-type'; + const channelId = 'test-channel-id'; + const channelData = {'name': 'test-channel-name'}; + const channelCid = '$channelType:$channelId'; + + final channel = client.channel( + channelType, + id: channelId, + extraData: channelData, + ); + + final channelState = ChannelState( + channel: ChannelModel(cid: channelCid), + ); + + when(() => api.channel.queryChannel( + channelType, + channelId: channelId, + channelData: channelData, + state: any(named: 'state'), + watch: any(named: 'watch'), + presence: any(named: 'presence'), + messagesPagination: any(named: 'messagesPagination'), + membersPagination: any(named: 'membersPagination'), + watchersPagination: any(named: 'watchersPagination'), + )).thenAnswer((_) async => channelState); + + expectLater( + client.state.channelsStream.skip(1), + emitsInOrder([ + {channelCid: isCorrectChannelFor(channelState)} + ]), + ); + + await channel.watch(); + + final newChannel = client.channel(channelType, id: channelId); + expect(newChannel, channel); + + verify(() => api.channel.queryChannel( + channelType, + channelId: channelId, + channelData: channelData, + state: any(named: 'state'), + watch: any(named: 'watch'), + presence: any(named: 'presence'), + messagesPagination: any(named: 'messagesPagination'), + membersPagination: any(named: 'membersPagination'), + watchersPagination: any(named: 'watchersPagination'), + )).called(1); + }); + }); + + test('`.createChannel`', () async { + const channelType = 'test-channel-type'; + const channelId = 'test-channel-id'; + const channelData = {'name': 'test-channel-name'}; + const channelCid = '$channelType:$channelId'; + + final channelState = ChannelState( + channel: ChannelModel(cid: channelCid, extraData: channelData), + ); + + when(() => api.channel.queryChannel( + channelType, + channelId: channelId, + channelData: channelData, + state: any(named: 'state'), + watch: any(named: 'watch'), + presence: any(named: 'presence'), + messagesPagination: any(named: 'messagesPagination'), + membersPagination: any(named: 'membersPagination'), + watchersPagination: any(named: 'watchersPagination'), + )).thenAnswer((_) async => channelState); + + final res = await client.createChannel( + channelType, + channelId: channelId, + channelData: channelData, + ); + + expect(res, isNotNull); + expect(res.channel, isNotNull); + final channel = res.channel!; + expect(channel.type, channelType); + expect(channel.id, channelId); + expect(channel.cid, '$channelType:$channelId'); + expect(channel.extraData, channelData); + + verify(() => api.channel.queryChannel( + channelType, + channelId: channelId, + channelData: channelData, + state: any(named: 'state'), + watch: any(named: 'watch'), + presence: any(named: 'presence'), + messagesPagination: any(named: 'messagesPagination'), + membersPagination: any(named: 'membersPagination'), + watchersPagination: any(named: 'watchersPagination'), + )).called(1); + verifyNoMoreInteractions(api.channel); + }); + + test('`.watchChannel`', () async { + const channelType = 'test-channel-type'; + const channelId = 'test-channel-id'; + const channelData = {'name': 'test-channel-name'}; + const channelCid = '$channelType:$channelId'; + + final channelState = ChannelState( + channel: ChannelModel(cid: channelCid, extraData: channelData), + ); + + when(() => api.channel.queryChannel( + channelType, + channelId: channelId, + channelData: channelData, + state: any(named: 'state'), + watch: any(named: 'watch'), + presence: any(named: 'presence'), + messagesPagination: any(named: 'messagesPagination'), + membersPagination: any(named: 'membersPagination'), + watchersPagination: any(named: 'watchersPagination'), + )).thenAnswer((_) async => channelState); + + final res = await client.watchChannel( + channelType, + channelId: channelId, + channelData: channelData, + ); + + expect(res, isNotNull); + expect(res.channel, isNotNull); + final channel = res.channel!; + expect(channel.type, channelType); + expect(channel.id, channelId); + expect(channel.cid, '$channelType:$channelId'); + expect(channel.extraData, channelData); + + verify(() => api.channel.queryChannel( + channelType, + channelId: channelId, + channelData: channelData, + state: any(named: 'state'), + watch: any(named: 'watch'), + presence: any(named: 'presence'), + messagesPagination: any(named: 'messagesPagination'), + membersPagination: any(named: 'membersPagination'), + watchersPagination: any(named: 'watchersPagination'), + )).called(1); + verifyNoMoreInteractions(api.channel); + }); + + test('`.queryChannel`', () async { + const channelType = 'test-channel-type'; + const channelId = 'test-channel-id'; + const channelData = {'name': 'test-channel-name'}; + const channelCid = '$channelType:$channelId'; + + final channelState = ChannelState( + channel: ChannelModel(cid: channelCid, extraData: channelData), + ); + + when(() => api.channel.queryChannel( + channelType, + channelId: channelId, + channelData: channelData, + state: any(named: 'state'), + watch: any(named: 'watch'), + presence: any(named: 'presence'), + messagesPagination: any(named: 'messagesPagination'), + membersPagination: any(named: 'membersPagination'), + watchersPagination: any(named: 'watchersPagination'), + )).thenAnswer((_) async => channelState); + + final res = await client.queryChannel( + channelType, + channelId: channelId, + channelData: channelData, + ); + + expect(res, isNotNull); + expect(res.channel, isNotNull); + final channel = res.channel!; + expect(channel.type, channelType); + expect(channel.id, channelId); + expect(channel.cid, '$channelType:$channelId'); + expect(channel.extraData, channelData); + + verify(() => api.channel.queryChannel( + channelType, + channelId: channelId, + channelData: channelData, + state: any(named: 'state'), + watch: any(named: 'watch'), + presence: any(named: 'presence'), + messagesPagination: any(named: 'messagesPagination'), + membersPagination: any(named: 'membersPagination'), + watchersPagination: any(named: 'watchersPagination'), + )).called(1); + verifyNoMoreInteractions(api.channel); + }); + + test('`.queryMembers`', () async { + const channelType = 'test-channel-type'; + + final members = List.generate( + 3, + (index) => Member(userId: 'test-user-id-$index'), + ); + + when(() => api.general.queryMembers(channelType)).thenAnswer( + (_) async => QueryMembersResponse()..members = members, + ); + + final res = await client.queryMembers(channelType); + expect(res, isNotNull); + expect(res.members.length, members.length); + + verify(() => api.general.queryMembers(channelType)).called(1); + verifyNoMoreInteractions(api.general); + }); + + test('`.hideChannel`', () async { + const channelType = 'test-channel-type'; + const channelId = 'test-channel-id'; + + when(() => api.channel.hideChannel(channelId, channelType)) + .thenAnswer((_) async => EmptyResponse()); + + final res = await client.hideChannel(channelId, channelType); + + expect(res, isNotNull); + + verify(() => api.channel.hideChannel(channelId, channelType)).called(1); + verifyNoMoreInteractions(api.channel); + }); + + test('`.showChannel`', () async { + const channelType = 'test-channel-type'; + const channelId = 'test-channel-id'; + + when(() => api.channel.showChannel(channelId, channelType)) + .thenAnswer((_) async => EmptyResponse()); + + final res = await client.showChannel(channelId, channelType); + + expect(res, isNotNull); + + verify(() => api.channel.showChannel(channelId, channelType)).called(1); + verifyNoMoreInteractions(api.channel); + }); + + test('`.deleteChannel`', () async { + const channelType = 'test-channel-type'; + const channelId = 'test-channel-id'; + + when(() => api.channel.deleteChannel(channelId, channelType)) + .thenAnswer((_) async => EmptyResponse()); + + final res = await client.deleteChannel(channelId, channelType); + + expect(res, isNotNull); + + verify(() => api.channel.deleteChannel(channelId, channelType)).called(1); + verifyNoMoreInteractions(api.channel); + }); + + test('`.truncateChannel`', () async { + const channelType = 'test-channel-type'; + const channelId = 'test-channel-id'; + + when(() => api.channel.truncateChannel(channelId, channelType)) + .thenAnswer((_) async => EmptyResponse()); + + final res = await client.truncateChannel(channelId, channelType); + + expect(res, isNotNull); + + verify( + () => api.channel.truncateChannel(channelId, channelType), + ).called(1); + verifyNoMoreInteractions(api.channel); + }); + + test('`.muteChannel`', () async { + const channelType = 'test-channel-type'; + const channelId = 'test-channel-id'; + const channelCid = '$channelType:$channelId'; + + when(() => api.moderation.muteChannel(channelCid)) + .thenAnswer((_) async => EmptyResponse()); + + final res = await client.muteChannel(channelCid); + + expect(res, isNotNull); + + verify(() => api.moderation.muteChannel(channelCid)).called(1); + verifyNoMoreInteractions(api.moderation); + }); + + test('`.unmuteChannel`', () async { + const channelType = 'test-channel-type'; + const channelId = 'test-channel-id'; + const channelCid = '$channelType:$channelId'; + + when(() => api.moderation.unmuteChannel(channelCid)) + .thenAnswer((_) async => EmptyResponse()); + + final res = await client.unmuteChannel(channelCid); + + expect(res, isNotNull); + + verify(() => api.moderation.unmuteChannel(channelCid)).called(1); + verifyNoMoreInteractions(api.moderation); + }); + + test('`.acceptChannelInvite`', () async { + const channelType = 'test-channel-type'; + const channelId = 'test-channel-id'; + const channelCid = '$channelType:$channelId'; + + when(() => api.channel.acceptChannelInvite(channelId, channelType)) + .thenAnswer((_) async => + AcceptInviteResponse()..channel = ChannelModel(cid: channelCid)); + + final res = await client.acceptChannelInvite(channelId, channelType); + expect(res, isNotNull); + expect(res.channel.cid, channelCid); + + verify(() => api.channel.acceptChannelInvite(channelId, channelType)) + .called(1); + verifyNoMoreInteractions(api.channel); + }); + + test('`.rejectChannelInvite`', () async { + const channelType = 'test-channel-type'; + const channelId = 'test-channel-id'; + const channelCid = '$channelType:$channelId'; + + when(() => api.channel.rejectChannelInvite(channelId, channelType)) + .thenAnswer((_) async => + RejectInviteResponse()..channel = ChannelModel(cid: channelCid)); + + final res = await client.rejectChannelInvite(channelId, channelType); + expect(res, isNotNull); + expect(res.channel.cid, channelCid); + + verify(() => api.channel.rejectChannelInvite(channelId, channelType)) + .called(1); + verifyNoMoreInteractions(api.channel); + }); + + test('`.addChannelMembers`', () async { + const channelType = 'test-channel-type'; + const channelId = 'test-channel-id'; + const channelCid = '$channelType:$channelId'; + + final members = List.generate( + 3, + (index) => Member(userId: 'test-user-id-$index'), + ); + + final memberIds = members.map((e) => e.userId!).toList(growable: false); + + when(() => api.channel.addMembers(channelId, channelType, memberIds)) + .thenAnswer((_) async => AddMembersResponse() + ..channel = ChannelModel(cid: channelCid) + ..members = members); + + final res = await client.addChannelMembers( + channelId, + channelType, + memberIds, + ); + + expect(res, isNotNull); + expect(res.channel.cid, channelCid); + expect(res.members.length, memberIds.length); + + verify( + () => api.channel.addMembers(channelId, channelType, memberIds), + ).called(1); + verifyNoMoreInteractions(api.channel); + }); + + test('`.removeChannelMembers`', () async { + const channelType = 'test-channel-type'; + const channelId = 'test-channel-id'; + const channelCid = '$channelType:$channelId'; + + final members = List.generate( + 3, + (index) => Member(userId: 'test-user-id-$index'), + ); + + final memberIds = members.map((e) => e.userId!).toList(growable: false); + + when(() => api.channel.removeMembers(channelId, channelType, memberIds)) + .thenAnswer((_) async => RemoveMembersResponse() + ..channel = ChannelModel(cid: channelCid) + ..members = members); + + final res = await client.removeChannelMembers( + channelId, + channelType, + memberIds, + ); + + expect(res, isNotNull); + expect(res.channel.cid, channelCid); + expect(res.members.length, memberIds.length); + + verify( + () => api.channel.removeMembers(channelId, channelType, memberIds), + ).called(1); + verifyNoMoreInteractions(api.channel); + }); + + test('`.inviteChannelMembers`', () async { + const channelType = 'test-channel-type'; + const channelId = 'test-channel-id'; + const channelCid = '$channelType:$channelId'; + + final members = List.generate( + 3, + (index) => Member(userId: 'test-user-id-$index'), + ); + + final memberIds = members.map((e) => e.userId!).toList(growable: false); + + when(() => api.channel + .inviteChannelMembers(channelId, channelType, memberIds)) + .thenAnswer((_) async => InviteMembersResponse() + ..channel = ChannelModel(cid: channelCid) + ..members = members); + + final res = await client.inviteChannelMembers( + channelId, + channelType, + memberIds, + ); + + expect(res, isNotNull); + expect(res.channel.cid, channelCid); + expect(res.members.length, memberIds.length); + + verify(() => api.channel + .inviteChannelMembers(channelId, channelType, memberIds)).called(1); + verifyNoMoreInteractions(api.channel); + }); + + test('`.stopChannelWatching`', () async { + const channelType = 'test-channel-type'; + const channelId = 'test-channel-id'; + + when(() => api.channel.stopWatching(channelId, channelType)) + .thenAnswer((_) async => EmptyResponse()); + + final res = await client.stopChannelWatching(channelId, channelType); + expect(res, isNotNull); + + verify(() => api.channel.stopWatching(channelId, channelType)).called(1); + verifyNoMoreInteractions(api.channel); + }); + + test('`.sendAction`', () async { + const channelType = 'test-channel-type'; + const channelId = 'test-channel-id'; + const messageId = 'test-message-id'; + const formData = {'key': 'value'}; + + when(() => api.message + .sendAction(channelId, channelType, messageId, formData)) + .thenAnswer((_) async => SendActionResponse()); + + final res = await client.sendAction( + channelId, + channelType, + messageId, + formData, + ); + + expect(res, isNotNull); + + verify(() => api.message + .sendAction(channelId, channelType, messageId, formData)).called(1); + verifyNoMoreInteractions(api.message); + }); + + test('`.markChannelRead`', () async { + const channelType = 'test-channel-type'; + const channelId = 'test-channel-id'; + + when(() => api.channel.markRead(channelId, channelType)) + .thenAnswer((_) async => EmptyResponse()); + + final res = await client.markChannelRead(channelId, channelType); + + expect(res, isNotNull); + + verify(() => api.channel.markRead(channelId, channelType)).called(1); + verifyNoMoreInteractions(api.channel); + }); + + test('`.updateUser`', () async { + final user = User( + id: 'test-user-id', + extraData: const {'name': 'test-user'}, + ); + + when(() => api.user.updateUsers([user])).thenAnswer( + (_) async => UpdateUsersResponse()..users = {user.id: user}); + + final res = await client.updateUser(user); + + expect(res, isNotNull); + expect(res.users, {user.id: user}); + + verify(() => api.user.updateUsers([user])).called(1); + verifyNoMoreInteractions(api.user); + }); + + test('`.banUser`', () async { + const userId = 'test-user-id'; + + when(() => api.moderation.banUser(userId, options: any(named: 'options'))) + .thenAnswer((_) async => EmptyResponse()); + + final res = await client.banUser(userId); + + expect(res, isNotNull); + + verify( + () => api.moderation.banUser(userId, options: any(named: 'options')), + ).called(1); + verifyNoMoreInteractions(api.moderation); + }); + + test('`.unbanUser`', () async { + const userId = 'test-user-id'; + + when(() => + api.moderation.unbanUser(userId, options: any(named: 'options'))) + .thenAnswer((_) async => EmptyResponse()); + + final res = await client.unbanUser(userId); + + expect(res, isNotNull); + + verify( + () => api.moderation.unbanUser(userId, options: any(named: 'options')), + ).called(1); + verifyNoMoreInteractions(api.moderation); + }); + + test('`.shadowBan`', () async { + const userId = 'test-user-id'; + + when(() => api.moderation.banUser(userId, options: {'shadow': true})) + .thenAnswer((_) async => EmptyResponse()); + + final res = await client.shadowBan(userId); + + expect(res, isNotNull); + + verify( + () => api.moderation.banUser(userId, options: {'shadow': true}), + ).called(1); + verifyNoMoreInteractions(api.moderation); + }); + + test('`.removeShadowBan`', () async { + const userId = 'test-user-id'; + + when(() => api.moderation.unbanUser(userId, options: {'shadow': true})) + .thenAnswer((_) async => EmptyResponse()); + + final res = await client.removeShadowBan(userId); + + expect(res, isNotNull); + + verify( + () => api.moderation.unbanUser(userId, options: {'shadow': true}), + ).called(1); + verifyNoMoreInteractions(api.moderation); + }); + + test('`.muteUser`', () async { + const userId = 'test-user-id'; + + when(() => api.moderation.muteUser(userId)) + .thenAnswer((_) async => EmptyResponse()); + + final res = await client.muteUser(userId); + + expect(res, isNotNull); + + verify(() => api.moderation.muteUser(userId)).called(1); + verifyNoMoreInteractions(api.moderation); + }); + + test('`.unmuteUser`', () async { + const userId = 'test-user-id'; + + when(() => api.moderation.unmuteUser(userId)) + .thenAnswer((_) async => EmptyResponse()); + + final res = await client.unmuteUser(userId); + + expect(res, isNotNull); + + verify(() => api.moderation.unmuteUser(userId)).called(1); + verifyNoMoreInteractions(api.moderation); + }); + + test('`.flagMessage`', () async { + const messageId = 'test-message-id'; + + when(() => api.moderation.flagMessage(messageId)) + .thenAnswer((_) async => EmptyResponse()); + + final res = await client.flagMessage(messageId); + + expect(res, isNotNull); + + verify(() => api.moderation.flagMessage(messageId)).called(1); + verifyNoMoreInteractions(api.moderation); + }); + + test('`.unflagMessage`', () async { + const messageId = 'test-message-id'; + + when(() => api.moderation.unflagMessage(messageId)) + .thenAnswer((_) async => EmptyResponse()); + + final res = await client.unflagMessage(messageId); + + expect(res, isNotNull); + + verify(() => api.moderation.unflagMessage(messageId)).called(1); + verifyNoMoreInteractions(api.moderation); + }); + + test('`.flagUser`', () async { + const userId = 'test-message-id'; + + when(() => api.moderation.flagUser(userId)) + .thenAnswer((_) async => EmptyResponse()); + + final res = await client.flagUser(userId); + + expect(res, isNotNull); + + verify(() => api.moderation.flagUser(userId)).called(1); + verifyNoMoreInteractions(api.moderation); + }); + + test('`.unflagUser`', () async { + const userId = 'test-message-id'; + + when(() => api.moderation.unflagUser(userId)) + .thenAnswer((_) async => EmptyResponse()); + + final res = await client.unflagUser(userId); + + expect(res, isNotNull); + + verify(() => api.moderation.unflagUser(userId)).called(1); + verifyNoMoreInteractions(api.moderation); + }); + + test('`.markAllRead`', () async { + when(() => api.channel.markAllRead()) + .thenAnswer((_) async => EmptyResponse()); + + final res = await client.markAllRead(); + expect(res, isNotNull); + + verify(() => api.channel.markAllRead()).called(1); + verifyNoMoreInteractions(api.channel); + }); + + test('`.sendEvent`', () async { + const channelType = 'test-channel-type'; + const channelId = 'test-channel-id'; + final event = Event(type: EventType.any); + + when( + () => api.channel.sendEvent( + channelId, + channelType, + any(that: isSameEventAs(event)), + ), + ).thenAnswer((_) async => EmptyResponse()); + + final res = await client.sendEvent(channelId, channelType, event); + expect(res, isNotNull); + + verify(() => api.channel.sendEvent( + channelId, + channelType, + any(that: isSameEventAs(event)), + )).called(1); + verifyNoMoreInteractions(api.channel); + }); + + test('`.sendReaction`', () async { + const messageId = 'test-message-id'; + const reactionType = 'like'; + + when(() => api.message.sendReaction(messageId, reactionType)) + .thenAnswer((_) async => SendReactionResponse() + ..message = Message(id: messageId) + ..reaction = Reaction(type: reactionType, messageId: messageId)); + + final res = await client.sendReaction(messageId, reactionType); + expect(res, isNotNull); + expect(res.message.id, messageId); + expect(res.reaction.type, reactionType); + expect(res.reaction.messageId, messageId); + + verify(() => api.message.sendReaction(messageId, reactionType)).called(1); + verifyNoMoreInteractions(api.message); + }); + + test('`.deleteReaction`', () async { + const messageId = 'test-message-id'; + const reactionType = 'like'; + + when(() => api.message.deleteReaction(messageId, reactionType)) + .thenAnswer((_) async => EmptyResponse()); + + final res = await client.deleteReaction(messageId, reactionType); + expect(res, isNotNull); + + verify( + () => api.message.deleteReaction(messageId, reactionType), + ).called(1); + verifyNoMoreInteractions(api.message); + }); + + test('`.sendMessage`', () async { + final message = Message(id: 'test-message-id'); + const channelId = 'test-channel-id'; + const channelType = 'test-channel-type'; + + when(() => api.message.sendMessage( + channelId, channelType, any(that: isSameMessageAs(message)))) + .thenAnswer((_) async => SendMessageResponse()..message = message); + + final res = await client.sendMessage(message, channelId, channelType); + expect(res, isNotNull); + expect(res.message, isSameMessageAs(message)); + + verify(() => api.message.sendMessage( + channelId, + channelType, + any(that: isSameMessageAs(message)), + )).called(1); + verifyNoMoreInteractions(api.message); + }); + + test('`.getReplies`', () async { + const parentId = 'test-parent-id'; + + final messages = List.generate( + 3, + (index) => Message(id: 'test-message-id-$index'), + ); + + when(() => api.message.getReplies(parentId)) + .thenAnswer((_) async => QueryRepliesResponse()..messages = messages); + + final res = await client.getReplies(parentId); + expect(res, isNotNull); + expect(res.messages.length, messages.length); + + verify(() => api.message.getReplies(parentId)).called(1); + verifyNoMoreInteractions(api.message); + }); + + test('`.getReactions`', () async { + const messageId = 'test-parent-id'; + + final reactions = List.generate( + 3, + (index) => Reaction( + type: 'test-reactions-type-$index', + messageId: messageId, + ), + ); + + when(() => api.message.getReactions(messageId)).thenAnswer( + (_) async => QueryReactionsResponse()..reactions = reactions); + + final res = await client.getReactions(messageId); + expect(res, isNotNull); + expect(res.reactions.length, reactions.length); + expect(res.reactions.every((it) => it.messageId == messageId), isTrue); + + verify(() => api.message.getReactions(messageId)).called(1); + verifyNoMoreInteractions(api.message); + }); + + test('`.updateMessage`', () async { + final message = Message(id: 'test-message-id', text: 'Hello!'); + + when(() => api.message.updateMessage(any(that: isSameMessageAs(message)))) + .thenAnswer((_) async => UpdateMessageResponse()..message = message); + + final res = await client.updateMessage(message); + expect(res, isNotNull); + expect(res.message, isSameMessageAs(message)); + + verify( + () => api.message.updateMessage(any(that: isSameMessageAs(message))), + ).called(1); + verifyNoMoreInteractions(api.message); + }); + + test('`.deleteMessage`', () async { + const messageId = 'test-message-id'; + + when(() => api.message.deleteMessage(messageId)) + .thenAnswer((_) async => EmptyResponse()); + + final res = await client.deleteMessage(messageId); + expect(res, isNotNull); + + verify(() => api.message.deleteMessage(messageId)).called(1); + verifyNoMoreInteractions(api.message); + }); + + test('`.getMessage`', () async { + const messageId = 'test-message-id'; + final message = Message(id: messageId); + + when(() => api.message.getMessage(messageId)) + .thenAnswer((_) async => GetMessageResponse()..message = message); + + final res = await client.getMessage(messageId); + expect(res, isNotNull); + expect(res.message.id, messageId); + + verify(() => api.message.getMessage(messageId)).called(1); + verifyNoMoreInteractions(api.message); + }); + + test('`.getMessagesById`', () async { + const channelId = 'test-channel-id'; + const channelType = 'test-channel-type'; + const messageIds = ['test-message-id']; + + final messages = messageIds.map((id) => Message(id: id)).toList(); + + when( + () => api.message.getMessagesById(channelId, channelType, messageIds), + ).thenAnswer((_) async => GetMessagesByIdResponse()..messages = messages); + + final res = await client.getMessagesById( + channelId, + channelType, + messageIds, + ); + expect(res, isNotNull); + expect(res.messages.length, messageIds.length); + + verify( + () => api.message.getMessagesById(channelId, channelType, messageIds), + ).called(1); + verifyNoMoreInteractions(api.message); + }); + + test('`.translateMessage`', () async { + const messageId = 'test-message-id'; + const language = 'hi'; // Hindi + const translatedMessageText = 'नमस्ते'; + final translatedMessage = TranslatedMessage(const { + language: translatedMessageText, + }); + + when(() => api.message.translateMessage(messageId, language)).thenAnswer( + (_) async => TranslateMessageResponse()..message = translatedMessage, + ); + + final res = await client.translateMessage(messageId, language); + + expect(res, isNotNull); + expect(res.message.i18n, translatedMessage.i18n); + + verify(() => api.message.translateMessage(messageId, language)).called(1); + verifyNoMoreInteractions(api.message); + }); + + test('`.partialUpdateMessage`', () async { + const messageId = 'test-message-id'; + final message = Message(id: messageId); + + const set = {'text': 'Update Message text'}; + const unset = ['pinExpires']; + + final updateMessageResponse = UpdateMessageResponse() + ..message = message.copyWith(text: set['text'], pinExpires: null); + + when(() => api.message.partialUpdateMessage( + message.id, + set: set, + unset: unset, + )).thenAnswer((_) async => updateMessageResponse); + + final res = await client.partialUpdateMessage( + messageId, + set: set, + unset: unset, + ); + + expect(res, isNotNull); + expect(res.message.id, message.id); + expect(res.message.id, message.id); + expect(res.message.text, set['text']); + expect(res.message.pinExpires, isNull); + + verify(() => api.message.partialUpdateMessage( + message.id, + set: set, + unset: unset, + )).called(1); + verifyNoMoreInteractions(api.message); + }); + + group('`.pinMessage`', () { + test('should work fine without passing timeoutOrExpirationDate', + () async { + const messageId = 'test-message-id'; + final message = Message(id: messageId); + + when(() => api.message.partialUpdateMessage( + messageId, + set: any(named: 'set'), + unset: any(named: 'unset'), + )).thenAnswer((_) async => UpdateMessageResponse() + ..message = message.copyWith( + pinned: true, + pinExpires: null, + status: MessageSendingStatus.sent, + )); + + final res = await client.pinMessage(messageId); + + expect(res, isNotNull); + expect(res.message.pinned, isTrue); + expect(res.message.pinExpires, isNull); + + verify(() => api.message.partialUpdateMessage( + messageId, + set: any(named: 'set'), + unset: any(named: 'unset'), + )).called(1); + verifyNoMoreInteractions(api.message); + }); + + test( + 'should work fine if passed timeoutOrExpirationDate as num(seconds)', + () async { + const messageId = 'test-message-id'; + final message = Message(id: messageId); + const timeoutOrExpirationDate = 300; // 300 seconds + + when(() => api.message.partialUpdateMessage( + message.id, + set: any(named: 'set'), + unset: any(named: 'unset'), + )).thenAnswer((_) async => UpdateMessageResponse() + ..message = message.copyWith( + pinned: true, + pinExpires: DateTime.now().add( + const Duration(seconds: timeoutOrExpirationDate), + ), + status: MessageSendingStatus.sent, + )); + + final res = await client.pinMessage( + messageId, + timeoutOrExpirationDate: timeoutOrExpirationDate, + ); + + expect(res, isNotNull); + expect(res.message.pinned, isTrue); + expect(res.message.pinExpires, isNotNull); + + verify(() => api.message.partialUpdateMessage( + messageId, + set: any(named: 'set'), + unset: any(named: 'unset'), + )).called(1); + verifyNoMoreInteractions(api.message); + }, + ); + + test( + 'should work fine if passed timeoutOrExpirationDate as DateTime', + () async { + const messageId = 'test-message-id'; + final message = Message(id: messageId); + final timeoutOrExpirationDate = + DateTime.now().add(const Duration(days: 3)); // 3 days + + when(() => api.message.partialUpdateMessage( + messageId, + set: any(named: 'set'), + unset: any(named: 'unset'), + )).thenAnswer((_) async => UpdateMessageResponse() + ..message = message.copyWith( + pinned: true, + pinExpires: timeoutOrExpirationDate, + status: MessageSendingStatus.sent, + )); + + final res = await client.pinMessage( + messageId, + timeoutOrExpirationDate: timeoutOrExpirationDate, + ); + + expect(res, isNotNull); + expect(res.message.pinned, isTrue); + expect(res.message.pinExpires, isNotNull); + expect(res.message.pinExpires, timeoutOrExpirationDate.toUtc()); + + verify(() => api.message.partialUpdateMessage( + messageId, + set: any(named: 'set'), + unset: any(named: 'unset'), + )).called(1); + verifyNoMoreInteractions(api.message); + }, + ); + + test( + 'should throw if invalid timeoutOrExpirationDate is passed', + () async { + const messageId = 'test-message-id'; + const timeoutOrExpirationDate = 'invalid-value'; + + try { + await client.pinMessage( + messageId, + timeoutOrExpirationDate: timeoutOrExpirationDate, + ); + } catch (e) { + expect(e, isA()); + } + }, + ); + }); + + test('`.unpinMessage`', () async { + const messageId = 'test-message-id'; + final message = Message(id: messageId, pinned: true); + + when(() => api.message.partialUpdateMessage( + messageId, + set: {'pinned': false}, + )).thenAnswer((_) async => UpdateMessageResponse() + ..message = message.copyWith( + pinned: false, + status: MessageSendingStatus.sent, + )); + + final res = await client.unpinMessage(messageId); + + expect(res, isNotNull); + expect(res.message.pinned, isFalse); + + verify(() => api.message.partialUpdateMessage( + messageId, + set: {'pinned': false}, + )).called(1); + verifyNoMoreInteractions(api.message); + }); + }); +} diff --git a/packages/stream_chat/test/src/api/retry_queue_test.dart b/packages/stream_chat/test/src/api/retry_queue_test.dart new file mode 100644 index 00000000..084b82ec --- /dev/null +++ b/packages/stream_chat/test/src/api/retry_queue_test.dart @@ -0,0 +1,74 @@ +import 'package:mocktail/mocktail.dart'; +import 'package:stream_chat/src/client/retry_policy.dart'; +import 'package:stream_chat/src/client/retry_queue.dart'; +import 'package:stream_chat/src/core/models/event.dart'; +import 'package:stream_chat/src/core/models/message.dart'; +import 'package:stream_chat/src/event_type.dart'; +import 'package:test/scaffolding.dart'; +import 'package:test/test.dart'; + +import '../mocks.dart'; + +void main() { + late final channel = MockRetryQueueChannel(); + late final logger = MockLogger(); + late RetryQueue retryQueue; + + setUpAll(() { + final retryPolicy = RetryPolicy( + shouldRetry: (_, attempt, __) => attempt < 5, + retryTimeout: (_, attempt, __) => Duration(seconds: attempt), + ); + when(() => channel.client.retryPolicy).thenReturn(retryPolicy); + + when(() => channel.client.on(EventType.connectionRecovered)).thenAnswer( + (_) => Stream.value(Event( + type: EventType.connectionRecovered, + online: false, + )), + ); + + when(() => channel.on(any(), any(), any(), any())).thenAnswer( + (_) => Stream.value( + Event(type: EventType.any), + ), + ); + }); + + setUp(() { + retryQueue = RetryQueue(channel: channel, logger: logger); + }); + + tearDown(() { + retryQueue.dispose(); + }); + + group('`.add`', () { + test('should return if message list is empty', () { + expect(() => retryQueue.add([]), returnsNormally); + verifyNever(() => logger.info(any())); + }); + + test('should return if queue already contains the message', () { + final message = Message( + id: 'test-message-id', + text: 'Sample message test', + ); + retryQueue.add([message]); + expect(() => retryQueue.add([message]), returnsNormally); + // Called only for the first message + verify(() => logger.info('Adding 1 messages')).called(1); + }); + + test('`.add` should add failed request to the queue', () async { + final message = Message( + id: 'test-message-id', + text: 'Sample message test', + ); + retryQueue.add([message]); + expect(retryQueue.hasMessages, isTrue); + }); + }); + + // TODO: Add more tests once macbook is fixed :( +} diff --git a/packages/stream_chat/test/src/api/websocket_test.dart b/packages/stream_chat/test/src/api/websocket_test.dart deleted file mode 100644 index 238cc850..00000000 --- a/packages/stream_chat/test/src/api/websocket_test.dart +++ /dev/null @@ -1,388 +0,0 @@ -import 'dart:async'; - -import 'package:logging/logging.dart'; -import 'package:mockito/mockito.dart'; -import 'package:stream_chat/src/api/connection_status.dart'; -import 'package:stream_chat/src/api/websocket.dart'; -import 'package:stream_chat/src/models/event.dart'; -import 'package:stream_chat/src/models/user.dart'; -import 'package:stream_chat/stream_chat.dart'; -import 'package:test/test.dart'; -import 'package:web_socket_channel/web_socket_channel.dart'; - -class Functions { - WebSocketChannel connectFunc( - String url, { - Iterable protocols, - Map headers, - Duration pingInterval, - }) => - null; - - void handleFunc(Event event) => null; -} - -class MockFunctions extends Mock implements Functions {} - -class MockWSChannel extends Mock implements WebSocketChannel {} - -class MockWSSink extends Mock implements WebSocketSink {} - -void main() { - group('src/api/websocket', () { - test('should connect with correct parameters', () async { - final ConnectWebSocket connectFunc = MockFunctions().connectFunc; - - final ws = WebSocket( - baseUrl: 'baseurl', - user: User(id: 'testid'), - logger: Logger('ws'), - connectParams: {'test': 'true'}, - connectPayload: {'payload': 'test'}, - handler: (e) { - print(e); - }, - connectFunc: connectFunc, - ); - - final mockWSChannel = MockWSChannel(); - - final streamController = StreamController.broadcast(); - - const computedUrl = - 'wss://baseurl/connect?test=true&json=%7B%22payload%22%3A%22test%22%2C%22user_details%22%3A%7B%22id%22%3A%22testid%22%7D%7D'; - - when(connectFunc(computedUrl)).thenAnswer((_) => mockWSChannel); - when(mockWSChannel.sink).thenAnswer((_) => MockWSSink()); - when(mockWSChannel.stream).thenAnswer((_) { - return streamController.stream; - }); - - final timer = Timer.periodic( - const Duration(milliseconds: 100), - (_) => streamController.sink.add('{}'), - ); - - await ws.connect(); - - verify(connectFunc(computedUrl)).called(1); - expect(ws.connectionStatus, ConnectionStatus.connected); - - await streamController.close(); - timer.cancel(); - }); - }); - - test('should connect with correct parameters and handle events', () async { - final handleFunc = MockFunctions().handleFunc; - final ConnectWebSocket connectFunc = MockFunctions().connectFunc; - - final ws = WebSocket( - baseUrl: 'baseurl', - user: User(id: 'testid'), - logger: Logger('ws'), - connectParams: {'test': 'true'}, - connectPayload: {'payload': 'test'}, - handler: handleFunc, - connectFunc: connectFunc, - ); - - final mockWSChannel = MockWSChannel(); - - final StreamController streamController = - StreamController.broadcast(); - - final computedUrl = - 'wss://baseurl/connect?test=true&json=%7B%22payload%22%3A%22test%22%2C%22user_details%22%3A%7B%22id%22%3A%22testid%22%7D%7D'; - - when(connectFunc(computedUrl)).thenAnswer((_) => mockWSChannel); - when(mockWSChannel.sink).thenAnswer((_) => MockWSSink()); - when(mockWSChannel.stream).thenAnswer((_) { - return streamController.stream; - }); - - final connect = ws.connect().then((_) { - streamController.sink.add('{}'); - return Future.delayed(Duration(milliseconds: 200)); - }).then((value) { - verify(connectFunc(computedUrl)).called(1); - verify(handleFunc(any)).called(greaterThan(0)); - - return streamController.close(); - }); - - streamController.sink.add('{}'); - - return connect; - }); - - test('should close correctly the controller', () async { - final handleFunc = MockFunctions().handleFunc; - - final ConnectWebSocket connectFunc = MockFunctions().connectFunc; - - final ws = WebSocket( - baseUrl: 'baseurl', - user: User(id: 'testid'), - logger: Logger('ws'), - connectParams: {'test': 'true'}, - connectPayload: {'payload': 'test'}, - handler: handleFunc, - connectFunc: connectFunc, - ); - - final mockWSChannel = MockWSChannel(); - - final StreamController streamController = - StreamController.broadcast(); - - final computedUrl = - 'wss://baseurl/connect?test=true&json=%7B%22payload%22%3A%22test%22%2C%22user_details%22%3A%7B%22id%22%3A%22testid%22%7D%7D'; - - when(connectFunc(computedUrl)).thenAnswer((_) => mockWSChannel); - when(mockWSChannel.sink).thenAnswer((_) => MockWSSink()); - when(mockWSChannel.stream).thenAnswer((_) { - return streamController.stream; - }); - - final connect = ws.connect().then((_) { - streamController.sink.add('{}'); - return Future.delayed(Duration(milliseconds: 200)); - }).then((value) { - verify(connectFunc(computedUrl)).called(1); - verify(handleFunc(any)).called(greaterThan(0)); - - return streamController.close(); - }); - - streamController.sink.add('{}'); - - return connect; - }); - test('should close correctly the controller while connecting', () async { - final handleFunc = MockFunctions().handleFunc; - - final ConnectWebSocket connectFunc = MockFunctions().connectFunc; - - final ws = WebSocket( - baseUrl: 'baseurl', - user: User(id: 'testid'), - logger: Logger('ws'), - connectParams: {'test': 'true'}, - connectPayload: {'payload': 'test'}, - handler: handleFunc, - connectFunc: connectFunc, - ); - - final mockWSChannel = MockWSChannel(); - - final StreamController streamController = - StreamController.broadcast(); - - final computedUrl = - 'wss://baseurl/connect?test=true&json=%7B%22payload%22%3A%22test%22%2C%22user_details%22%3A%7B%22id%22%3A%22testid%22%7D%7D'; - - when(connectFunc(computedUrl)).thenAnswer((_) => mockWSChannel); - when(mockWSChannel.sink).thenAnswer((_) => MockWSSink()); - when(mockWSChannel.stream).thenAnswer((_) { - return streamController.stream; - }); - - ws.connect(); - await ws.disconnect(); - streamController.add('{}'); - - verify(connectFunc(computedUrl)).called(1); - verifyNever(handleFunc(any)); - }); - - test('should run correctly health check', () async { - final handleFunc = MockFunctions().handleFunc; - - final ConnectWebSocket connectFunc = MockFunctions().connectFunc; - - final ws = WebSocket( - baseUrl: 'baseurl', - user: User(id: 'testid'), - logger: Logger('ws'), - connectParams: {'test': 'true'}, - connectPayload: {'payload': 'test'}, - handler: handleFunc, - connectFunc: connectFunc, - ); - - final mockWSChannel = MockWSChannel(); - final mockWSSink = MockWSSink(); - - final StreamController streamController = - StreamController.broadcast(); - - final computedUrl = - 'wss://baseurl/connect?test=true&json=%7B%22payload%22%3A%22test%22%2C%22user_details%22%3A%7B%22id%22%3A%22testid%22%7D%7D'; - - when(connectFunc(computedUrl)).thenAnswer((_) => mockWSChannel); - when(mockWSChannel.stream).thenAnswer((_) { - return streamController.stream; - }); - when(mockWSChannel.sink).thenReturn(mockWSSink); - - final timer = Timer.periodic( - Duration(milliseconds: 1000), - (_) => streamController.sink.add('{}'), - ); - - final connect = ws.connect().then((_) { - streamController.sink.add('{}'); - return Future.delayed(Duration(milliseconds: 200)); - }).then((value) async { - verify(mockWSSink.add("{'type': 'health.check'}")).called(greaterThan(0)); - - timer.cancel(); - await streamController.close(); - return mockWSSink.close(); - }); - - streamController.sink.add('{}'); - - return connect; - }); - - test('should run correctly reconnection check', () async { - final handleFunc = MockFunctions().handleFunc; - - final ConnectWebSocket connectFunc = MockFunctions().connectFunc; - - Logger.root.level = Level.ALL; - final ws = WebSocket( - baseUrl: 'baseurl', - user: User(id: 'testid'), - logger: Logger('ws'), - connectParams: {'test': 'true'}, - connectPayload: {'payload': 'test'}, - handler: handleFunc, - connectFunc: connectFunc, - reconnectionMonitorTimeout: 1, - reconnectionMonitorInterval: 1, - ); - - final mockWSChannel = MockWSChannel(); - final mockWSSink = MockWSSink(); - - StreamController streamController = - StreamController.broadcast(); - - final computedUrl = - 'wss://baseurl/connect?test=true&json=%7B%22payload%22%3A%22test%22%2C%22user_details%22%3A%7B%22id%22%3A%22testid%22%7D%7D'; - - when(connectFunc(computedUrl)).thenAnswer((_) => mockWSChannel); - when(mockWSChannel.stream).thenAnswer((_) { - return streamController.stream; - }); - when(mockWSChannel.sink).thenReturn(mockWSSink); - - final connect = ws.connect().then((_) { - streamController.sink.add('{}'); - streamController.close(); - streamController = StreamController.broadcast(); - streamController.sink.add('{}'); - return Future.delayed(Duration(milliseconds: 200)); - }).then((value) async { - verify(mockWSSink.add("{'type': 'health.check'}")).called(greaterThan(0)); - - verify(connectFunc(computedUrl)).called(2); - - await streamController.close(); - return mockWSSink.close(); - }); - - streamController.sink.add('{}'); - - return connect; - }); - - test('should close correctly the controller', () async { - final handleFunc = MockFunctions().handleFunc; - - final ConnectWebSocket connectFunc = MockFunctions().connectFunc; - - final ws = WebSocket( - baseUrl: 'baseurl', - user: User(id: 'testid'), - logger: Logger('ws'), - connectParams: {'test': 'true'}, - connectPayload: {'payload': 'test'}, - handler: handleFunc, - connectFunc: connectFunc, - ); - - final mockWSChannel = MockWSChannel(); - final mockWSSink = MockWSSink(); - - final StreamController streamController = - StreamController.broadcast(); - - final computedUrl = - 'wss://baseurl/connect?test=true&json=%7B%22payload%22%3A%22test%22%2C%22user_details%22%3A%7B%22id%22%3A%22testid%22%7D%7D'; - - when(connectFunc(computedUrl)).thenAnswer((_) => mockWSChannel); - when(mockWSChannel.stream).thenAnswer((_) { - return streamController.stream; - }); - when(mockWSChannel.sink).thenReturn(mockWSSink); - - final connect = ws.connect().then((_) { - streamController.sink.add('{}'); - return Future.delayed(Duration(milliseconds: 200)); - }).then((value) async { - await ws.disconnect(); - verify(mockWSSink.close()).called(greaterThan(0)); - - await streamController.close(); - await mockWSSink.close(); - }); - - streamController.sink.add('{}'); - - return connect; - }); - - test('should throw an error', () async { - final ConnectWebSocket connectFunc = MockFunctions().connectFunc; - - final ws = WebSocket( - baseUrl: 'baseurl', - user: User(id: 'testid'), - logger: Logger('ws'), - connectParams: {'test': 'true'}, - connectPayload: {'payload': 'test'}, - handler: (e) { - print(e); - }, - connectFunc: connectFunc, - ); - - final mockWSChannel = MockWSChannel(); - - final streamController = StreamController.broadcast(); - - final computedUrl = - 'wss://baseurl/connect?test=true&json=%7B%22payload%22%3A%22test%22%2C%22user_details%22%3A%7B%22id%22%3A%22testid%22%7D%7D'; - - when(connectFunc(computedUrl)).thenAnswer((_) => mockWSChannel); - when(mockWSChannel.sink).thenAnswer((_) => MockWSSink()); - when(mockWSChannel.stream).thenAnswer((_) { - return streamController.stream; - }); - - Future.delayed( - Duration(milliseconds: 1000), - () => streamController.sink.addError('test error'), - ); - - try { - expect(await ws.connect(), throwsA(isA())); - } catch (e) { - verify(connectFunc(computedUrl)).called(greaterThanOrEqualTo(1)); - } - }); -} diff --git a/packages/stream_chat/test/src/client_test.dart b/packages/stream_chat/test/src/client_test.dart deleted file mode 100644 index 21b9d09b..00000000 --- a/packages/stream_chat/test/src/client_test.dart +++ /dev/null @@ -1,1032 +0,0 @@ -import 'dart:async'; -import 'dart:convert'; - -import 'package:dio/dio.dart'; -import 'package:dio/native_imp.dart'; -import 'package:logging/logging.dart'; -import 'package:mockito/mockito.dart'; -import 'package:stream_chat/src/api/requests.dart'; -import 'package:stream_chat/src/client.dart'; -import 'package:stream_chat/src/exceptions.dart'; -import 'package:stream_chat/src/models/message.dart'; -import 'package:stream_chat/src/models/user.dart'; -import 'package:stream_chat/src/models/channel_model.dart'; -import 'package:test/test.dart'; - -class MockDio extends Mock implements DioForNative {} - -class MockHttpClientAdapter extends Mock implements HttpClientAdapter {} - -class Functions { - Future tokenProvider(String userId) => null; -} - -class MockFunctions extends Mock implements Functions {} - -void main() { - group('src/client', () { - group('constructor', () { - final List log = []; - - overridePrint(testFn()) => () { - log.clear(); - final spec = ZoneSpecification(print: (_, __, ___, String msg) { - // Add to log instead of printing to stdout - log.add(msg); - }); - return Zone.current.fork(specification: spec).run(testFn); - }; - - tearDown(() { - log.clear(); - }); - - test('should create the object correctly', () { - final client = StreamChatClient('api-key'); - - expect(client.baseURL, 'chat-us-east-1.stream-io-api.com'); - expect(client.apiKey, 'api-key'); - expect(client.logLevel, Level.WARNING); - expect(client.httpClient.options.connectTimeout, 6000); - expect(client.httpClient.options.receiveTimeout, 6000); - }); - - test('should create the object correctly', overridePrint(() { - final LogHandlerFunction logHandler = (LogRecord record) { - print(record.message); - }; - - final client = StreamChatClient( - 'api-key', - connectTimeout: Duration(seconds: 10), - receiveTimeout: Duration(seconds: 12), - logLevel: Level.INFO, - baseURL: 'test.com', - logHandlerFunction: logHandler, - ); - - expect(client.baseURL, 'test.com'); - expect(client.apiKey, 'api-key'); - expect(Logger.root.level, Level.INFO); - expect(client.httpClient.options.connectTimeout, 10000); - expect(client.httpClient.options.receiveTimeout, 12000); - - client.logger.warning('test'); - client.logger.config('test config'); - - expect([log[log.length - 2], log[log.length - 1]], - ['instantiating new client', 'test']); - })); - - test('Channel', () { - final client = StreamChatClient('test'); - final Map data = {'test': 1}; - final channelClient = client.channel('type', id: 'id', extraData: data); - expect(channelClient.type, 'type'); - expect(channelClient.id, 'id'); - }); - }); - - group('queryChannelsOnline', () { - test('should pass right default parameters', () async { - final mockDio = MockDio(); - - when(mockDio.options).thenReturn(BaseOptions()); - when(mockDio.interceptors).thenReturn(Interceptors()); - - final client = StreamChatClient( - 'api-key', - httpClient: mockDio, - ); - - final queryParams = { - 'payload': json.encode({ - "filter_conditions": null, - "sort": null, - "state": true, - "watch": true, - "presence": false, - "limit": 10, - "offset": 0, - }), - }; - - when(mockDio.get('/channels', queryParameters: queryParams)) - .thenAnswer((_) async => Response(data: '{}', statusCode: 200)); - - await client.queryChannelsOnline(filter: null, waitForConnect: false); - - verify(mockDio.get('/channels', queryParameters: queryParams)) - .called(1); - }); - - test('should pass right parameters', () async { - final mockDio = MockDio(); - - when(mockDio.options).thenReturn(BaseOptions()); - when(mockDio.interceptors).thenReturn(Interceptors()); - - final client = StreamChatClient( - 'api-key', - httpClient: mockDio, - ); - - final queryFilter = { - "id": { - "\$in": ["test"], - }, - }; - final sortOptions = >[]; - final options = {"state": false, "watch": false, "presence": true}; - final paginationParams = PaginationParams( - limit: 10, - offset: 2, - ); - - final queryParams = { - 'payload': json.encode({ - "filter_conditions": queryFilter, - "sort": sortOptions, - } - ..addAll(options) - ..addAll(paginationParams.toJson())), - }; - - when(mockDio.get('/channels', queryParameters: queryParams)) - .thenAnswer((_) async { - return Response(data: '{"channels":[]}', statusCode: 200); - }); - - await client.queryChannelsOnline( - filter: queryFilter, - sort: sortOptions, - options: options, - paginationParams: paginationParams, - waitForConnect: false, - ); - - verify(mockDio.get('/channels', queryParameters: queryParams)) - .called(1); - }); - }); - - group('search', () { - test('should pass right default parameters', () async { - final mockDio = MockDio(); - - when(mockDio.options).thenReturn(BaseOptions()); - when(mockDio.interceptors).thenReturn(Interceptors()); - - final client = StreamChatClient( - 'api-key', - httpClient: mockDio, - ); - - final filter = { - 'cid': { - r'$in': ['messaging:testId'] - } - }; - - const query = 'hello'; - - final queryParams = { - 'payload': json.encode({ - 'filter_conditions': filter, - 'query': query, - }), - }; - - when(mockDio.get('/search', queryParameters: queryParams)) - .thenAnswer((_) async => Response(data: '{}', statusCode: 200)); - - await client.search(filter, query: query); - - verify(mockDio.get('/search', queryParameters: queryParams)) - .called(1); - }); - - test('should pass right parameters', () async { - final mockDio = MockDio(); - - when(mockDio.options).thenReturn(BaseOptions()); - when(mockDio.interceptors).thenReturn(Interceptors()); - - final client = StreamChatClient( - 'api-key', - httpClient: mockDio, - ); - - final filters = { - "id": { - "\$in": ["test"], - }, - }; - final sortOptions = [SortOption('name')]; - final query = 'query'; - - final queryParams = { - 'payload': json.encode({ - 'filter_conditions': filters, - 'query': query, - 'sort': sortOptions, - 'limit': 10, - 'offset': 0, - }), - }; - - when(mockDio.get('/search', queryParameters: queryParams)) - .thenAnswer((_) async => Response(data: '{}', statusCode: 200)); - - await client.search( - filters, - sort: sortOptions, - query: query, - paginationParams: PaginationParams(), - ); - - verify(mockDio.get('/search', queryParameters: queryParams)) - .called(1); - }); - }); - - group('devices', () { - test('addDevice', () async { - final mockDio = MockDio(); - - when(mockDio.options).thenReturn(BaseOptions()); - when(mockDio.interceptors).thenReturn(Interceptors()); - - final client = StreamChatClient( - 'api-key', - httpClient: mockDio, - ); - - when(mockDio.post('/devices', data: { - 'id': 'test-id', - 'push_provider': 'firebase', - })).thenAnswer((_) async => Response(data: '{}', statusCode: 200)); - - await client.addDevice('test-id', PushProvider.firebase); - - verify( - mockDio.post( - '/devices', - data: {'id': 'test-id', 'push_provider': 'firebase'}, - ), - ).called(1); - }); - - test('getDevices', () async { - final mockDio = MockDio(); - - when(mockDio.options).thenReturn(BaseOptions()); - when(mockDio.interceptors).thenReturn(Interceptors()); - - final client = StreamChatClient( - 'api-key', - httpClient: mockDio, - ); - - when(mockDio.get('/devices')) - .thenAnswer((_) async => Response(data: '{}', statusCode: 200)); - - await client.getDevices(); - - verify(mockDio.get('/devices')).called(1); - }); - - test('removeDevice', () async { - final mockDio = MockDio(); - - when(mockDio.options).thenReturn(BaseOptions()); - when(mockDio.interceptors).thenReturn(Interceptors()); - - final client = StreamChatClient( - 'api-key', - httpClient: mockDio, - ); - - when(mockDio - .delete('/devices', queryParameters: {'id': 'test-id'})) - .thenAnswer((_) async => Response(data: '{}', statusCode: 200)); - - await client.removeDevice('test-id'); - - verify(mockDio.delete('/devices', - queryParameters: {'id': 'test-id'})).called(1); - }); - }); - - test('devToken', () { - final client = StreamChatClient('api-key'); - final token = client.devToken('test'); - - expect( - token, - 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoidGVzdCJ9.devtoken', - ); - }); - - group('queryUsers', () { - test('should pass right default parameters', () async { - final mockDio = MockDio(); - - when(mockDio.options).thenReturn(BaseOptions()); - when(mockDio.interceptors).thenReturn(Interceptors()); - - final client = StreamChatClient( - 'api-key', - httpClient: mockDio, - ); - - final queryParams = { - 'payload': json.encode({ - "filter_conditions": {}, - "sort": null, - "presence": false, - }), - }; - - when(mockDio.get('/users', queryParameters: queryParams)) - .thenAnswer( - (_) async => Response(data: '{"users":[]}', statusCode: 200)); - - await client.queryUsers(); - - verify(mockDio.get('/users', queryParameters: queryParams)) - .called(1); - }); - - test('should pass right parameters', () async { - final mockDio = MockDio(); - - when(mockDio.options).thenReturn(BaseOptions()); - when(mockDio.interceptors).thenReturn(Interceptors()); - - final client = StreamChatClient( - 'api-key', - httpClient: mockDio, - ); - - final Map queryFilter = { - "id": { - "\$in": ["test"], - }, - }; - final List sortOptions = []; - final options = {"presence": true}; - - final Map queryParams = { - 'payload': json.encode({ - "filter_conditions": queryFilter, - "sort": sortOptions, - }..addAll(options)), - }; - - when(mockDio.get('/users', queryParameters: queryParams)) - .thenAnswer((_) async { - return Response(data: '{"users":[]}', statusCode: 200); - }); - - await client.queryUsers( - filter: queryFilter, - sort: sortOptions, - options: options, - ); - - verify(mockDio.get('/users', queryParameters: queryParams)) - .called(1); - }); - }); - - group('user', () { - test('connectUser should throw exception', () async { - final mockDio = MockDio(); - - when(mockDio.options).thenReturn(BaseOptions()); - when(mockDio.interceptors).thenReturn(Interceptors()); - - final client = StreamChatClient( - 'api-key', - httpClient: mockDio, - ); - - when(mockDio.post('/moderation/flag', - data: {'target_user_id': 'test-id'})) - .thenAnswer((_) async => Response(data: '{}', statusCode: 200)); - - await client.flagUser('test-id'); - - verify(mockDio.post('/moderation/flag', - data: {'target_user_id': 'test-id'})).called(1); - }); - - test('flagUser', () async { - final mockDio = MockDio(); - - when(mockDio.options).thenReturn(BaseOptions()); - when(mockDio.interceptors).thenReturn(Interceptors()); - - final client = StreamChatClient( - 'api-key', - httpClient: mockDio, - ); - - expect(() => client.connectUserWithProvider(User(id: 'test-id')), - throwsA(isA())); - }); - - test('unflagUser', () async { - final mockDio = MockDio(); - - when(mockDio.options).thenReturn(BaseOptions()); - when(mockDio.interceptors).thenReturn(Interceptors()); - - final client = StreamChatClient( - 'api-key', - httpClient: mockDio, - ); - - when(mockDio.post('/moderation/unflag', - data: {'target_user_id': 'test-id'})) - .thenAnswer((_) async => Response(data: '{}', statusCode: 200)); - - await client.unflagUser('test-id'); - - verify(mockDio.post('/moderation/unflag', - data: {'target_user_id': 'test-id'})).called(1); - }); - - test('updateUser', () async { - final mockDio = MockDio(); - - when(mockDio.options).thenReturn(BaseOptions()); - when(mockDio.interceptors).thenReturn(Interceptors()); - - final client = StreamChatClient( - 'api-key', - httpClient: mockDio, - ); - - final user = User(id: 'test-id'); - - final data = { - 'users': {user.id: user.toJson()}, - }; - - when(mockDio.post('/users', data: data)) - .thenAnswer((_) async => Response(data: '{}', statusCode: 200)); - - await client.updateUser(user); - - verify(mockDio.post('/users', data: data)).called(1); - }); - - test('updateUsers', () async { - final mockDio = MockDio(); - - when(mockDio.options).thenReturn(BaseOptions()); - when(mockDio.interceptors).thenReturn(Interceptors()); - - final client = StreamChatClient( - 'api-key', - httpClient: mockDio, - ); - - final user = User(id: 'test-id'); - final user2 = User(id: 'test-id2'); - - final data = { - 'users': { - user.id: user.toJson(), - user2.id: user2.toJson(), - }, - }; - - when(mockDio.post('/users', data: data)) - .thenAnswer((_) async => Response(data: '{}', statusCode: 200)); - - await client.updateUsers([user, user2]); - - verify(mockDio.post('/users', data: data)).called(1); - }); - - test('banUser', () async { - final mockDio = MockDio(); - - when(mockDio.options).thenReturn(BaseOptions()); - when(mockDio.interceptors).thenReturn(Interceptors()); - - final client = StreamChatClient( - 'api-key', - httpClient: mockDio, - ); - - when(mockDio.post('/moderation/ban', - data: {'test': true, 'target_user_id': 'test-id'})) - .thenAnswer((_) async => Response(data: '{}', statusCode: 200)); - - await client.banUser('test-id', {'test': true}); - - verify(mockDio.post('/moderation/ban', - data: {'test': true, 'target_user_id': 'test-id'})).called(1); - }); - - test('unbanUser', () async { - final mockDio = MockDio(); - - when(mockDio.options).thenReturn(BaseOptions()); - when(mockDio.interceptors).thenReturn(Interceptors()); - - final client = StreamChatClient( - 'api-key', - httpClient: mockDio, - ); - - when(mockDio.delete('/moderation/ban', - queryParameters: {'test': true, 'target_user_id': 'test-id'})) - .thenAnswer((_) async => Response(data: '{}', statusCode: 200)); - - await client.unbanUser('test-id', {'test': true}); - - verify(mockDio.delete('/moderation/ban', - queryParameters: {'test': true, 'target_user_id': 'test-id'})) - .called(1); - }); - - test('muteUser', () async { - final mockDio = MockDio(); - - when(mockDio.options).thenReturn(BaseOptions()); - when(mockDio.interceptors).thenReturn(Interceptors()); - - final client = StreamChatClient( - 'api-key', - httpClient: mockDio, - ); - - when(mockDio.post('/moderation/mute', - data: {'target_id': 'test-id'})) - .thenAnswer((_) async => Response(data: '{}', statusCode: 200)); - - await client.muteUser('test-id'); - - verify(mockDio.post('/moderation/mute', - data: {'target_id': 'test-id'})).called(1); - }); - - test('unmuteUser', () async { - final mockDio = MockDio(); - - when(mockDio.options).thenReturn(BaseOptions()); - when(mockDio.interceptors).thenReturn(Interceptors()); - - final client = StreamChatClient( - 'api-key', - httpClient: mockDio, - ); - - when(mockDio.post('/moderation/unmute', - data: {'target_id': 'test-id'})) - .thenAnswer((_) async => Response(data: '{}', statusCode: 200)); - - await client.unmuteUser('test-id'); - - verify(mockDio.post('/moderation/unmute', - data: {'target_id': 'test-id'})).called(1); - }); - }); - - group('message', () { - test('flagMessage', () async { - final mockDio = MockDio(); - - when(mockDio.options).thenReturn(BaseOptions()); - when(mockDio.interceptors).thenReturn(Interceptors()); - - final client = StreamChatClient( - 'api-key', - httpClient: mockDio, - ); - - when(mockDio.post('/moderation/flag', - data: {'target_message_id': 'test-id'})) - .thenAnswer((_) async => Response(data: '{}', statusCode: 200)); - - await client.flagMessage('test-id'); - - verify(mockDio.post('/moderation/flag', - data: {'target_message_id': 'test-id'})).called(1); - }); - - test('unflagMessage', () async { - final mockDio = MockDio(); - - when(mockDio.options).thenReturn(BaseOptions()); - when(mockDio.interceptors).thenReturn(Interceptors()); - - final client = StreamChatClient( - 'api-key', - httpClient: mockDio, - ); - - when(mockDio.post('/moderation/unflag', - data: {'target_message_id': 'test-id'})) - .thenAnswer((_) async => Response(data: '{}', statusCode: 200)); - - await client.unflagMessage('test-id'); - - verify(mockDio.post('/moderation/unflag', - data: {'target_message_id': 'test-id'})).called(1); - }); - - test('updateMessage', () async { - final mockDio = MockDio(); - - when(mockDio.options).thenReturn(BaseOptions()); - when(mockDio.interceptors).thenReturn(Interceptors()); - - final client = StreamChatClient( - 'api-key', - httpClient: mockDio, - ); - - final message = Message( - id: 'test', - updatedAt: DateTime.now(), - ); - - when(mockDio.post( - '/messages/${message.id}', - data: {'message': anything}, - )).thenAnswer((_) async => Response(data: '{}', statusCode: 200)); - - await client.updateMessage(message); - - verify(mockDio.post('/messages/${message.id}', - data: {'message': anything})).called(1); - }); - - test('deleteMessage', () async { - final mockDio = MockDio(); - - when(mockDio.options).thenReturn(BaseOptions()); - when(mockDio.interceptors).thenReturn(Interceptors()); - - final client = StreamChatClient( - 'api-key', - httpClient: mockDio, - ); - - final messageId = 'test'; - - when(mockDio.delete('/messages/$messageId')) - .thenAnswer((_) async => Response(data: '{}', statusCode: 200)); - - await client.deleteMessage(Message(id: messageId)); - - verify(mockDio.delete('/messages/$messageId')).called(1); - }); - - test('getMessage', () async { - final mockDio = MockDio(); - - when(mockDio.options).thenReturn(BaseOptions()); - when(mockDio.interceptors).thenReturn(Interceptors()); - - final client = StreamChatClient( - 'api-key', - httpClient: mockDio, - ); - - final messageId = 'test'; - - when(mockDio.get('/messages/$messageId')) - .thenAnswer((_) async => Response(data: '{}', statusCode: 200)); - - await client.getMessage(messageId); - - verify(mockDio.get('/messages/$messageId')).called(1); - }); - - test('markAllRead', () async { - final mockDio = MockDio(); - - when(mockDio.options).thenReturn(BaseOptions()); - when(mockDio.interceptors).thenReturn(Interceptors()); - - final client = StreamChatClient( - 'api-key', - httpClient: mockDio, - ); - - when(mockDio.post('/channels/read')) - .thenAnswer((_) async => Response(data: '{}', statusCode: 200)); - - await client.markAllRead(); - - verify(mockDio.post('/channels/read')).called(1); - }); - }); - - group('api methods', () { - group('get', () { - test('should put the correct parameters', () async { - final mockDio = MockDio(); - - when(mockDio.options).thenReturn(BaseOptions()); - when(mockDio.interceptors).thenReturn(Interceptors()); - - final client = StreamChatClient( - 'api-key', - httpClient: mockDio, - ); - - final Map queryParams = { - 'test': 1, - }; - - when(mockDio.get('/test', queryParameters: queryParams)) - .thenAnswer((_) async { - return Response(data: '{}', statusCode: 200); - }); - - await client.get('/test', queryParameters: queryParams); - - verify(mockDio.get('/test', queryParameters: queryParams)) - .called(1); - }); - - test('should catch the error', () async { - final dioHttp = Dio(); - final mockHttpClientAdapter = MockHttpClientAdapter(); - dioHttp.httpClientAdapter = mockHttpClientAdapter; - - final client = StreamChatClient( - 'api-key', - httpClient: dioHttp, - ); - - when(mockHttpClientAdapter.fetch(any, any, any)).thenAnswer( - (_) async => ResponseBody.fromString('test error', 400)); - - expect(client.get('/test'), throwsA(ApiError('test error', 400))); - }); - }); - - group('post', () { - test('should put the correct parameters', () async { - final mockDio = MockDio(); - - when(mockDio.options).thenReturn(BaseOptions()); - when(mockDio.interceptors).thenReturn(Interceptors()); - - final client = StreamChatClient( - 'api-key', - httpClient: mockDio, - ); - - final Map data = { - 'test': 1, - }; - - when(mockDio.post('/test', data: data)).thenAnswer((_) async { - return Response(data: '{}', statusCode: 200); - }); - - await client.post('/test', data: data); - - verify(mockDio.post('/test', data: data)).called(1); - }); - - test('should catch the error', () async { - final dioHttp = Dio(); - final mockHttpClientAdapter = MockHttpClientAdapter(); - dioHttp.httpClientAdapter = mockHttpClientAdapter; - - final client = StreamChatClient( - 'api-key', - httpClient: dioHttp, - ); - - when(mockHttpClientAdapter.fetch(any, any, any)).thenAnswer( - (_) async => ResponseBody.fromString('test error', 400)); - - expect(client.post('/test'), throwsA(ApiError('test error', 400))); - }); - }); - - group('put', () { - test('should put the correct parameters', () async { - final mockDio = MockDio(); - - when(mockDio.options).thenReturn(BaseOptions()); - when(mockDio.interceptors).thenReturn(Interceptors()); - - final client = StreamChatClient( - 'api-key', - httpClient: mockDio, - ); - - final Map data = { - 'test': 1, - }; - - when(mockDio.put('/test', data: data)).thenAnswer((_) async { - return Response(data: '{}', statusCode: 200); - }); - - await client.put('/test', data: data); - - verify(mockDio.put('/test', data: data)).called(1); - }); - - test('should catch the error', () async { - final dioHttp = Dio(); - final mockHttpClientAdapter = MockHttpClientAdapter(); - dioHttp.httpClientAdapter = mockHttpClientAdapter; - - final client = StreamChatClient( - 'api-key', - httpClient: dioHttp, - ); - - when(mockHttpClientAdapter.fetch(any, any, any)).thenAnswer( - (_) async => ResponseBody.fromString('test error', 400)); - - expect(client.put('/test'), throwsA(ApiError('test error', 400))); - }); - }); - - group('patch', () { - test('should put the correct parameters', () async { - final mockDio = MockDio(); - - when(mockDio.options).thenReturn(BaseOptions()); - when(mockDio.interceptors).thenReturn(Interceptors()); - - final client = StreamChatClient( - 'api-key', - httpClient: mockDio, - ); - - final Map data = { - 'test': 1, - }; - - when(mockDio.patch('/test', data: data)) - .thenAnswer((_) async { - return Response(data: '{}', statusCode: 200); - }); - - await client.patch('/test', data: data); - - verify(mockDio.patch('/test', data: data)).called(1); - }); - - test('should catch the error', () async { - final dioHttp = Dio(); - final mockHttpClientAdapter = MockHttpClientAdapter(); - dioHttp.httpClientAdapter = mockHttpClientAdapter; - - final client = StreamChatClient( - 'api-key', - httpClient: dioHttp, - ); - - when(mockHttpClientAdapter.fetch(any, any, any)).thenAnswer( - (_) async => ResponseBody.fromString('test error', 400)); - - expect(client.patch('/test'), throwsA(ApiError('test error', 400))); - }); - }); - - group('delete', () { - test('should put the correct parameters', () async { - final mockDio = MockDio(); - - when(mockDio.options).thenReturn(BaseOptions()); - when(mockDio.interceptors).thenReturn(Interceptors()); - - final client = StreamChatClient( - 'api-key', - httpClient: mockDio, - ); - - final Map queryParams = { - 'test': 1, - }; - - when(mockDio.delete('/test', queryParameters: queryParams)) - .thenAnswer((_) async { - return Response(data: '{}', statusCode: 200); - }); - - await client.delete('/test', queryParameters: queryParams); - - verify(mockDio.delete('/test', queryParameters: queryParams)) - .called(1); - }); - - test('should catch the error', () async { - final dioHttp = Dio(); - final mockHttpClientAdapter = MockHttpClientAdapter(); - dioHttp.httpClientAdapter = mockHttpClientAdapter; - - final client = StreamChatClient( - 'api-key', - httpClient: dioHttp, - ); - - when(mockHttpClientAdapter.fetch(any, any, any)).thenAnswer( - (_) async => ResponseBody.fromString('test error', 400)); - - expect(client.delete('/test'), throwsA(ApiError('test error', 400))); - }); - }); - - group('pin message', () { - final mockDio = MockDio(); - - when(mockDio.options).thenReturn(BaseOptions()); - when(mockDio.interceptors).thenReturn(Interceptors()); - - final client = StreamChatClient( - 'api-key', - httpClient: mockDio, - ); - - test('should throw argument error', () { - final message = Message(text: 'Hello'); - expect( - () => client.pinMessage(message, 'InvalidType'), - throwsArgumentError, - ); - }); - - test('should complete successfully', () async { - final timeout = 30; - final message = Message(text: 'Hello'); - - when(mockDio.post( - '/messages/${message.id}', - data: anything, - )).thenAnswer((_) async => Response(data: '{}', statusCode: 200)); - - await client.pinMessage(message, timeout); - - verify(mockDio.post('/messages/${message.id}', - data: {'message': anything})).called(1); - }); - - test('should unpin message successfully', () async { - final message = Message(text: 'Hello'); - - when(mockDio.post( - '/messages/${message.id}', - data: anything, - )).thenAnswer((_) async => Response(data: '{}', statusCode: 200)); - - await client.unpinMessage(message); - - verify(mockDio.post('/messages/${message.id}', - data: anything)) - .called(1); - }); - }); - }); - - group('channel', () { - test('should update channel', () async { - final mockDio = MockDio(); - - when(mockDio.options).thenReturn(BaseOptions()); - when(mockDio.interceptors).thenReturn(Interceptors()); - - final client = StreamChatClient( - 'api-key', - httpClient: mockDio, - ); - - final channelClient = - client.channel('type', id: 'id', extraData: {'name': 'init'}); - - var update = { - 'set': {'name': 'demo'} - }; - - when(mockDio.patch( - '/channels/${channelClient.type}/${channelClient.id}', - data: update, - )).thenAnswer((_) async => Response(data: '{}', statusCode: 200)); - - await channelClient.updatePartial(update); - verify(mockDio.patch( - '/channels/${channelClient.type}/${channelClient.id}', - data: update)) - .called(1); - }); - }); - }); -} diff --git a/packages/stream_chat/test/src/core/api/attachment_file_uploader_test.dart b/packages/stream_chat/test/src/core/api/attachment_file_uploader_test.dart new file mode 100644 index 00000000..27f4ce6a --- /dev/null +++ b/packages/stream_chat/test/src/core/api/attachment_file_uploader_test.dart @@ -0,0 +1,136 @@ +import 'package:dio/dio.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:stream_chat/src/core/api/attachment_file_uploader.dart'; +import 'package:stream_chat/src/core/models/attachment_file.dart'; +import 'package:test/test.dart'; + +import '../../fakes.dart'; +import '../../matchers.dart'; +import '../../mocks.dart'; +import '../../utils.dart'; + +void main() { + late final client = MockHttpClient(); + late StreamAttachmentFileUploader fileUploader; + + setUp(() { + fileUploader = StreamAttachmentFileUploader(client); + registerFallbackValue(FakeMultiPartFile()); + }); + + Response successResponse(String path, {Object? data}) => Response( + data: data, + requestOptions: RequestOptions(path: path), + statusCode: 200, + ); + + test('sendImage', () async { + const channelId = 'test-channel-id'; + const channelType = 'test-channel-type'; + + const path = '/channels/$channelType/$channelId/image'; + final file = assetFile('test_image.jpeg'); + final attachmentFile = AttachmentFile( + size: 333, + path: file.path, + bytes: file.readAsBytesSync(), + ); + final multipartFile = await attachmentFile.toMultipartFile(); + + when(() => client.postFile( + path, + any(that: isSameMultipartFileAs(multipartFile)), + )).thenAnswer((_) async => successResponse(path, data: { + 'file': 'test-file-url', + })); + + final res = await fileUploader.sendImage( + attachmentFile, + channelId, + channelType, + ); + + expect(res, isNotNull); + expect(res.file, isNotNull); + expect(res.file, isNotEmpty); + + verify(() => client.postFile( + path, + any(that: isSameMultipartFileAs(multipartFile)), + )).called(1); + verifyNoMoreInteractions(client); + }); + + test('sendFile', () async { + const channelId = 'test-channel-id'; + const channelType = 'test-channel-type'; + + const path = '/channels/$channelType/$channelId/file'; + final file = assetFile('example.pdf'); + final attachmentFile = AttachmentFile( + size: 333, + path: file.path, + bytes: file.readAsBytesSync(), + ); + final multipartFile = await attachmentFile.toMultipartFile(); + + when(() => client.postFile( + path, + any(that: isSameMultipartFileAs(multipartFile)), + )).thenAnswer((_) async => successResponse(path, data: { + 'file': 'test-file-url', + })); + + final res = await fileUploader.sendFile( + attachmentFile, + channelId, + channelType, + ); + + expect(res, isNotNull); + expect(res.file, isNotNull); + expect(res.file, isNotEmpty); + + verify(() => client.postFile( + path, + any(that: isSameMultipartFileAs(multipartFile)), + )).called(1); + verifyNoMoreInteractions(client); + }); + + test('deleteImage', () async { + const channelId = 'test-channel-id'; + const channelType = 'test-channel-type'; + const path = '/channels/$channelType/$channelId/image'; + + const url = 'test-image-url'; + + when(() => client.delete(path, queryParameters: {'url': url})).thenAnswer( + (_) async => successResponse(path, data: {})); + + final res = await fileUploader.deleteImage(url, channelId, channelType); + + expect(res, isNotNull); + + verify(() => client.delete(path, queryParameters: {'url': url})).called(1); + verifyNoMoreInteractions(client); + }); + + test('deleteFile', () async { + const channelId = 'test-channel-id'; + const channelType = 'test-channel-type'; + const path = '/channels/$channelType/$channelId/file'; + + const url = 'test-file-url'; + + when(() => client.delete(path, queryParameters: {'url': url})).thenAnswer( + (_) async => successResponse(path, data: {})); + + final res = await fileUploader.deleteFile(url, channelId, channelType); + + expect(res, isNotNull); + + verify(() => client.delete(path, queryParameters: {'url': url})).called(1); + verifyNoMoreInteractions(client); + }); +} diff --git a/packages/stream_chat/test/src/core/api/channel_api_test.dart b/packages/stream_chat/test/src/core/api/channel_api_test.dart new file mode 100644 index 00000000..ab530ff1 --- /dev/null +++ b/packages/stream_chat/test/src/core/api/channel_api_test.dart @@ -0,0 +1,608 @@ +import 'dart:convert'; + +import 'package:dio/dio.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:stream_chat/src/core/api/channel_api.dart'; +import 'package:stream_chat/src/core/models/channel_model.dart'; +import 'package:stream_chat/src/core/models/channel_state.dart'; +import 'package:stream_chat/stream_chat.dart'; +import 'package:test/test.dart'; + +import '../../mocks.dart'; + +void main() { + String _getChannelUrl(String channelId, String channelType) => + '/channels/$channelType/$channelId'; + + ChannelState _generateChannelState( + String channelId, + String channelType, + ) { + final channel = ChannelModel(id: channelId, type: channelType); + final messages = List.generate( + 3, + (index) => Message( + id: 'test-message-id-$index', + text: 'test-message-text-$index', + ), + ); + final members = List.generate( + 3, + (index) => Member(userId: 'test-user-id-$index'), + ); + final reads = List.generate( + 3, + (index) => Read( + lastRead: DateTime.now(), + user: User(id: 'test-user-id-$index'), + ), + ); + final watchers = List.generate( + 3, + (index) => User(id: 'test-user-id-$index'), + ); + final state = ChannelState( + channel: channel, + messages: messages, + pinnedMessages: messages, + members: members, + read: reads, + watchers: watchers, + watcherCount: watchers.length, + ); + return state; + } + + Response successResponse(String path, {Object? data}) => Response( + data: data, + requestOptions: RequestOptions(path: path), + statusCode: 200, + ); + + late final client = MockHttpClient(); + late ChannelApi channelApi; + + setUp(() { + channelApi = ChannelApi(client); + }); + + test('queryChannel', () async { + const channelId = 'test-channel-id'; + const channelType = 'test-channel-type'; + const channelData = {'name': 'test-channel'}; + const messagePagination = PaginationParams(); + const membersPagination = PaginationParams(); + const watchersPagination = PaginationParams(); + + const channelPath = '/channels/$channelType/$channelId'; + const path = '$channelPath/query'; + + final channelState = _generateChannelState(channelId, channelType); + + final data = { + 'state': true, + 'watch': false, + 'presence': false, + 'data': channelData, + 'messages': messagePagination, + 'members': membersPagination, + 'watchers': watchersPagination, + }; + + when(() => client.post( + path, + data: data, + )).thenAnswer((_) async => successResponse( + path, + data: channelState.toJson(), + )); + + final res = await channelApi.queryChannel( + channelType, + channelId: channelId, + channelData: channelData, + messagesPagination: messagePagination, + membersPagination: membersPagination, + watchersPagination: watchersPagination, + ); + + expect(res, isNotNull); + expect(res.messages.length, channelState.messages.length); + expect(res.pinnedMessages.length, channelState.pinnedMessages.length); + expect(res.members.length, channelState.members.length); + expect(res.read.length, channelState.read.length); + expect(res.watchers.length, channelState.watchers.length); + expect(res.watcherCount, channelState.watcherCount); + + verify(() => client.post(path, data: any(named: 'data'))).called(1); + verifyNoMoreInteractions(client); + }); + + test('queryChannels', () async { + const channelId = 'test-channel-id'; + const channelType = 'test-channel-type'; + + final filter = Filter.in_('cid', const ['test-cid']); + const sort = [SortOption('test-field')]; + const memberLimit = 33; + const messageLimit = 33; + + const path = '/channels'; + + final channelState = _generateChannelState(channelId, channelType); + + final payload = jsonEncode({ + // default options + 'state': true, + 'watch': true, + 'presence': false, + + // passed options + 'sort': sort, + 'filter_conditions': filter, + 'member_limit': memberLimit, + 'message_limit': messageLimit, + + // pagination + ...const PaginationParams().toJson() + }); + + when(() => client.get( + path, + queryParameters: { + 'payload': payload, + }, + )).thenAnswer((_) async => successResponse( + path, + data: { + 'channels': [channelState.toJson()] + }, + )); + + final res = await channelApi.queryChannels( + filter: filter, + sort: sort, + memberLimit: memberLimit, + messageLimit: messageLimit, + ); + + expect(res, isNotNull); + expect(res.channels, isNotEmpty); + + verify( + () => client.get(path, queryParameters: any(named: 'queryParameters')), + ).called(1); + verifyNoMoreInteractions(client); + }); + + test('markAllRead', () async { + const path = 'channels/read'; + when(() => client.post(path)).thenAnswer( + (_) async => successResponse(path, data: {})); + + final res = await channelApi.markAllRead(); + + expect(res, isNotNull); + + verify(() => client.post(path)).called(1); + verifyNoMoreInteractions(client); + }); + + test('updateChannel', () async { + const channelId = 'test-channel-id'; + const channelType = 'test-channel-type'; + const data = {'name': 'test-channel-name'}; + final message = Message(id: 'test-message-id', text: 'channel-updated'); + + final path = _getChannelUrl(channelId, channelType); + + final channelModel = ChannelModel( + id: channelId, + type: channelType, + extraData: data, + ); + + when(() => client.post( + path, + data: any( + named: 'data', + that: wrapMatcher((Map v) => + containsPair('data', data).matches(v, {}) && + contains('message').matches(v, {})), + ), + )).thenAnswer((_) async => successResponse(path, data: { + 'channel': channelModel.toJson(), + 'message': message.toJson(), + })); + + final res = await channelApi.updateChannel( + channelId, + channelType, + data, + message: message, + ); + + expect(res, isNotNull); + expect(res.channel.cid, channelModel.cid); + expect(res.message?.id, message.id); + + verify(() => client.post(path, data: any(named: 'data'))).called(1); + verifyNoMoreInteractions(client); + }); + + test('updateChannelPartial', () async { + const channelId = 'test-channel-id'; + const channelType = 'test-channel-type'; + const set = { + 'name': 'Stream Team', + 'profile_image': 'test-profile-image', + }; + + const unset = ['tag', 'last_name']; + + final path = _getChannelUrl(channelId, channelType); + + final channelModel = ChannelModel( + id: channelId, + type: channelType, + extraData: set, + ); + + when( + () => client.patch(path, data: {'set': set, 'unset': unset}), + ).thenAnswer((_) async => successResponse(path, data: { + 'channel': channelModel.toJson(), + })); + + final res = await channelApi.updateChannelPartial( + channelId, + channelType, + set: set, + unset: unset, + ); + + expect(res, isNotNull); + + verify( + () => client.patch(path, data: {'set': set, 'unset': unset}), + ).called(1); + verifyNoMoreInteractions(client); + }); + + test('acceptChannelInvite', () async { + const channelId = 'test-channel-id'; + const channelType = 'test-channel-type'; + final message = Message(id: 'test-message-id', text: 'channel-accepted'); + + final channelModel = ChannelModel(id: channelId, type: channelType); + + final path = _getChannelUrl(channelId, channelType); + + when(() => client.post( + path, + data: { + 'accept_invite': true, + 'message': message, + }, + )).thenAnswer((_) async => successResponse(path, data: { + 'channel': channelModel.toJson(), + 'message': message.toJson(), + })); + + final res = await channelApi.acceptChannelInvite( + channelId, + channelType, + message: message, + ); + + expect(res, isNotNull); + expect(res.channel.cid, channelModel.cid); + expect(res.message?.id, message.id); + + verify(() => client.post(path, data: any(named: 'data'))).called(1); + verifyNoMoreInteractions(client); + }); + + test('rejectChannelInvite', () async { + const channelId = 'test-channel-id'; + const channelType = 'test-channel-type'; + final message = Message(id: 'test-message-id', text: 'channel-rejected'); + + final channelModel = ChannelModel(id: channelId, type: channelType); + + final path = _getChannelUrl(channelId, channelType); + + when(() => client.post( + path, + data: { + 'reject_invite': true, + 'message': message, + }, + )).thenAnswer((_) async => successResponse(path, data: { + 'channel': channelModel.toJson(), + 'message': message.toJson(), + })); + + final res = await channelApi.rejectChannelInvite( + channelId, + channelType, + message: message, + ); + + expect(res, isNotNull); + expect(res.channel.cid, channelModel.cid); + expect(res.message?.id, message.id); + + verify(() => client.post(path, data: any(named: 'data'))).called(1); + verifyNoMoreInteractions(client); + }); + + test('inviteChannelMembers', () async { + const channelId = 'test-channel-id'; + const channelType = 'test-channel-type'; + const memberIds = ['test-member-id-1', 'test-member-id-2']; + final channelModel = ChannelModel(id: channelId, type: channelType); + final message = Message(id: 'test-message-id', text: 'members-invited'); + + final path = _getChannelUrl(channelId, channelType); + + when(() => client.post( + path, + data: { + 'invites': memberIds, + 'message': message, + }, + )).thenAnswer((_) async => successResponse(path, data: { + 'channel': channelModel.toJson(), + 'message': message.toJson(), + })); + + final res = await channelApi.inviteChannelMembers( + channelId, + channelType, + memberIds, + message: message, + ); + + expect(res, isNotNull); + expect(res.channel.cid, channelModel.cid); + expect(res.message?.id, message.id); + + verify(() => client.post(path, data: any(named: 'data'))).called(1); + verifyNoMoreInteractions(client); + }); + + test('addMembers', () async { + const channelId = 'test-channel-id'; + const channelType = 'test-channel-type'; + const memberIds = ['test-member-id-1', 'test-member-id-2']; + final channelModel = ChannelModel(id: channelId, type: channelType); + final message = Message(id: 'test-message-id', text: 'members-added'); + + final path = _getChannelUrl(channelId, channelType); + + when(() => client.post( + path, + data: { + 'add_members': memberIds, + 'message': message, + }, + )).thenAnswer((_) async => successResponse(path, data: { + 'channel': channelModel.toJson(), + 'message': message.toJson(), + })); + + final res = await channelApi.addMembers( + channelId, + channelType, + memberIds, + message: message, + ); + + expect(res, isNotNull); + expect(res.channel.cid, channelModel.cid); + expect(res.message?.id, message.id); + + verify(() => client.post(path, data: any(named: 'data'))).called(1); + verifyNoMoreInteractions(client); + }); + + test('removeMembers', () async { + const channelId = 'test-channel-id'; + const channelType = 'test-channel-type'; + const memberIds = ['test-member-id-1', 'test-member-id-2']; + final channelModel = ChannelModel(id: channelId, type: channelType); + final message = Message(id: 'test-message-id', text: 'members-removed'); + + final path = _getChannelUrl(channelId, channelType); + + when(() => client.post( + path, + data: { + 'remove_members': memberIds, + 'message': message, + }, + )).thenAnswer((_) async => successResponse(path, data: { + 'channel': channelModel.toJson(), + 'message': message.toJson(), + })); + + final res = await channelApi.removeMembers( + channelId, + channelType, + memberIds, + message: message, + ); + + expect(res, isNotNull); + expect(res.channel.cid, channelModel.cid); + expect(res.message?.id, message.id); + + verify(() => client.post(path, data: any(named: 'data'))).called(1); + verifyNoMoreInteractions(client); + }); + + test('sendEvent', () async { + const channelId = 'test-channel-id'; + const channelType = 'test-channel-type'; + final event = Event(type: 'event.test'); + + final path = '${_getChannelUrl(channelId, channelType)}/event'; + + when(() => client.post(path, data: {'event': event})).thenAnswer( + (_) async => successResponse(path, data: {})); + + final res = await channelApi.sendEvent(channelId, channelType, event); + + expect(res, isNotNull); + + verify(() => client.post(path, data: any(named: 'data'))).called(1); + verifyNoMoreInteractions(client); + }); + + test('deleteChannel', () async { + const channelId = 'test-channel-id'; + const channelType = 'test-channel-type'; + + final path = _getChannelUrl(channelId, channelType); + + when(() => client.delete(path)).thenAnswer( + (_) async => successResponse(path, data: {})); + + final res = await channelApi.deleteChannel(channelId, channelType); + + expect(res, isNotNull); + + verify(() => client.delete(path)).called(1); + verifyNoMoreInteractions(client); + }); + + test('truncateChannel', () async { + const channelId = 'test-channel-id'; + const channelType = 'test-channel-type'; + + final path = '${_getChannelUrl(channelId, channelType)}/truncate'; + + when(() => client.post(path)).thenAnswer( + (_) async => successResponse(path, data: {})); + + final res = await channelApi.truncateChannel(channelId, channelType); + + expect(res, isNotNull); + + verify(() => client.post(path)).called(1); + verifyNoMoreInteractions(client); + }); + + test('hideChannel', () async { + const channelId = 'test-channel-id'; + const channelType = 'test-channel-type'; + + final path = '${_getChannelUrl(channelId, channelType)}/hide'; + + when( + () => client.post( + path, + data: { + 'clear_history': false, + }, + ), + ).thenAnswer((_) async => successResponse(path, data: {})); + + final res = await channelApi.hideChannel(channelId, channelType); + + expect(res, isNotNull); + + verify(() => client.post(path, data: any(named: 'data'))).called(1); + verifyNoMoreInteractions(client); + }); + + test('hideChannel with clear_history: true', () async { + const channelId = 'test-channel-id'; + const channelType = 'test-channel-type'; + + final path = '${_getChannelUrl(channelId, channelType)}/hide'; + + when( + () => client.post( + path, + data: { + 'clear_history': true, + }, + ), + ).thenAnswer((_) async => successResponse(path, data: {})); + + final res = await channelApi.hideChannel( + channelId, + channelType, + clearHistory: true, + ); + + expect(res, isNotNull); + + verify(() => client.post(path, data: any(named: 'data'))).called(1); + verifyNoMoreInteractions(client); + }); + + test('showChannel', () async { + const channelId = 'test-channel-id'; + const channelType = 'test-channel-type'; + + final path = '${_getChannelUrl(channelId, channelType)}/show'; + + when(() => client.post(path)).thenAnswer( + (_) async => successResponse(path, data: {})); + + final res = await channelApi.showChannel(channelId, channelType); + + expect(res, isNotNull); + + verify(() => client.post(path)).called(1); + verifyNoMoreInteractions(client); + }); + + test('markRead', () async { + const channelId = 'test-channel-id'; + const channelType = 'test-channel-type'; + const messageId = 'test-message-id'; + + final path = '${_getChannelUrl(channelId, channelType)}/read'; + + when(() => client.post( + path, + data: { + 'message_id': messageId, + }, + )) + .thenAnswer( + (_) async => successResponse(path, data: {})); + + final res = await channelApi.markRead( + channelId, + channelType, + messageId: messageId, + ); + + expect(res, isNotNull); + + verify(() => client.post(path, data: any(named: 'data'))).called(1); + verifyNoMoreInteractions(client); + }); + + test('stopWatching', () async { + const channelId = 'test-channel-id'; + const channelType = 'test-channel-type'; + + final path = '${_getChannelUrl(channelId, channelType)}/stop-watching'; + + when(() => client.post(path)).thenAnswer( + (_) async => successResponse(path, data: {})); + + final res = await channelApi.stopWatching(channelId, channelType); + + expect(res, isNotNull); + + verify(() => client.post(path)).called(1); + verifyNoMoreInteractions(client); + }); +} diff --git a/packages/stream_chat/test/src/core/api/device_api_test.dart b/packages/stream_chat/test/src/core/api/device_api_test.dart new file mode 100644 index 00000000..7d4f59fd --- /dev/null +++ b/packages/stream_chat/test/src/core/api/device_api_test.dart @@ -0,0 +1,94 @@ +import 'package:dio/dio.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:stream_chat/src/core/api/device_api.dart'; +import 'package:stream_chat/stream_chat.dart'; +import 'package:test/test.dart'; + +import '../../mocks.dart'; + +void main() { + Response successResponse(String path, {Object? data}) => Response( + data: data, + requestOptions: RequestOptions(path: path), + statusCode: 200, + ); + + late final client = MockHttpClient(); + late DeviceApi deviceApi; + + setUp(() { + deviceApi = DeviceApi(client); + }); + + test('addDevice', () async { + const deviceId = 'test-device-id'; + const pushProvider = PushProvider.firebase; + + const path = '/devices'; + + when(() => client.post( + path, + data: { + 'id': deviceId, + 'push_provider': pushProvider.name, + }, + )) + .thenAnswer( + (_) async => successResponse(path, data: {})); + + final res = await deviceApi.addDevice(deviceId, pushProvider); + + expect(res, isNotNull); + + verify(() => client.post(path, data: any(named: 'data'))).called(1); + verifyNoMoreInteractions(client); + }); + + test('getDevices', () async { + const path = '/devices'; + + final devices = List.generate( + 3, + (index) => Device( + id: 'test-device-id-$index', + pushProvider: PushProvider.firebase.name, + ), + ); + + when(() => client.get(path)).thenAnswer( + (_) async => successResponse(path, data: { + 'devices': [...devices.map((it) => it.toJson())] + }), + ); + + final res = await deviceApi.getDevices(); + + expect(res, isNotNull); + expect(res.devices.length, devices.length); + + verify(() => client.get(path)).called(1); + verifyNoMoreInteractions(client); + }); + + test('removeDevice', () async { + const deviceId = 'test-device-id'; + + const path = '/devices'; + + when( + () => client.delete( + path, + queryParameters: {'id': deviceId}, + ), + ).thenAnswer((_) async => successResponse(path, data: {})); + + final res = await deviceApi.removeDevice(deviceId); + + expect(res, isNotNull); + + verify( + () => client.delete(path, queryParameters: any(named: 'queryParameters')), + ).called(1); + verifyNoMoreInteractions(client); + }); +} diff --git a/packages/stream_chat/test/src/core/api/general_api_test.dart b/packages/stream_chat/test/src/core/api/general_api_test.dart new file mode 100644 index 00000000..afb3f6aa --- /dev/null +++ b/packages/stream_chat/test/src/core/api/general_api_test.dart @@ -0,0 +1,266 @@ +import 'dart:convert'; + +import 'package:dio/dio.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:stream_chat/src/core/api/general_api.dart'; +import 'package:stream_chat/src/core/api/requests.dart'; +import 'package:stream_chat/src/core/models/channel_model.dart'; +import 'package:stream_chat/src/core/models/event.dart'; +import 'package:stream_chat/src/core/models/filter.dart'; +import 'package:stream_chat/stream_chat.dart'; +import 'package:test/test.dart'; + +import '../../mocks.dart'; + +void main() { + Response successResponse(String path, {Object? data}) => Response( + data: data, + requestOptions: RequestOptions(path: path), + statusCode: 200, + ); + + late final client = MockHttpClient(); + late GeneralApi generalApi; + + setUp(() { + generalApi = GeneralApi(client); + }); + + test('sync', () async { + const cids = ['test-cid-1', 'test-cid-2', 'test-cid-3']; + final lastSyncAt = DateTime.now(); + + const path = '/sync'; + + final events = + List.generate(3, (index) => Event(type: 'test-event-type-$index')); + + final data = { + 'channel_cids': cids, + 'last_sync_at': lastSyncAt.toUtc().toIso8601String(), + }; + + when(() => client.post( + path, + data: data, + )).thenAnswer((_) async => successResponse(path, data: { + 'events': [...events.map((it) => it.toJson())] + })); + + final res = await generalApi.sync(cids, lastSyncAt); + + expect(res, isNotNull); + + verify(() => client.post(path, data: any(named: 'data'))).called(1); + verifyNoMoreInteractions(client); + }); + + group('searchMessages', () { + test( + 'should throw if `query` and `messageFilters` is not provided', + () async { + final filter = Filter.in_('cid', const ['test-cid-1', 'test-cid-2']); + try { + await generalApi.searchMessages(filter); + } catch (e) { + expect(e, isA()); + } + }, + ); + + test( + 'should throw if `query` and `messageFilters` both are provided', + () async { + final filter = Filter.in_('cid', const ['test-cid-1', 'test-cid-2']); + const query = 'test-query'; + final messageFilter = Filter.query('key', 'text'); + try { + await generalApi.searchMessages( + filter, + query: query, + messageFilters: messageFilter, + ); + } catch (e) { + expect(e, isA()); + } + }, + ); + + test('should run successfully with `query`', () async { + final filter = Filter.in_('cid', const ['test-cid-1', 'test-cid-2']); + const query = 'test-query'; + const sort = [SortOption('test-field')]; + const pagination = PaginationParams(); + + const path = '/search'; + + final payload = jsonEncode({ + 'filter_conditions': filter, + 'sort': sort, + 'query': query, + ...pagination.toJson(), + }); + + when( + () => client.get( + path, + queryParameters: { + 'payload': payload, + }, + ), + ).thenAnswer((_) async => successResponse(path, data: {'results': []})); + + final res = await generalApi.searchMessages( + filter, + query: query, + sort: sort, + pagination: pagination, + ); + + expect(res, isNotNull); + expect(res.results, isEmpty); + + verify( + () => client.get(path, queryParameters: any(named: 'queryParameters')), + ).called(1); + verifyNoMoreInteractions(client); + }); + + test('should run successfully with `messageFilter`', () async { + final filter = Filter.in_('cid', const ['test-cid-1', 'test-cid-2']); + const sort = [SortOption('test-field')]; + final messageFilter = Filter.query('key', 'text'); + const pagination = PaginationParams(); + + const path = '/search'; + + final payload = jsonEncode({ + 'filter_conditions': filter, + 'sort': sort, + 'message_filter_conditions': messageFilter, + ...pagination.toJson(), + }); + + when( + () => client.get( + path, + queryParameters: { + 'payload': payload, + }, + ), + ).thenAnswer((_) async => successResponse(path, data: {'results': []})); + + final res = await generalApi.searchMessages( + filter, + messageFilters: messageFilter, + sort: sort, + pagination: pagination, + ); + + expect(res, isNotNull); + expect(res.results, isEmpty); + + verify( + () => client.get(path, queryParameters: any(named: 'queryParameters')), + ).called(1); + verifyNoMoreInteractions(client); + }); + }); + + group('queryMembers', () { + test('with `channelId`', () async { + const channelType = 'test-channel-type'; + const channelId = 'test-channel-id'; + final filter = Filter.in_('cid', const ['test-cid-1', 'test-cid-2']); + const pagination = PaginationParams(); + const sort = [SortOption('test-field')]; + + const path = '/members'; + + final members = List.generate( + 3, + (index) => Member(userId: 'test-user-id=$index'), + ); + + final payload = jsonEncode({ + 'type': channelType, + 'filter_conditions': filter, + 'id': channelId, + 'sort': sort, + ...pagination.toJson(), + }); + + when(() => client.get( + path, + queryParameters: { + 'payload': payload, + }, + )).thenAnswer((_) async => successResponse(path, data: { + 'members': [...members.map((it) => it.toJson())] + })); + + final res = await generalApi.queryMembers( + channelType, + channelId: channelId, + filter: filter, + pagination: pagination, + sort: sort, + ); + + expect(res, isNotNull); + expect(res.members.length, members.length); + + verify( + () => client.get(path, queryParameters: any(named: 'queryParameters')), + ).called(1); + verifyNoMoreInteractions(client); + }); + + test('with `members`', () async { + const channelType = 'test-channel-type'; + final filter = Filter.in_('cid', const ['test-cid-1', 'test-cid-2']); + const pagination = PaginationParams(); + const sort = [SortOption('test-field')]; + + const path = '/members'; + + final members = List.generate( + 3, + (index) => Member(userId: 'test-user-id=$index'), + ); + + final payload = jsonEncode({ + 'type': channelType, + 'filter_conditions': filter, + 'members': members, + 'sort': sort, + ...pagination.toJson(), + }); + + when(() => client.get( + path, + queryParameters: { + 'payload': payload, + }, + )).thenAnswer((_) async => successResponse(path, data: { + 'members': [...members.map((it) => it.toJson())] + })); + + final res = await generalApi.queryMembers( + channelType, + filter: filter, + pagination: pagination, + sort: sort, + members: members, + ); + + expect(res, isNotNull); + expect(res.members.length, members.length); + + verify( + () => client.get(path, queryParameters: any(named: 'queryParameters')), + ).called(1); + verifyNoMoreInteractions(client); + }); + }); +} diff --git a/packages/stream_chat/test/src/core/api/guest_api_test.dart b/packages/stream_chat/test/src/core/api/guest_api_test.dart new file mode 100644 index 00000000..7db74b4a --- /dev/null +++ b/packages/stream_chat/test/src/core/api/guest_api_test.dart @@ -0,0 +1,46 @@ +import 'package:dio/dio.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:stream_chat/src/core/api/guest_api.dart'; +import 'package:stream_chat/stream_chat.dart'; +import 'package:test/test.dart'; + +import '../../mocks.dart'; + +void main() { + Response successResponse(String path, {Object? data}) => Response( + data: data, + requestOptions: RequestOptions(path: path), + statusCode: 200, + ); + + late final client = MockHttpClient(); + late GuestApi guestApi; + + setUp(() { + guestApi = GuestApi(client); + }); + + test('getGuestUser', () async { + const accessToken = 'test-guest-token'; + final user = User(id: 'test-user-id'); + + const path = '/guest'; + + when(() => client.post( + path, + data: {'user': user}, + )).thenAnswer((_) async => successResponse(path, data: { + 'access_token': accessToken, + 'user': user.toJson(), + })); + + final res = await guestApi.getGuestUser(user); + + expect(res, isNotNull); + expect(res.accessToken, accessToken); + expect(res.user.id, user.id); + + verify(() => client.post(path, data: any(named: 'data'))).called(1); + verifyNoMoreInteractions(client); + }); +} diff --git a/packages/stream_chat/test/src/core/api/message_api_test.dart b/packages/stream_chat/test/src/core/api/message_api_test.dart new file mode 100644 index 00000000..1aacb6d2 --- /dev/null +++ b/packages/stream_chat/test/src/core/api/message_api_test.dart @@ -0,0 +1,429 @@ +import 'package:dio/dio.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:stream_chat/src/core/api/message_api.dart'; +import 'package:stream_chat/stream_chat.dart'; +import 'package:test/test.dart'; + +import '../../mocks.dart'; + +void main() { + Response successResponse(String path, {Object? data}) => Response( + data: data, + requestOptions: RequestOptions(path: path), + statusCode: 200, + ); + + late final client = MockHttpClient(); + late MessageApi messageApi; + + setUp(() { + messageApi = MessageApi(client); + }); + + test('sendMessage', () async { + const channelId = 'test-channel-id'; + const channelType = 'test-channel-type'; + final message = Message(id: 'test-message-id', text: 'test-message-text'); + + const path = '/channels/$channelType/$channelId/message'; + + when(() => client.post( + path, + data: { + 'message': message, + 'skip_push': false, + }, + )).thenAnswer((_) async => successResponse(path, data: { + 'message': message.toJson(), + })); + + final res = await messageApi.sendMessage(channelId, channelType, message); + + expect(res, isNotNull); + expect(res.message.id, message.id); + + verify(() => client.post(path, data: any(named: 'data'))).called(1); + verifyNoMoreInteractions(client); + }); + + test('sendMessage with skipPush: true', () async { + const channelId = 'test-channel-id'; + const channelType = 'test-channel-type'; + final message = Message(id: 'test-message-id', text: 'test-message-text'); + + const path = '/channels/$channelType/$channelId/message'; + + when(() => client.post( + path, + data: { + 'message': message, + 'skip_push': true, + }, + )).thenAnswer((_) async => successResponse(path, data: { + 'message': message.toJson(), + })); + + final res = await messageApi.sendMessage( + channelId, + channelType, + message, + skipPush: true, + ); + + expect(res, isNotNull); + expect(res.message.id, message.id); + + verify(() => client.post(path, data: any(named: 'data'))).called(1); + verifyNoMoreInteractions(client); + }); + + test('getMessagesById', () async { + const channelId = 'test-channel-id'; + const channelType = 'test-channel-type'; + const messageIds = ['test-message-id-1', 'test-message-id-2']; + + const path = '/channels/$channelType/$channelId/messages'; + + final messages = List.generate( + 3, + (index) => Message(id: 'test-message-id-$index'), + ); + + when(() => client.get( + path, + queryParameters: {'ids': messageIds.join(',')}, + )).thenAnswer((_) async => successResponse(path, data: { + 'messages': [...messages.map((it) => it.toJson())], + })); + + final res = await messageApi.getMessagesById( + channelId, + channelType, + messageIds, + ); + + expect(res, isNotNull); + expect(res.messages.length, messages.length); + + verify( + () => client.get(path, queryParameters: any(named: 'queryParameters')), + ).called(1); + verifyNoMoreInteractions(client); + }); + + test('getMessage', () async { + const messageId = 'test-message-id'; + + const path = '/messages/$messageId'; + + final message = Message(id: messageId); + + when(() => client.get(path)).thenAnswer((_) async => + successResponse(path, data: {'message': message.toJson()})); + + final res = await messageApi.getMessage(messageId); + + expect(res, isNotNull); + expect(res.message.id, messageId); + + verify(() => client.get(path)).called(1); + verifyNoMoreInteractions(client); + }); + + test('updateMessage', () async { + final message = Message(id: 'test-message-id'); + + final path = '/messages/${message.id}'; + + when(() => client.post( + path, + data: {'message': message}, + )).thenAnswer( + (_) async => successResponse(path, data: {'message': message.toJson()}), + ); + + final res = await messageApi.updateMessage(message); + + expect(res, isNotNull); + expect(res.message.id, message.id); + + verify(() => client.post(path, data: any(named: 'data'))).called(1); + verifyNoMoreInteractions(client); + }); + + test('partialUpdateMessage', () async { + const messageId = 'test-message-id'; + + const set = {'text': 'Update Message text'}; + const unset = ['pinExpires']; + + const path = '/messages/$messageId'; + final message = Message(id: 'test-message-id', text: set['text']); + + when(() => client.put( + path, + data: {'set': set, 'unset': unset}, + )).thenAnswer( + (_) async => successResponse(path, data: {'message': message.toJson()}), + ); + + final res = await messageApi.partialUpdateMessage( + messageId, + set: set, + unset: unset, + ); + + expect(res, isNotNull); + expect(res.message.id, message.id); + expect(res.message.text, set['text']); + expect(res.message.pinExpires, isNull); + + verify(() => client.put( + path, + data: {'set': set, 'unset': unset}, + )).called(1); + verifyNoMoreInteractions(client); + }); + + test('deleteMessage', () async { + const messageId = 'test-message-id'; + + const path = '/messages/$messageId'; + + when(() => client.delete(path)).thenAnswer( + (_) async => successResponse(path, data: {}), + ); + + final res = await messageApi.deleteMessage(messageId); + + expect(res, isNotNull); + + verify(() => client.delete(path)).called(1); + verifyNoMoreInteractions(client); + }); + + test('sendAction', () async { + const channelId = 'test-channel-id'; + const channelType = 'test-channel-type'; + const messageId = 'test-message-id'; + const formData = {'test-key': 'test-data'}; + + const path = '/messages/$messageId/action'; + + when(() => client.post( + path, + data: { + 'id': channelId, + 'type': channelType, + 'form_data': formData, + 'message_id': messageId, + }, + )) + .thenAnswer( + (_) async => successResponse(path, data: {})); + + final res = await messageApi.sendAction( + channelId, + channelType, + messageId, + formData, + ); + + expect(res, isNotNull); + + verify(() => client.post(path, data: any(named: 'data'))).called(1); + verifyNoMoreInteractions(client); + }); + + test('sendReaction', () async { + const messageId = 'test-message-id'; + const reactionType = 'test-reaction-type'; + const extraData = {'test-key': 'test-data'}; + + const path = '/messages/$messageId/reaction'; + + final message = Message(id: messageId); + final reaction = Reaction(type: reactionType, messageId: messageId); + + when(() => client.post( + path, + data: { + 'reaction': Map.from(extraData) + ..addAll({'type': reactionType}), + 'enforce_unique': false, + }, + )).thenAnswer((_) async => successResponse(path, data: { + 'message': message.toJson(), + 'reaction': reaction.toJson(), + })); + + final res = await messageApi.sendReaction( + messageId, + reactionType, + extraData: extraData, + ); + + expect(res, isNotNull); + expect(res.message.id, messageId); + expect(res.reaction.messageId, messageId); + expect(res.reaction.type, reactionType); + + verify(() => client.post(path, data: any(named: 'data'))).called(1); + verifyNoMoreInteractions(client); + }); + + test('sendReaction with enforceUnique: true', () async { + const messageId = 'test-message-id'; + const reactionType = 'test-reaction-type'; + const extraData = {'test-key': 'test-data'}; + + const path = '/messages/$messageId/reaction'; + + final message = Message(id: messageId); + final reaction = Reaction(type: reactionType, messageId: messageId); + + when(() => client.post( + path, + data: { + 'reaction': Map.from(extraData) + ..addAll({'type': reactionType}), + 'enforce_unique': true, + }, + )).thenAnswer((_) async => successResponse(path, data: { + 'message': message.toJson(), + 'reaction': reaction.toJson(), + })); + + final res = await messageApi.sendReaction( + messageId, + reactionType, + extraData: extraData, + enforceUnique: true, + ); + + expect(res, isNotNull); + expect(res.message.id, messageId); + expect(res.reaction.messageId, messageId); + expect(res.reaction.type, reactionType); + + verify(() => client.post(path, data: any(named: 'data'))).called(1); + verifyNoMoreInteractions(client); + }); + + test('deleteReaction', () async { + const messageId = 'test-message-id'; + const reactionType = 'test-reaction-type'; + + const path = '/messages/$messageId/reaction/$reactionType'; + + when(() => client.delete(path)).thenAnswer( + (_) async => successResponse(path, data: {})); + + final res = await messageApi.deleteReaction(messageId, reactionType); + + expect(res, isNotNull); + + verify(() => client.delete(path)).called(1); + verifyNoMoreInteractions(client); + }); + + test('getReactions', () async { + const messageId = 'test-message-id'; + const options = PaginationParams(); + + const path = '/messages/$messageId/reactions'; + + final reactions = List.generate( + 3, + (index) => Reaction( + type: 'test-reaction-type-$index', + messageId: messageId, + ), + ); + + when(() => client.get( + path, + queryParameters: { + ...const PaginationParams().toJson(), + }, + )).thenAnswer((_) async => successResponse(path, data: { + 'reactions': [...reactions.map((it) => it.toJson())] + })); + + final res = await messageApi.getReactions(messageId, pagination: options); + + expect(res, isNotNull); + expect(res.reactions.length, reactions.length); + expect(res.reactions.every((it) => it.messageId == messageId), isTrue); + + verify( + () => client.get(path, queryParameters: any(named: 'queryParameters')), + ).called(1); + verifyNoMoreInteractions(client); + }); + + test('translateMessage', () async { + const messageId = 'test-message-id'; + const messageText = 'hello'; + const language = 'hi'; // Hindi + final message = Message(id: messageId, text: messageText); + + final path = '/messages/${message.id}/translate'; + + const translatedMessageText = 'नमस्ते'; + final translatedMessage = TranslatedMessage(const { + language: translatedMessageText, + }); + + when(() => client.post( + path, + data: {'language': language}, + )).thenAnswer((_) async => successResponse(path, data: { + 'message': translatedMessage.toJson(), + })); + + final res = await messageApi.translateMessage(messageId, language); + + expect(res, isNotNull); + expect(res.message.i18n?.containsKey(language), isTrue); + expect(res.message.i18n?[language], translatedMessageText); + + verify(() => client.post(path, data: any(named: 'data'))).called(1); + verifyNoMoreInteractions(client); + }); + + test('getReplies', () async { + const parentId = 'test-parent-id'; + const options = PaginationParams(); + + const path = '/messages/$parentId/replies'; + + final messages = List.generate( + 3, + (index) => Message( + id: 'test-message-id-$index', + parentId: parentId, + ), + ); + + when(() => client.get( + path, + queryParameters: { + ...options.toJson(), + }, + )).thenAnswer((_) async => successResponse(path, data: { + 'messages': [...messages.map((it) => it.toJson())] + })); + + final res = await messageApi.getReplies(parentId, options: options); + + expect(res, isNotNull); + expect(res.messages.length, messages.length); + expect(res.messages.every((it) => it.parentId == parentId), isTrue); + + verify( + () => client.get(path, queryParameters: any(named: 'queryParameters')), + ).called(1); + verifyNoMoreInteractions(client); + }); +} diff --git a/packages/stream_chat/test/src/core/api/moderation_api_test.dart b/packages/stream_chat/test/src/core/api/moderation_api_test.dart new file mode 100644 index 00000000..69d85fd9 --- /dev/null +++ b/packages/stream_chat/test/src/core/api/moderation_api_test.dart @@ -0,0 +1,240 @@ +import 'package:dio/dio.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:stream_chat/src/core/api/moderation_api.dart'; +import 'package:test/test.dart'; + +import '../../mocks.dart'; + +void main() { + Response successResponse(String path, {Object? data}) => Response( + data: data, + requestOptions: RequestOptions(path: path), + statusCode: 200, + ); + + late final client = MockHttpClient(); + late ModerationApi moderationApi; + + setUp(() { + moderationApi = ModerationApi(client); + }); + + test('muteUser', () async { + const userId = 'test-user-id'; + + const path = '/moderation/mute'; + + when( + () => client.post( + path, + data: {'target_id': userId}, + ), + ).thenAnswer((_) async => successResponse(path, data: {})); + + final res = await moderationApi.muteUser(userId); + + expect(res, isNotNull); + + verify(() => client.post(path, data: any(named: 'data'))).called(1); + verifyNoMoreInteractions(client); + }); + + test('unmuteUser', () async { + const userId = 'test-user-id'; + + const path = '/moderation/unmute'; + + when( + () => client.post( + path, + data: {'target_id': userId}, + ), + ).thenAnswer((_) async => successResponse(path, data: {})); + + final res = await moderationApi.unmuteUser(userId); + + expect(res, isNotNull); + + verify(() => client.post(path, data: any(named: 'data'))).called(1); + verifyNoMoreInteractions(client); + }); + + test('muteChannel', () async { + const channelCid = 'test-channel-cid'; + const expiration = Duration(days: 3); + + const path = '/moderation/mute/channel'; + + when(() => client.post( + path, + data: { + 'channel_cid': channelCid, + 'expiration': expiration.inMilliseconds, + }, + )) + .thenAnswer( + (_) async => successResponse(path, data: {})); + + final res = await moderationApi.muteChannel( + channelCid, + expiration: expiration, + ); + + expect(res, isNotNull); + + verify(() => client.post(path, data: any(named: 'data'))).called(1); + verifyNoMoreInteractions(client); + }); + + test('unmuteChannel', () async { + const channelCid = 'test-channel-cid'; + + const path = '/moderation/unmute/channel'; + + when(() => client.post( + path, + data: {'channel_cid': channelCid}, + )) + .thenAnswer( + (_) async => successResponse(path, data: {})); + + final res = await moderationApi.unmuteChannel(channelCid); + + expect(res, isNotNull); + + verify(() => client.post(path, data: any(named: 'data'))).called(1); + verifyNoMoreInteractions(client); + }); + + test('flagMessage', () async { + const messageId = 'test-message-id'; + + const path = '/moderation/flag'; + + when(() => client.post( + path, + data: { + 'target_message_id': messageId, + }, + )) + .thenAnswer( + (_) async => successResponse(path, data: {})); + + final res = await moderationApi.flagMessage(messageId); + + expect(res, isNotNull); + + verify(() => client.post(path, data: any(named: 'data'))).called(1); + verifyNoMoreInteractions(client); + }); + + test('unflagMessage', () async { + const messageId = 'test-message-id'; + + const path = '/moderation/unflag'; + + when(() => client.post( + path, + data: { + 'target_message_id': messageId, + }, + )) + .thenAnswer( + (_) async => successResponse(path, data: {})); + + final res = await moderationApi.unflagMessage(messageId); + + expect(res, isNotNull); + + verify(() => client.post(path, data: any(named: 'data'))).called(1); + verifyNoMoreInteractions(client); + }); + + test('flagUser', () async { + const userId = 'test-message-id'; + + const path = '/moderation/flag'; + + when(() => client.post(path, data: { + 'target_user_id': userId, + })) + .thenAnswer( + (_) async => successResponse(path, data: {})); + + final res = await moderationApi.flagUser(userId); + + expect(res, isNotNull); + + verify(() => client.post(path, data: any(named: 'data'))).called(1); + verifyNoMoreInteractions(client); + }); + + test('unflagUser', () async { + const userId = 'test-message-id'; + + const path = '/moderation/unflag'; + + when(() => client.post( + path, + data: { + 'target_user_id': userId, + }, + )) + .thenAnswer( + (_) async => successResponse(path, data: {})); + + final res = await moderationApi.unflagUser(userId); + + expect(res, isNotNull); + + verify(() => client.post(path, data: any(named: 'data'))).called(1); + verifyNoMoreInteractions(client); + }); + + test('banUser', () async { + const targetUserId = 'test-target-user-id'; + const options = {'key': 'value'}; + + const path = '/moderation/ban'; + + when(() => client.post(path, data: { + 'target_user_id': targetUserId, + ...options, + })) + .thenAnswer( + (_) async => successResponse(path, data: {})); + + final res = await moderationApi.banUser(targetUserId, options: options); + + expect(res, isNotNull); + + verify(() => client.post(path, data: any(named: 'data'))).called(1); + verifyNoMoreInteractions(client); + }); + + test('unbanUser', () async { + const targetUserId = 'test-target-user-id'; + const options = {'key': 'value'}; + + const path = '/moderation/ban'; + + when( + () => client.delete( + path, + queryParameters: { + 'target_user_id': targetUserId, + ...options, + }, + ), + ).thenAnswer((_) async => successResponse(path, data: {})); + + final res = await moderationApi.unbanUser(targetUserId, options: options); + + expect(res, isNotNull); + + verify( + () => client.delete(path, queryParameters: any(named: 'queryParameters')), + ).called(1); + verifyNoMoreInteractions(client); + }); +} diff --git a/packages/stream_chat/test/src/api/requests_test.dart b/packages/stream_chat/test/src/core/api/requests_test.dart similarity index 66% rename from packages/stream_chat/test/src/api/requests_test.dart rename to packages/stream_chat/test/src/core/api/requests_test.dart index de8ddaf8..76253403 100644 --- a/packages/stream_chat/test/src/api/requests_test.dart +++ b/packages/stream_chat/test/src/core/api/requests_test.dart @@ -1,18 +1,19 @@ -import 'package:test/test.dart'; import 'package:stream_chat/stream_chat.dart'; +import 'package:test/test.dart'; void main() { group('src/api/requests', () { test('SortOption', () { - final option = SortOption('name'); + const option = SortOption('name'); final j = option.toJson(); expect(j, {'field': 'name', 'direction': -1}); }); test('PaginationParams', () { - final option = PaginationParams(); + const option = PaginationParams(); final j = option.toJson(); - expect(j, {'limit': 10, 'offset': 0}); + expect(j, containsPair('limit', 10)); + expect(j, containsPair('offset', 0)); }); }); } diff --git a/packages/stream_chat/test/src/api/responses_test.dart b/packages/stream_chat/test/src/core/api/responses_test.dart similarity index 99% rename from packages/stream_chat/test/src/api/responses_test.dart rename to packages/stream_chat/test/src/core/api/responses_test.dart index 8c7b57e5..77e7abc3 100644 --- a/packages/stream_chat/test/src/api/responses_test.dart +++ b/packages/stream_chat/test/src/core/api/responses_test.dart @@ -1,18 +1,19 @@ import 'dart:convert'; import 'package:test/test.dart'; -import 'package:stream_chat/src/api/responses.dart'; -import 'package:stream_chat/src/models/device.dart'; -import 'package:stream_chat/src/models/member.dart'; -import 'package:stream_chat/src/models/message.dart'; -import 'package:stream_chat/src/models/reaction.dart'; -import 'package:stream_chat/src/models/read.dart'; +import 'package:stream_chat/src/core/api/responses.dart'; +import 'package:stream_chat/src/core/models/device.dart'; +import 'package:stream_chat/src/core/models/member.dart'; +import 'package:stream_chat/src/core/models/message.dart'; +import 'package:stream_chat/src/core/models/reaction.dart'; +import 'package:stream_chat/src/core/models/read.dart'; import 'package:stream_chat/stream_chat.dart'; void main() { group('src/api/responses', () { test('QueryChannelsResponse', () { - const jsonExample = r'''{ + const jsonExample = r''' + { "channels": [ { "channel": { @@ -3284,7 +3285,7 @@ void main() { }); test('QueryReactionsResponse', () { - const jsonExample = r''' + const jsonExample = ''' {"reactions": [{"message_id": "4637f7e4-a06b-42db-ba5a-8d8270dd926f","user_id": "c1c9b454-2bcc-402d-8bb0-2f3706ce1680","user": {"id": "c1c9b454-2bcc-402d-8bb0-2f3706ce1680","role": "user","created_at": "2020-01-28T22:17:30.83015Z","updated_at": "2020-01-28T22:17:31.19435Z","banned": false,"online": false,"image": "https://randomuser.me/api/portraits/women/2.jpg","name": "Mia Denys"},"type": "love","score": 1,"created_at": "2020-01-28T22:17:31.128376Z","updated_at": "2020-01-28T22:17:31.128376Z"}]} '''; final response = @@ -3402,37 +3403,38 @@ void main() { test('ListDevicesResponse', () { const jsonExample = - r'''{"devices":[{"push_provider":"firebase","id":"test"}],"duration":"0.35ms"}'''; + '''{"devices":[{"push_provider":"firebase","id":"test"}],"duration":"0.35ms"}'''; final response = ListDevicesResponse.fromJson(json.decode(jsonExample)); expect(response.devices, isA>()); }); test('SendFileResponse', () { - const jsonExample = r'''{"file": "file-url","duration":"0.35ms"}'''; + const jsonExample = '''{"file": "file-url","duration":"0.35ms"}'''; final response = SendFileResponse.fromJson(json.decode(jsonExample)); expect(response.file, isA()); }); test('SendImageResponse', () { - const jsonExample = r'''{"file": "file-url","duration":"0.35ms"}'''; + const jsonExample = '''{"file": "file-url","duration":"0.35ms"}'''; final response = SendImageResponse.fromJson(json.decode(jsonExample)); expect(response.file, isA()); }); test('SendImageResponse', () { - const jsonExample = r'''{"file": "file-url","duration":"0.35ms"}'''; + const jsonExample = '''{"file": "file-url","duration":"0.35ms"}'''; final response = SendImageResponse.fromJson(json.decode(jsonExample)); expect(response.file, isA()); }); test('EmptyResponse', () { - const jsonExample = r'''{"file": "file-url","duration":"0.35ms"}'''; + const jsonExample = '''{"file": "file-url","duration":"0.35ms"}'''; final response = EmptyResponse.fromJson(json.decode(jsonExample)); expect(response.duration, isA()); }); test('SendReactionResponse', () { - const jsonExample = r'''{"message": { + const jsonExample = r''' + {"message": { "id": "c6076f11-7768-4a04-bdf2-c43dddd6d666", "text": "What we don't know for sure is whether or not a step-daughter of the bear is assumed to be a farci hourglass.", "html": "\u003cp\u003eWhat we don’t know for sure is whether or not a step-daughter of the bear is assumed to be a farci hourglass.\u003c/p\u003e\n", @@ -3481,8 +3483,8 @@ void main() { }); test('UpdateUsersResponse', () { - const jsonExample = - r'''{"users": {"bbb19d9a-ee50-45bc-84e5-0584e79d0c9e":{ + const jsonExample = ''' + {"users": {"bbb19d9a-ee50-45bc-84e5-0584e79d0c9e":{ "id": "bbb19d9a-ee50-45bc-84e5-0584e79d0c9e", "role": "user", "created_at": "2020-01-28T22:17:30.826259Z", @@ -3498,7 +3500,7 @@ void main() { test('ConnectGuestUserResponse', () { const jsonExample = - r'{"user":{"id":"guest-ac612aee-25fe-49fb-b1af-969e41f452a0-wild-breeze-7","role":"guest","created_at":"2020-02-03T10:19:01.538434Z","updated_at":"2020-02-03T10:19:01.539543Z","banned":false,"online":false},"access_token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoiZ3Vlc3QtYWM2MTJhZWUtMjVmZS00OWZiLWIxYWYtOTY5ZTQxZjQ1MmEwLXdpbGQtYnJlZXplLTcifQ.mmoFGu7oJjpFsp7nFN78UbIpO7gowbuIbyoppsuvbXA","duration":"4.66ms"}'; + '''{"user":{"id":"guest-ac612aee-25fe-49fb-b1af-969e41f452a0-wild-breeze-7","role":"guest","created_at":"2020-02-03T10:19:01.538434Z","updated_at":"2020-02-03T10:19:01.539543Z","banned":false,"online":false},"access_token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoiZ3Vlc3QtYWM2MTJhZWUtMjVmZS00OWZiLWIxYWYtOTY5ZTQxZjQ1MmEwLXdpbGQtYnJlZXplLTcifQ.mmoFGu7oJjpFsp7nFN78UbIpO7gowbuIbyoppsuvbXA","duration":"4.66ms"}'''; final response = ConnectGuestUserResponse.fromJson(json.decode(jsonExample)); expect(response.user, isA()); @@ -3506,7 +3508,8 @@ void main() { }); test('GetMessagesByIdResponse', () { - const jsonExample = r'''{"messages":[{ + const jsonExample = r''' + {"messages":[{ "id": "c6076f11-7768-4a04-bdf2-c43dddd6d666", "text": "What we don't know for sure is whether or not a step-daughter of the bear is assumed to be a farci hourglass.", "html": "\u003cp\u003eWhat we don’t know for sure is whether or not a step-daughter of the bear is assumed to be a farci hourglass.\u003c/p\u003e\n", @@ -3537,7 +3540,8 @@ void main() { }); test('SendActionResponse', () { - const jsonExample = r'''{"message":{ + const jsonExample = r''' + {"message":{ "id": "c6076f11-7768-4a04-bdf2-c43dddd6d666", "text": "What we don't know for sure is whether or not a step-daughter of the bear is assumed to be a farci hourglass.", "html": "\u003cp\u003eWhat we don’t know for sure is whether or not a step-daughter of the bear is assumed to be a farci hourglass.\u003c/p\u003e\n", @@ -3567,7 +3571,8 @@ void main() { }); test('UpdateMessageResponse', () { - const jsonExample = r'''{"message":{ + const jsonExample = r''' + {"message":{ "id": "c6076f11-7768-4a04-bdf2-c43dddd6d666", "text": "What we don't know for sure is whether or not a step-daughter of the bear is assumed to be a farci hourglass.", "html": "\u003cp\u003eWhat we don’t know for sure is whether or not a step-daughter of the bear is assumed to be a farci hourglass.\u003c/p\u003e\n", @@ -3597,7 +3602,8 @@ void main() { }); test('SendMessageResponse', () { - const jsonExample = r'''{"message":{ + const jsonExample = r''' + {"message":{ "id": "c6076f11-7768-4a04-bdf2-c43dddd6d666", "text": "What we don't know for sure is whether or not a step-daughter of the bear is assumed to be a farci hourglass.", "html": "\u003cp\u003eWhat we don’t know for sure is whether or not a step-daughter of the bear is assumed to be a farci hourglass.\u003c/p\u003e\n", @@ -3627,7 +3633,8 @@ void main() { }); test('GetMessageResponse', () { - const jsonExample = r'''{"message":{ + const jsonExample = r''' + {"message":{ "id": "c6076f11-7768-4a04-bdf2-c43dddd6d666", "text": "What we don't know for sure is whether or not a step-daughter of the bear is assumed to be a farci hourglass.", "html": "\u003cp\u003eWhat we don’t know for sure is whether or not a step-daughter of the bear is assumed to be a farci hourglass.\u003c/p\u003e\n", @@ -3657,7 +3664,8 @@ void main() { }); test('UpdateChannelResponse', () { - const jsonExample = r'''{"message":{ + const jsonExample = r''' + {"message":{ "id": "c6076f11-7768-4a04-bdf2-c43dddd6d666", "text": "What we don't know for sure is whether or not a step-daughter of the bear is assumed to be a farci hourglass.", "html": "\u003cp\u003eWhat we don’t know for sure is whether or not a step-daughter of the bear is assumed to be a farci hourglass.\u003c/p\u003e\n", @@ -3770,7 +3778,8 @@ void main() { }); test('InviteMembersResponse', () { - const jsonExample = r'''{"message":{ + const jsonExample = r''' + {"message":{ "id": "c6076f11-7768-4a04-bdf2-c43dddd6d666", "text": "What we don't know for sure is whether or not a step-daughter of the bear is assumed to be a farci hourglass.", "html": "\u003cp\u003eWhat we don’t know for sure is whether or not a step-daughter of the bear is assumed to be a farci hourglass.\u003c/p\u003e\n", @@ -3883,7 +3892,8 @@ void main() { }); test('RemoveMembersResponse', () { - const jsonExample = r'''{"message":{ + const jsonExample = r''' + {"message":{ "id": "c6076f11-7768-4a04-bdf2-c43dddd6d666", "text": "What we don't know for sure is whether or not a step-daughter of the bear is assumed to be a farci hourglass.", "html": "\u003cp\u003eWhat we don’t know for sure is whether or not a step-daughter of the bear is assumed to be a farci hourglass.\u003c/p\u003e\n", @@ -3996,7 +4006,8 @@ void main() { }); test('AddMembersResponse', () { - const jsonExample = r'''{"message":{ + const jsonExample = r''' + {"message":{ "id": "c6076f11-7768-4a04-bdf2-c43dddd6d666", "text": "What we don't know for sure is whether or not a step-daughter of the bear is assumed to be a farci hourglass.", "html": "\u003cp\u003eWhat we don’t know for sure is whether or not a step-daughter of the bear is assumed to be a farci hourglass.\u003c/p\u003e\n", @@ -4109,7 +4120,8 @@ void main() { }); test('AcceptInviteResponse', () { - const jsonExample = r'''{"message":{ + const jsonExample = r''' + {"message":{ "id": "c6076f11-7768-4a04-bdf2-c43dddd6d666", "text": "What we don't know for sure is whether or not a step-daughter of the bear is assumed to be a farci hourglass.", "html": "\u003cp\u003eWhat we don’t know for sure is whether or not a step-daughter of the bear is assumed to be a farci hourglass.\u003c/p\u003e\n", @@ -4222,7 +4234,8 @@ void main() { }); test('RejectInviteResponse', () { - const jsonExample = r'''{"message":{ + const jsonExample = r''' + {"message":{ "id": "c6076f11-7768-4a04-bdf2-c43dddd6d666", "text": "What we don't know for sure is whether or not a step-daughter of the bear is assumed to be a farci hourglass.", "html": "\u003cp\u003eWhat we don’t know for sure is whether or not a step-daughter of the bear is assumed to be a farci hourglass.\u003c/p\u003e\n", diff --git a/packages/stream_chat/test/src/core/api/stream_chat_api_test.dart b/packages/stream_chat/test/src/core/api/stream_chat_api_test.dart new file mode 100644 index 00000000..f61746cd --- /dev/null +++ b/packages/stream_chat/test/src/core/api/stream_chat_api_test.dart @@ -0,0 +1,49 @@ +import 'package:stream_chat/src/core/api/stream_chat_api.dart'; +import 'package:test/test.dart'; + +import '../../mocks.dart'; + +void main() { + const apiKey = 'test-api-key'; + late final client = MockHttpClient(); + late StreamChatApi streamChatApi; + + setUp(() { + streamChatApi = StreamChatApi( + apiKey, + client: client, + ); + }); + + test('`.user`', () { + expect(streamChatApi.user, isNotNull); + }); + + test('`.guest`', () { + expect(streamChatApi.guest, isNotNull); + }); + + test('`.message`', () { + expect(streamChatApi.message, isNotNull); + }); + + test('`.channel`', () { + expect(streamChatApi.channel, isNotNull); + }); + + test('`.device`', () { + expect(streamChatApi.device, isNotNull); + }); + + test('`.moderation`', () { + expect(streamChatApi.moderation, isNotNull); + }); + + test('`.general`', () { + expect(streamChatApi.general, isNotNull); + }); + + test('`.fileUploader`', () { + expect(streamChatApi.fileUploader, isNotNull); + }); +} diff --git a/packages/stream_chat/test/src/core/api/user_api_test.dart b/packages/stream_chat/test/src/core/api/user_api_test.dart new file mode 100644 index 00000000..6eecbee1 --- /dev/null +++ b/packages/stream_chat/test/src/core/api/user_api_test.dart @@ -0,0 +1,87 @@ +import 'dart:convert'; + +import 'package:dio/dio.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:stream_chat/src/core/api/requests.dart'; +import 'package:stream_chat/src/core/api/user_api.dart'; +import 'package:stream_chat/src/core/models/filter.dart'; +import 'package:stream_chat/stream_chat.dart'; +import 'package:test/test.dart'; + +import '../../mocks.dart'; + +void main() { + Response successResponse(String path, {Object? data}) => Response( + data: data, + requestOptions: RequestOptions(path: path), + statusCode: 200, + ); + + late final client = MockHttpClient(); + late UserApi userApi; + + setUp(() { + userApi = UserApi(client); + }); + + test('queryUsers', () async { + const presence = true; + final filter = Filter.in_('cid', const ['test-cid-1', 'test-cid-2']); + const sort = [SortOption('test-field')]; + const pagination = PaginationParams(); + + const path = '/users'; + + final users = List.generate(3, (index) => User(id: 'test-user-id-$index')); + + when(() => client.get(path, queryParameters: { + 'payload': jsonEncode({ + 'presence': presence, + 'sort': sort, + 'filter_conditions': filter, + ...pagination.toJson(), + }), + })).thenAnswer((_) async => successResponse(path, data: { + 'users': [...users.map((it) => it.toJson())] + })); + + final res = await userApi.queryUsers( + presence: presence, + filter: filter, + sort: sort, + pagination: pagination, + ); + + expect(res, isNotNull); + expect(res.users.length, users.length); + + verify( + () => client.get(path, queryParameters: any(named: 'queryParameters')), + ).called(1); + verifyNoMoreInteractions(client); + }); + + test('updateUsers', () async { + final users = List.generate(3, (index) => User(id: 'test-user-id-$index')); + + const path = '/users'; + + final updatedUsers = {for (final user in users) user.id: user}; + + when(() => client.post(path, data: { + 'users': updatedUsers, + })).thenAnswer((_) async => successResponse(path, + data: { + 'users': updatedUsers + .map((key, value) => MapEntry(key, value.toJson())) + })); + + final res = await userApi.updateUsers(users); + + expect(res, isNotNull); + expect(res.users.length, updatedUsers.length); + + verify(() => client.post(path, data: any(named: 'data'))).called(1); + verifyNoMoreInteractions(client); + }); +} diff --git a/packages/stream_chat/test/src/core/error/stream_chat_error_test.dart b/packages/stream_chat/test/src/core/error/stream_chat_error_test.dart new file mode 100644 index 00000000..a32e0d71 --- /dev/null +++ b/packages/stream_chat/test/src/core/error/stream_chat_error_test.dart @@ -0,0 +1,126 @@ +import 'package:dio/dio.dart'; +import 'package:stream_chat/src/core/api/responses.dart'; +import 'package:stream_chat/src/core/error/error.dart'; +import 'package:test/test.dart'; + +void main() { + group('StreamChatError', () { + test('should match if message is same', () { + const message = 'test-error-message'; + const error = StreamChatError(message); + const error2 = StreamChatError(message); + + expect(error, error2); + }); + + test('`.toString`', () { + const message = 'test-error-message'; + const error = StreamChatError(message); + + expect(error.toString(), 'StreamChatError(message: $message)'); + }); + }); + + group('StreamWebSocketError', () { + test('.fromStreamError', () { + final data = ErrorResponse()..code = 333; + final error = StreamWebSocketError.fromStreamError(data.toJson()); + expect(error, isNotNull); + expect(error.code, data.code); + }); + + test('should match if message and data.code is same', () { + const message = 'test-error-message'; + final data = ErrorResponse()..code = 333; + final error = StreamWebSocketError(message, data: data); + final error2 = StreamWebSocketError(message, data: data); + + expect(error, error2); + }); + + test('`.toString`', () { + const message = 'test-error-message'; + final data = ErrorResponse()..code = 333; + final error = StreamWebSocketError(message, data: data); + + expect( + error.toString(), + 'WebSocketError(message: $message, data: $data)', + ); + }); + }); + + group('StreamChatNetworkError', () { + test('.raw', () { + const code = 333; + const message = 'test-error-message'; + final error = StreamChatNetworkError.raw(code: code, message: message); + expect(error, isNotNull); + expect(error.code, code); + expect(error.message, message); + }); + + test('.fromDioError', () { + const code = 333; + const statusCode = 666; + const message = 'test-error-message'; + final options = RequestOptions(path: 'test-path'); + final data = ErrorResponse() + ..code = code + ..statusCode = statusCode + ..message = message; + final dioError = DioError( + requestOptions: options, + response: Response( + requestOptions: options, + statusCode: data.statusCode, + data: data.toJson(), + ), + ); + final error = StreamChatNetworkError.fromDioError(dioError); + expect(error, isNotNull); + expect(error.code, code); + expect(error.message, message); + expect(error.statusCode, statusCode); + expect(error.data?.code, data.code); + expect(error.data?.statusCode, data.statusCode); + expect(error.data?.message, data.message); + }); + + test('should match if message, code and statusCode is same', () { + const code = 333; + const statusCode = 666; + const message = 'test-error-message'; + final error = StreamChatNetworkError.raw( + code: code, + statusCode: statusCode, + message: message, + ); + final error2 = StreamChatNetworkError.raw( + code: code, + statusCode: statusCode, + message: message, + ); + + expect(error, error2); + }); + + test('`.retriable` should return true if data is not present', () { + const errorCode = ChatErrorCode.tokenExpired; + final error = StreamChatNetworkError(errorCode); + + expect(error.isRetriable, isTrue); + }); + + test('`.toString`', () { + const errorCode = ChatErrorCode.tokenExpired; + final error = StreamChatNetworkError(errorCode); + expect( + error.toString(), + 'StreamChatNetworkError(' + 'code: ${errorCode.code}, ' + 'message: ${errorCode.message})', + ); + }); + }); +} diff --git a/packages/stream_chat/test/src/core/http/connection_id_manager_test.dart b/packages/stream_chat/test/src/core/http/connection_id_manager_test.dart new file mode 100644 index 00000000..ad70283f --- /dev/null +++ b/packages/stream_chat/test/src/core/http/connection_id_manager_test.dart @@ -0,0 +1,38 @@ +import 'package:stream_chat/src/core/http/connection_id_manager.dart'; +import 'package:test/test.dart'; + +void main() { + late ConnectionIdManager connectionIdManager; + + setUp(() { + connectionIdManager = ConnectionIdManager(); + }); + + tearDown(() { + connectionIdManager.reset(); + }); + + test('`setConnectionId` should set connectionId', () { + expect(connectionIdManager.connectionId, isNull); + expect(connectionIdManager.hasConnectionId, isFalse); + + const connectionId = 'test-connection-id'; + connectionIdManager.setConnectionId(connectionId); + + expect(connectionIdManager.connectionId, connectionId); + expect(connectionIdManager.hasConnectionId, isTrue); + }); + + test('`reset` should clear the connectionId', () { + const connectionId = 'test-connection-id'; + connectionIdManager.setConnectionId(connectionId); + + expect(connectionIdManager.connectionId, connectionId); + expect(connectionIdManager.hasConnectionId, isTrue); + + connectionIdManager.reset(); + + expect(connectionIdManager.connectionId, isNull); + expect(connectionIdManager.hasConnectionId, isFalse); + }); +} diff --git a/packages/stream_chat/test/src/core/http/interceptor/auth_interceptor_test.dart b/packages/stream_chat/test/src/core/http/interceptor/auth_interceptor_test.dart new file mode 100644 index 00000000..82dcdfb9 --- /dev/null +++ b/packages/stream_chat/test/src/core/http/interceptor/auth_interceptor_test.dart @@ -0,0 +1,270 @@ +import 'package:dio/dio.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:stream_chat/src/core/http/interceptor/auth_interceptor.dart'; +import 'package:stream_chat/src/core/http/stream_chat_dio_error.dart'; +import 'package:stream_chat/src/core/http/stream_http_client.dart'; +import 'package:stream_chat/src/core/http/token.dart'; +import 'package:stream_chat/src/core/http/token_manager.dart'; +import 'package:stream_chat/stream_chat.dart'; +import 'package:test/test.dart'; + +import '../../../mocks.dart'; + +void main() { + late StreamHttpClient client; + late TokenManager tokenManager; + late AuthInterceptor authInterceptor; + + setUp(() { + client = MockHttpClient(); + tokenManager = MockTokenManager(); + authInterceptor = AuthInterceptor(client, tokenManager); + }); + + test( + '`onRequest` should add userId, authToken, authType in the request', + () async { + final options = RequestOptions(path: 'test-path'); + final handler = RequestInterceptorHandler(); + + final headers = options.headers; + final queryParams = options.queryParameters; + expect(headers.containsKey('Authorization'), isFalse); + expect(headers.containsKey('stream-auth-type'), isFalse); + expect(queryParams.containsKey('user_id'), isFalse); + + final token = Token.development('test-user-id'); + when(() => tokenManager.loadToken(refresh: any(named: 'refresh'))) + .thenAnswer((_) async => token); + + authInterceptor.onRequest(options, handler); + + final updatedOptions = (await handler.future).data as RequestOptions; + final updateHeaders = updatedOptions.headers; + final updatedQueryParams = updatedOptions.queryParameters; + + expect(updateHeaders.containsKey('Authorization'), isTrue); + expect(updateHeaders['Authorization'], token.rawValue); + expect(updateHeaders.containsKey('stream-auth-type'), isTrue); + expect(updateHeaders['stream-auth-type'], token.authType.raw); + expect(updatedQueryParams.containsKey('user_id'), isTrue); + expect(updatedQueryParams['user_id'], token.userId); + + verify(() => tokenManager.loadToken(refresh: any(named: 'refresh'))) + .called(1); + verifyNoMoreInteractions(tokenManager); + }, + ); + + test( + '`onRequest` should reject with error if `tokenManager.loadToken` throws', + () async { + final options = RequestOptions(path: 'test-path'); + final handler = RequestInterceptorHandler(); + + authInterceptor.onRequest(options, handler); + + try { + await handler.future; + } catch (e) { + // need to cast it as the type is private in dio + var error = (e as dynamic).data; + expect(error, isA()); + error = (error as StreamChatDioError).error; + expect(error.code, ChatErrorCode.undefinedToken.code); + expect(error.message, ChatErrorCode.undefinedToken.message); + } + }, + ); + + test('`onError` should retry the request with refreshed token', () async { + const path = 'test-request-path'; + final options = RequestOptions(path: path); + const code = ChatErrorCode.tokenExpired; + final errorResponse = ErrorResponse() + ..code = code.code + ..message = code.message; + final response = Response( + requestOptions: options, + data: errorResponse.toJson(), + ); + final err = DioError(requestOptions: options, response: response); + final handler = ErrorInterceptorHandler(); + + when(() => tokenManager.isStatic).thenReturn(false); + + when(() => client.lock()).thenReturn(() {}); + + final token = Token.development('test-user-id'); + when(() => tokenManager.loadToken(refresh: true)) + .thenAnswer((_) async => token); + + when(() => client.unlock()).thenReturn(() {}); + + when(() => client.request( + path, + data: options.data, + onReceiveProgress: options.onReceiveProgress, + onSendProgress: options.onSendProgress, + queryParameters: options.queryParameters, + cancelToken: options.cancelToken, + options: any(named: 'options'), + )).thenAnswer((_) async => Response( + requestOptions: options, + statusCode: 200, + )); + + authInterceptor.onError(err, handler); + + final res = await handler.future; + + var data = res.data; + expect(data, isA()); + data = data as Response; + expect(data, isNotNull); + expect(data.statusCode, 200); + expect(data.requestOptions.path, path); + + verify(() => tokenManager.isStatic).called(1); + + verify(() => client.lock()).called(1); + + verify(() => tokenManager.loadToken(refresh: true)).called(1); + verifyNoMoreInteractions(tokenManager); + + verify(() => client.unlock()).called(1); + verify(() => client.request( + path, + data: options.data, + onReceiveProgress: options.onReceiveProgress, + onSendProgress: options.onSendProgress, + queryParameters: options.queryParameters, + cancelToken: options.cancelToken, + options: any(named: 'options'), + )).called(1); + verifyNoMoreInteractions(client); + }); + + test( + '`onError` should reject with error if retried request throws', + () async { + const path = 'test-request-path'; + final options = RequestOptions(path: path); + const code = ChatErrorCode.tokenExpired; + final errorResponse = ErrorResponse() + ..code = code.code + ..message = code.message; + final response = Response( + requestOptions: options, + data: errorResponse.toJson(), + ); + final err = DioError(requestOptions: options, response: response); + final handler = ErrorInterceptorHandler(); + + when(() => tokenManager.isStatic).thenReturn(false); + + when(() => client.lock()).thenReturn(() {}); + + final token = Token.development('test-user-id'); + when(() => tokenManager.loadToken(refresh: true)) + .thenAnswer((_) async => token); + + when(() => client.unlock()).thenReturn(() {}); + + when(() => client.request( + path, + data: options.data, + onReceiveProgress: options.onReceiveProgress, + onSendProgress: options.onSendProgress, + queryParameters: options.queryParameters, + cancelToken: options.cancelToken, + options: any(named: 'options'), + )).thenThrow(err); + + authInterceptor.onError(err, handler); + + try { + await handler.future; + } catch (e) { + // need to cast it as the type is private in dio + final error = (e as dynamic).data; + expect(error, isA()); + } + + verify(() => tokenManager.isStatic).called(1); + + verify(() => client.lock()).called(1); + + verify(() => tokenManager.loadToken(refresh: true)).called(1); + verifyNoMoreInteractions(tokenManager); + + verify(() => client.unlock()).called(1); + verify(() => client.request( + path, + data: options.data, + onReceiveProgress: options.onReceiveProgress, + onSendProgress: options.onSendProgress, + queryParameters: options.queryParameters, + cancelToken: options.cancelToken, + options: any(named: 'options'), + )).called(1); + verifyNoMoreInteractions(client); + }, + ); + + test( + '`onError` should reject with error if `tokenManager.isStatic` is true', + () async { + const path = 'test-request-path'; + final options = RequestOptions(path: path); + const code = ChatErrorCode.tokenExpired; + final errorResponse = ErrorResponse() + ..code = code.code + ..message = code.message; + final response = Response( + requestOptions: options, + data: errorResponse.toJson(), + ); + final err = DioError(requestOptions: options, response: response); + final handler = ErrorInterceptorHandler(); + + when(() => tokenManager.isStatic).thenReturn(true); + + authInterceptor.onError(err, handler); + + try { + await handler.future; + } catch (e) { + // need to cast it as the type is private in dio + final error = (e as dynamic).data; + expect(error, isA()); + final response = StreamChatNetworkError.fromDioError(error); + expect(response.errorCode, code); + } + + verify(() => tokenManager.isStatic).called(1); + verifyNoMoreInteractions(tokenManager); + }, + ); + + test( + '`onError` should reject with error if error is not a `tokenExpired error`', + () async { + const path = 'test-request-path'; + final options = RequestOptions(path: path); + final response = Response(requestOptions: options); + final err = DioError(requestOptions: options, response: response); + final handler = ErrorInterceptorHandler(); + + authInterceptor.onError(err, handler); + + try { + await handler.future; + } catch (e) { + // need to cast it as the type is private in dio + final error = (e as dynamic).data; + expect(error, isA()); + } + }, + ); +} diff --git a/packages/stream_chat/test/src/core/http/interceptor/connection_id_interceptor_test.dart b/packages/stream_chat/test/src/core/http/interceptor/connection_id_interceptor_test.dart new file mode 100644 index 00000000..9acabccf --- /dev/null +++ b/packages/stream_chat/test/src/core/http/interceptor/connection_id_interceptor_test.dart @@ -0,0 +1,67 @@ +import 'package:dio/dio.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:stream_chat/src/core/http/connection_id_manager.dart'; +import 'package:stream_chat/src/core/http/interceptor/connection_id_interceptor.dart'; +import 'package:test/test.dart'; + +import '../../../mocks.dart'; + +void main() { + late ConnectionIdManager connectionIdManager; + late ConnectionIdInterceptor connectionIdInterceptor; + + setUp(() { + connectionIdManager = MockConnectionIdManager(); + connectionIdInterceptor = ConnectionIdInterceptor(connectionIdManager); + }); + + test( + '`onRequest` should add connectionId in the request', + () async { + final options = RequestOptions(path: 'test-path'); + final handler = RequestInterceptorHandler(); + + final queryParams = options.queryParameters; + expect(queryParams.containsKey('connection_id'), isFalse); + + const connectionId = 'test-connection-id'; + when(() => connectionIdManager.hasConnectionId).thenReturn(true); + when(() => connectionIdManager.connectionId).thenReturn(connectionId); + + connectionIdInterceptor.onRequest(options, handler); + + final updatedOptions = (await handler.future).data as RequestOptions; + final updatedQueryParams = updatedOptions.queryParameters; + + expect(updatedQueryParams.containsKey('connection_id'), isTrue); + expect(updatedQueryParams['connection_id'], connectionId); + + verify(() => connectionIdManager.hasConnectionId).called(1); + verify(() => connectionIdManager.connectionId).called(1); + verifyNoMoreInteractions(connectionIdManager); + }, + ); + + test( + '`onRequest` should not add connectionId if `hasConnectionId` is false', + () async { + final options = RequestOptions(path: 'test-path'); + final handler = RequestInterceptorHandler(); + + final queryParams = options.queryParameters; + expect(queryParams.containsKey('connection_id'), isFalse); + + when(() => connectionIdManager.hasConnectionId).thenReturn(false); + + connectionIdInterceptor.onRequest(options, handler); + + final updatedOptions = (await handler.future).data as RequestOptions; + final updatedQueryParams = updatedOptions.queryParameters; + + expect(updatedQueryParams.containsKey('connection_id'), isFalse); + + verify(() => connectionIdManager.hasConnectionId).called(1); + verifyNoMoreInteractions(connectionIdManager); + }, + ); +} diff --git a/packages/stream_chat/test/src/core/http/stream_chat_dio_error_test.dart b/packages/stream_chat/test/src/core/http/stream_chat_dio_error_test.dart new file mode 100644 index 00000000..f13cf50d --- /dev/null +++ b/packages/stream_chat/test/src/core/http/stream_chat_dio_error_test.dart @@ -0,0 +1,20 @@ +import 'package:dio/dio.dart'; +import 'package:stream_chat/src/core/error/error.dart'; +import 'package:stream_chat/src/core/http/stream_chat_dio_error.dart'; +import 'package:test/test.dart'; + +void main() { + test('should create a new instance of StreamChatDioError', () { + final error = StreamChatNetworkError(ChatErrorCode.inputError); + final options = RequestOptions(path: 'test-path'); + final dioError = StreamChatDioError( + error: error, + requestOptions: options, + ); + + expect(dioError, isA()); + expect(dioError, isNotNull); + expect(dioError.error, error); + expect(dioError.requestOptions, options); + }); +} diff --git a/packages/stream_chat/test/src/core/http/stream_http_client_options_test.dart b/packages/stream_chat/test/src/core/http/stream_http_client_options_test.dart new file mode 100644 index 00000000..a03434fa --- /dev/null +++ b/packages/stream_chat/test/src/core/http/stream_http_client_options_test.dart @@ -0,0 +1,53 @@ +import 'package:stream_chat/src/core/http/stream_http_client.dart'; +import 'package:stream_chat/src/location.dart'; +import 'package:test/test.dart'; + +void main() { + test('should return the all default set params', () { + const options = StreamHttpClientOptions(); + expect(options.location, isNull); + expect(options.baseUrl, 'https://chat-us-east-1.stream-io-api.com'); + expect(options.connectTimeout, const Duration(seconds: 6)); + expect(options.receiveTimeout, const Duration(seconds: 6)); + }); + + test('should override all the default set params', () { + const options = StreamHttpClientOptions( + baseUrl: 'base-url', + connectTimeout: Duration(seconds: 3), + receiveTimeout: Duration(seconds: 3), + ); + expect(options.location, isNull); + expect(options.baseUrl, 'base-url'); + expect(options.connectTimeout, const Duration(seconds: 3)); + expect(options.receiveTimeout, const Duration(seconds: 3)); + }); + + group('should create baseUrl according to provided location', () { + test('us-east', () { + const options = StreamHttpClientOptions(location: Location.usEast); + expect(options.location, isNotNull); + expect(options.baseUrl, 'https://chat-proxy-us-east.stream-io-api.com'); + }); + test('eu-west', () { + const options = StreamHttpClientOptions(location: Location.euWest); + expect(options.location, isNotNull); + expect(options.baseUrl, 'https://chat-proxy-dublin.stream-io-api.com'); + }); + test('mumbai', () { + const options = StreamHttpClientOptions(location: Location.mumbai); + expect(options.location, isNotNull); + expect(options.baseUrl, 'https://chat-proxy-mumbai.stream-io-api.com'); + }); + test('sydney', () { + const options = StreamHttpClientOptions(location: Location.sydney); + expect(options.location, isNotNull); + expect(options.baseUrl, 'https://chat-proxy-sydney.stream-io-api.com'); + }); + test('singapore', () { + const options = StreamHttpClientOptions(location: Location.singapore); + expect(options.location, isNotNull); + expect(options.baseUrl, 'https://chat-proxy-singapore.stream-io-api.com'); + }); + }); +} diff --git a/packages/stream_chat/test/src/core/http/stream_http_client_test.dart b/packages/stream_chat/test/src/core/http/stream_http_client_test.dart new file mode 100644 index 00000000..ab4c4984 --- /dev/null +++ b/packages/stream_chat/test/src/core/http/stream_http_client_test.dart @@ -0,0 +1,528 @@ +import 'package:dio/dio.dart'; +import 'package:logging/logging.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:stream_chat/src/core/api/responses.dart'; +import 'package:stream_chat/src/core/error/error.dart'; +import 'package:stream_chat/src/core/http/connection_id_manager.dart'; +import 'package:stream_chat/src/core/http/interceptor/auth_interceptor.dart'; +import 'package:stream_chat/src/core/http/interceptor/connection_id_interceptor.dart'; +import 'package:stream_chat/src/core/http/interceptor/logging_interceptor.dart'; +import 'package:stream_chat/src/core/http/stream_chat_dio_error.dart'; +import 'package:stream_chat/src/core/http/stream_http_client.dart'; +import 'package:stream_chat/src/core/http/token_manager.dart'; +import 'package:test/test.dart'; + +import '../../mocks.dart'; + +void main() { + Response successResponse(String path) => Response( + requestOptions: RequestOptions(path: path), + statusCode: 200, + ); + + DioError throwableError( + String path, { + StreamChatNetworkError? error, + bool streamChatDioError = false, + }) { + if (streamChatDioError) assert(error != null, ''); + final options = RequestOptions(path: path); + final data = ErrorResponse() + ..code = error?.code + ..statusCode = error?.statusCode + ..message = error?.message; + DioError? dioError; + if (streamChatDioError) { + dioError = StreamChatDioError(error: error!, requestOptions: options); + } else { + dioError = DioError( + error: error, + requestOptions: options, + response: Response( + requestOptions: options, + statusCode: data.statusCode, + data: data.toJson(), + ), + ); + } + return dioError; + } + + test('AuthInterceptor should be added if tokenManager is provided', () { + const apiKey = 'api-key'; + final client = StreamHttpClient(apiKey, tokenManager: TokenManager()); + + expect(client.httpClient.interceptors.length, 1); + expect(client.httpClient.interceptors.first, isA()); + }); + + test( + '''connectionIdInterceptor should be added if connectionIdManager is provided''', + () { + const apiKey = 'api-key'; + final client = StreamHttpClient( + apiKey, + connectionIdManager: ConnectionIdManager(), + ); + + expect(client.httpClient.interceptors.length, 1); + expect( + client.httpClient.interceptors.first, + isA(), + ); + }, + ); + + test('loggingInterceptor should be added if logger is provided', () { + const apiKey = 'api-key'; + final client = StreamHttpClient( + apiKey, + logger: Logger('test-logger'), + ); + + expect(client.httpClient.interceptors.length, 1); + expect( + client.httpClient.interceptors.first, + isA(), + ); + }); + + test('loggingInterceptor should log requests', () async { + const apiKey = 'api-key'; + final logger = MockLogger(); + final client = StreamHttpClient(apiKey, logger: logger); + + try { + await client.get('path'); + } catch (_) {} + + verify(() => logger.info(any())).called(16); + }); + + test('loggingInterceptor should log error', () async { + const apiKey = 'api-key'; + final logger = MockLogger(); + final client = StreamHttpClient(apiKey, logger: logger); + + try { + await client.get('path'); + } catch (_) {} + + verify(() => logger.severe(any())).called(8); + }); + + test('`.lock` should lock the dio client', () async { + final client = StreamHttpClient('api-key'); + expect(client.httpClient.interceptors.requestLock.locked, isFalse); + client.lock(); + expect(client.httpClient.interceptors.requestLock.locked, isTrue); + }); + + test('`.unlock` should unlock the dio client', () async { + final client = StreamHttpClient('api-key'); + expect(client.httpClient.interceptors.requestLock.locked, isFalse); + client.lock(); + expect(client.httpClient.interceptors.requestLock.locked, isTrue); + client.unlock(); + expect(client.httpClient.interceptors.requestLock.locked, isFalse); + }); + + test('`.clear` should clear and unlock the dio client', () async { + final client = StreamHttpClient('api-key')..clear(); + expect(client.httpClient.interceptors.requestLock.locked, isFalse); + }); + + test('`.close` should close the dio client', () async { + final client = StreamHttpClient('api-key')..close(force: true); + try { + await client.get('path'); + } on StreamChatNetworkError catch (e) { + expect(e, isA()); + expect(e.message, "Dio can't establish new connection after closed."); + } + }); + + test('`.get` should return response successfully', () async { + final dio = MockDio(); + final client = StreamHttpClient('api-key', dio: dio); + + const path = 'test-get-api-path'; + when(() => dio.get( + path, + options: any(named: 'options'), + )).thenAnswer((_) async => successResponse(path)); + + final res = await client.get(path); + + expect(res, isNotNull); + expect(res.statusCode, 200); + expect(res.requestOptions.path, path); + + verify(() => dio.get( + path, + options: any(named: 'options'), + )).called(1); + verifyNoMoreInteractions(dio); + }); + + test('`.get` should throw an instance of `StreamChatNetworkError`', () async { + final dio = MockDio(); + final client = StreamHttpClient('api-key', dio: dio); + + const path = 'test-get-api-path'; + final error = throwableError( + path, + error: StreamChatNetworkError(ChatErrorCode.internalSystemError), + ); + when(() => dio.get( + path, + options: any(named: 'options'), + )).thenThrow(error); + + try { + await client.get(path); + } catch (e) { + expect(e, isA()); + expect(e, StreamChatNetworkError.fromDioError(error)); + } + + verify(() => dio.get( + path, + options: any(named: 'options'), + )).called(1); + verifyNoMoreInteractions(dio); + }); + + test('`.post` should return response successfully', () async { + final dio = MockDio(); + final client = StreamHttpClient('api-key', dio: dio); + + const path = 'test-post-api-path'; + when(() => dio.post( + path, + options: any(named: 'options'), + )).thenAnswer((_) async => successResponse(path)); + + final res = await client.post(path); + + expect(res, isNotNull); + expect(res.statusCode, 200); + expect(res.requestOptions.path, path); + + verify(() => dio.post( + path, + options: any(named: 'options'), + )).called(1); + verifyNoMoreInteractions(dio); + }); + + test( + '`.post` should throw an instance of `StreamChatNetworkError`', + () async { + final dio = MockDio(); + final client = StreamHttpClient('api-key', dio: dio); + + const path = 'test-post-api-path'; + final error = throwableError( + path, + error: StreamChatNetworkError(ChatErrorCode.internalSystemError), + ); + when(() => dio.post( + path, + options: any(named: 'options'), + )).thenThrow(error); + + try { + await client.post(path); + } catch (e) { + expect(e, isA()); + expect(e, StreamChatNetworkError.fromDioError(error)); + } + + verify(() => dio.post( + path, + options: any(named: 'options'), + )).called(1); + verifyNoMoreInteractions(dio); + }, + ); + + test('`.delete` should return response successfully', () async { + final dio = MockDio(); + final client = StreamHttpClient('api-key', dio: dio); + + const path = 'test-delete-api-path'; + when(() => dio.delete( + path, + options: any(named: 'options'), + )).thenAnswer((_) async => successResponse(path)); + + final res = await client.delete(path); + + expect(res, isNotNull); + expect(res.statusCode, 200); + expect(res.requestOptions.path, path); + + verify(() => dio.delete( + path, + options: any(named: 'options'), + )).called(1); + verifyNoMoreInteractions(dio); + }); + + test( + '`.delete` should throw an instance of `StreamChatNetworkError`', + () async { + final dio = MockDio(); + final client = StreamHttpClient('api-key', dio: dio); + + const path = 'test-delete-api-path'; + final error = throwableError( + path, + error: StreamChatNetworkError(ChatErrorCode.internalSystemError), + ); + when(() => dio.delete( + path, + options: any(named: 'options'), + )).thenThrow(error); + + try { + await client.delete(path); + } catch (e) { + expect(e, isA()); + expect(e, StreamChatNetworkError.fromDioError(error)); + } + + verify(() => dio.delete( + path, + options: any(named: 'options'), + )).called(1); + verifyNoMoreInteractions(dio); + }, + ); + + test('`.patch` should return response successfully', () async { + final dio = MockDio(); + final client = StreamHttpClient('api-key', dio: dio); + + const path = 'test-patch-api-path'; + when(() => dio.patch( + path, + options: any(named: 'options'), + )).thenAnswer((_) async => successResponse(path)); + + final res = await client.patch(path); + + expect(res, isNotNull); + expect(res.statusCode, 200); + expect(res.requestOptions.path, path); + + verify(() => dio.patch( + path, + options: any(named: 'options'), + )).called(1); + verifyNoMoreInteractions(dio); + }); + + test( + '`.patch` should throw an instance of `StreamChatNetworkError`', + () async { + final dio = MockDio(); + final client = StreamHttpClient('api-key', dio: dio); + + const path = 'test-patch-api-path'; + final error = throwableError( + path, + error: StreamChatNetworkError(ChatErrorCode.internalSystemError), + ); + when(() => dio.patch( + path, + options: any(named: 'options'), + )).thenThrow(error); + + try { + await client.patch(path); + } catch (e) { + expect(e, isA()); + expect(e, StreamChatNetworkError.fromDioError(error)); + } + + verify(() => dio.patch( + path, + options: any(named: 'options'), + )).called(1); + verifyNoMoreInteractions(dio); + }, + ); + + test('`.put` should return response successfully', () async { + final dio = MockDio(); + final client = StreamHttpClient('api-key', dio: dio); + + const path = 'test-put-api-path'; + when(() => dio.put( + path, + options: any(named: 'options'), + )).thenAnswer((_) async => successResponse(path)); + + final res = await client.put(path); + + expect(res, isNotNull); + expect(res.statusCode, 200); + expect(res.requestOptions.path, path); + + verify(() => dio.put( + path, + options: any(named: 'options'), + )).called(1); + verifyNoMoreInteractions(dio); + }); + + test( + '`.put` should throw an instance of `StreamChatNetworkError`', + () async { + final dio = MockDio(); + final client = StreamHttpClient('api-key', dio: dio); + + const path = 'test-put-api-path'; + final error = throwableError( + path, + error: StreamChatNetworkError(ChatErrorCode.internalSystemError), + ); + when(() => dio.put( + path, + options: any(named: 'options'), + )).thenThrow(error); + + try { + await client.put(path); + } catch (e) { + expect(e, isA()); + expect(e, StreamChatNetworkError.fromDioError(error)); + } + + verify(() => dio.put( + path, + options: any(named: 'options'), + )).called(1); + verifyNoMoreInteractions(dio); + }, + ); + + test('`.postFile` should return response successfully', () async { + final dio = MockDio(); + final client = StreamHttpClient('api-key', dio: dio); + + const path = 'test-delete-api-path'; + final file = MultipartFile.fromBytes([]); + + when(() => dio.post( + path, + data: any(named: 'data'), + options: any(named: 'options'), + )).thenAnswer((_) async => successResponse(path)); + + final res = await client.postFile(path, file); + + expect(res, isNotNull); + expect(res.statusCode, 200); + expect(res.requestOptions.path, path); + + verify(() => dio.post( + path, + data: any(named: 'data'), + options: any(named: 'options'), + )).called(1); + verifyNoMoreInteractions(dio); + }); + + test( + '`.postFile` should throw an instance of `StreamChatNetworkError`', + () async { + final dio = MockDio(); + final client = StreamHttpClient('api-key', dio: dio); + + const path = 'test-post-file-api-path'; + final file = MultipartFile.fromBytes([]); + + final error = throwableError( + path, + error: StreamChatNetworkError(ChatErrorCode.internalSystemError), + ); + when(() => dio.post( + path, + data: any(named: 'data'), + options: any(named: 'options'), + )).thenThrow(error); + + try { + await client.postFile(path, file); + } catch (e) { + expect(e, isA()); + expect(e, StreamChatNetworkError.fromDioError(error)); + } + + verify(() => dio.post( + path, + data: any(named: 'data'), + options: any(named: 'options'), + )).called(1); + verifyNoMoreInteractions(dio); + }, + ); + + test('`.request` should return response successfully', () async { + final dio = MockDio(); + final client = StreamHttpClient('api-key', dio: dio); + + const path = 'test-request-api-path'; + when(() => dio.request( + path, + options: any(named: 'options'), + )).thenAnswer((_) async => successResponse(path)); + + final res = await client.request(path); + + expect(res, isNotNull); + expect(res.statusCode, 200); + expect(res.requestOptions.path, path); + + verify(() => dio.request( + path, + options: any(named: 'options'), + )).called(1); + verifyNoMoreInteractions(dio); + }); + + test( + '`.request` should throw an instance of `StreamChatNetworkError`', + () async { + final dio = MockDio(); + final client = StreamHttpClient('api-key', dio: dio); + + const path = 'test-put-api-path'; + final error = throwableError( + path, + streamChatDioError: true, + error: StreamChatNetworkError(ChatErrorCode.internalSystemError), + ); + when(() => dio.request( + path, + options: any(named: 'options'), + )).thenThrow(error); + + try { + await client.request(path); + } catch (e) { + expect(e, isA()); + expect(e, error.error); + } + + verify(() => dio.request( + path, + options: any(named: 'options'), + )).called(1); + verifyNoMoreInteractions(dio); + }, + ); +} diff --git a/packages/stream_chat/test/src/core/http/token_manager_test.dart b/packages/stream_chat/test/src/core/http/token_manager_test.dart new file mode 100644 index 00000000..163bafff --- /dev/null +++ b/packages/stream_chat/test/src/core/http/token_manager_test.dart @@ -0,0 +1,141 @@ +import 'package:stream_chat/src/core/http/token.dart'; +import 'package:stream_chat/src/core/http/token_manager.dart'; +import 'package:test/test.dart'; + +void main() { + late TokenManager tokenManager; + + setUp(() { + tokenManager = TokenManager(); + }); + + tearDown(() { + tokenManager.reset(); + }); + + test('`setTokenOrProvider` should set token', () async { + expect(tokenManager.userId, isNull); + + const userId = 'test-user-id'; + final token = Token.development(userId); + final returnedToken = await tokenManager.setTokenOrProvider( + userId, + token: token, + ); + + expect(returnedToken, token); + expect(tokenManager.userId, userId); + expect(tokenManager.isStatic, isTrue); + }); + + test('`setTokenOrProvider` should set tokenProvider', () async { + expect(tokenManager.userId, isNull); + + const userId = 'test-user-id'; + Future tokenProvider(String userId) async => + Token.development(userId).rawValue; + final returnedToken = await tokenManager.setTokenOrProvider( + userId, + provider: tokenProvider, + ); + + expect(returnedToken, isNotNull); + expect(tokenManager.userId, userId); + expect(tokenManager.isStatic, isFalse); + }); + + test( + '''`setTokenOrProvider` should throw if both token and provider is not provided''', + () async { + expect(tokenManager.userId, isNull); + + const userId = 'test-user-id'; + try { + await tokenManager.setTokenOrProvider(userId); + } catch (e) { + expect(e, isA()); + } + }, + ); + + test( + '`setTokenOrProvider` should throw if both token and provider is provided', + () async { + expect(tokenManager.userId, isNull); + + const userId = 'test-user-id'; + final token = Token.development(userId); + Future tokenProvider(String userId) async => + Token.development(userId).rawValue; + try { + await tokenManager.setTokenOrProvider( + userId, + token: token, + provider: tokenProvider, + ); + } catch (e) { + expect(e, isA()); + } + }, + ); + + test( + '`.loadToken` should return token set via `setToken`', + () async { + const userId = 'test-user-id'; + final token = Token.development(userId); + await tokenManager.setTokenOrProvider(userId, token: token); + + final returnedToken = await tokenManager.loadToken(); + expect(returnedToken, token); + }, + ); + + test( + '`.loadToken` should return token set via `setProvider`', + () async { + const userId = 'test-user-id'; + final token = Token.development(userId); + Future tokenProvider(String userId) async => token.rawValue; + await tokenManager.setTokenOrProvider(userId, provider: tokenProvider); + + final returnedToken = await tokenManager.loadToken(); + expect(returnedToken, token); + }, + ); + + test( + '`.loadToken` should return refreshed token set via `setProvider`', + () async { + const userId = 'test-user-id'; + final token = Token.development(userId); + final refreshToken = Token.development(userId); + + var refresh = false; + + Future tokenProvider(String userId) async { + if (refresh) return refreshToken.rawValue; + return token.rawValue; + } + + await tokenManager.setTokenOrProvider(userId, provider: tokenProvider); + + final returnedToken = await tokenManager.loadToken(); + expect(returnedToken, token); + + refresh = true; + final returnedRefreshToken = await tokenManager.loadToken(refresh: true); + expect(returnedRefreshToken, refreshToken); + }, + ); + + test('`.reset` should reset the tokenManager', () async { + const userId = 'test-user-id'; + final token = Token.development(userId); + await tokenManager.setTokenOrProvider(userId, token: token); + expect(tokenManager.userId, userId); + + tokenManager.reset(); + expect(tokenManager.userId, isNull); + }); +} diff --git a/packages/stream_chat/test/src/core/http/token_test.dart b/packages/stream_chat/test/src/core/http/token_test.dart new file mode 100644 index 00000000..b448884e --- /dev/null +++ b/packages/stream_chat/test/src/core/http/token_test.dart @@ -0,0 +1,57 @@ +import 'package:stream_chat/src/core/http/token.dart'; +import 'package:stream_chat/stream_chat.dart'; +import 'package:test/test.dart'; + +void main() { + test('`.anonymous` should create anonymous token with passed userId', () { + const userId = 'test-user-id'; + final token = Token.anonymous(userId: userId); + expect(token, isNotNull); + expect(token.userId, userId); + expect(token.rawValue, isEmpty); + expect(token.authType, AuthType.anonymous); + expect(token.authType.raw, AuthType.anonymous.raw); + }); + + test('`.fromRawValue` should create token from rawValue', () { + const userId = 'test-user-id'; + final devToken = Token.development(userId); + final token = Token.fromRawValue(devToken.rawValue); + expect(token, devToken); + }); + + test('`.fromRawValue` should throw if does not contain `user_id`', () { + const badToken = 'bad-token-without-a-user-id'; + try { + Token.fromRawValue(badToken); + } catch (e) { + expect(e, isA()); + } + }); + + test('`.development` should create a dev-token with provided user-id', () { + const userId = 'test-user-id'; + final token = Token.development(userId); + expect(token, isNotNull); + expect(token.userId, userId); + expect(token.rawValue, isNotEmpty); + expect(token.authType, AuthType.jwt); + expect(token.authType.raw, AuthType.jwt.raw); + }); + + test( + '`.guest` should create a guest-token with provided user and provider', + () async { + final user = User(id: 'test-user-id'); + Future provider(User user) async => + Token.development(user.id).rawValue; + + final token = await Token.guest(user, provider); + expect(token, isNotNull); + expect(token.userId, user.id); + expect(token.rawValue, isNotEmpty); + expect(token.authType, AuthType.jwt); + expect(token.authType.raw, AuthType.jwt.raw); + }, + ); +} diff --git a/packages/stream_chat/test/src/models/action_test.dart b/packages/stream_chat/test/src/core/models/action_test.dart similarity index 72% rename from packages/stream_chat/test/src/models/action_test.dart rename to packages/stream_chat/test/src/core/models/action_test.dart index 4859fcbe..0cf1c996 100644 --- a/packages/stream_chat/test/src/models/action_test.dart +++ b/packages/stream_chat/test/src/core/models/action_test.dart @@ -1,20 +1,12 @@ -import 'dart:convert'; - +import 'package:stream_chat/src/core/models/action.dart'; import 'package:test/test.dart'; -import 'package:stream_chat/src/models/action.dart'; + +import '../../utils.dart'; void main() { group('src/models/action', () { - const jsonExample = r'''{ - "name": "name", - "style": "style", - "text": "text", - "type": "type", - "value": "value" - }'''; - test('should parse json correctly', () { - final action = Action.fromJson(json.decode(jsonExample)); + final action = Action.fromJson(jsonFixture('action.json')); expect(action.name, 'name'); expect(action.style, 'style'); expect(action.text, 'text'); diff --git a/packages/stream_chat/test/src/core/models/attachment_test.dart b/packages/stream_chat/test/src/core/models/attachment_test.dart new file mode 100644 index 00000000..37716656 --- /dev/null +++ b/packages/stream_chat/test/src/core/models/attachment_test.dart @@ -0,0 +1,45 @@ +import 'package:stream_chat/src/core/models/action.dart'; +import 'package:stream_chat/src/core/models/attachment.dart'; +import 'package:test/test.dart'; + +import '../../utils.dart'; + +void main() { + group('src/models/attachment', () { + test('should parse json correctly', () { + final attachment = Attachment.fromJson(jsonFixture('attachment.json')); + expect(attachment.type, 'giphy'); + expect(attachment.title, 'awesome'); + expect( + attachment.titleLink, + 'https://giphy.com/gifs/nrkp3-dance-happy-3o7TKnCdBx5cMg0qti', + ); + expect( + attachment.thumbUrl, + 'https://media0.giphy.com/media/3o7TKnCdBx5cMg0qti/giphy.gif', + ); + expect(attachment.actions, hasLength(3)); + expect(attachment.actions[0], isA()); + }); + + test('should serialize to json correctly', () { + final channel = Attachment( + type: 'image', + title: 'soo', + titleLink: + 'https://giphy.com/gifs/nrkp3-dance-happy-3o7TKnCdBx5cMg0qti', + ); + + expect( + channel.toJson(), + { + 'type': 'image', + 'title': 'soo', + 'title_link': + 'https://giphy.com/gifs/nrkp3-dance-happy-3o7TKnCdBx5cMg0qti', + 'actions': [], + }, + ); + }); + }); +} diff --git a/packages/stream_chat/test/src/core/models/channel_state_test.dart b/packages/stream_chat/test/src/core/models/channel_state_test.dart new file mode 100644 index 00000000..8bd17d46 --- /dev/null +++ b/packages/stream_chat/test/src/core/models/channel_state_test.dart @@ -0,0 +1,68 @@ +import 'package:stream_chat/src/core/models/channel_config.dart'; +import 'package:stream_chat/src/core/models/channel_state.dart'; +import 'package:stream_chat/src/core/models/command.dart'; +import 'package:stream_chat/src/core/models/message.dart'; +import 'package:stream_chat/src/core/models/user.dart'; +import 'package:stream_chat/stream_chat.dart'; +import 'package:test/test.dart'; + +import '../../utils.dart'; + +void main() { + group('src/models/channel_state', () { + test('should parse json correctly', () { + final channelState = + ChannelState.fromJson(jsonFixture('channel_state.json')); + expect(channelState.channel?.cid, 'team:dev'); + expect(channelState.channel?.id, 'dev'); + expect(channelState.channel?.team, 'test'); + expect(channelState.channel?.type, 'team'); + expect(channelState.channel?.config, isA()); + expect(channelState.channel?.config, isNotNull); + expect(channelState.channel?.config.commands, hasLength(1)); + expect(channelState.channel?.config.commands[0], isA()); + expect(channelState.channel?.lastMessageAt, + DateTime.parse('2020-01-30T13:43:41.062362Z')); + expect(channelState.channel?.createdAt, + DateTime.parse('2019-04-03T18:43:33.213373Z')); + expect(channelState.channel?.updatedAt, + DateTime.parse('2019-04-03T18:43:33.213374Z')); + expect(channelState.channel?.createdBy, isA()); + expect(channelState.channel?.frozen, true); + expect(channelState.channel?.extraData['example'], 1); + expect(channelState.channel?.extraData['name'], '#dev'); + expect( + channelState.channel?.extraData['image'], + 'https://cdn.chrisshort.net/testing-certificate-chains-in-go/GOPHER_MIC_DROP.png', + ); + expect(channelState.messages, hasLength(25)); + expect(channelState.messages[0], isA()); + expect(channelState.messages[0], isNotNull); + expect( + channelState.messages[0].createdAt, + DateTime.parse('2020-01-29T03:23:02.843948Z'), + ); + expect(channelState.messages[0].user, isA()); + expect(channelState.watcherCount, 5); + }); + + test('should serialize to json correctly', () { + final j = jsonFixture('channel_state.json'); + final channelState = ChannelState( + channel: ChannelModel.fromJson(j['channel']), + members: [], + messages: + (j['messages'] as List).map((m) => Message.fromJson(m)).toList(), + read: [], + watcherCount: 5, + pinnedMessages: [], + watchers: [], + ); + + expect( + channelState.toJson(), + jsonFixture('channel_state_to_json.json'), + ); + }); + }); +} diff --git a/packages/stream_chat/test/src/core/models/channel_test.dart b/packages/stream_chat/test/src/core/models/channel_test.dart new file mode 100644 index 00000000..ca734c44 --- /dev/null +++ b/packages/stream_chat/test/src/core/models/channel_test.dart @@ -0,0 +1,45 @@ +import 'package:stream_chat/src/core/models/channel_model.dart'; +import 'package:test/test.dart'; + +import '../../utils.dart'; + +void main() { + group('src/models/channel', () { + test('should parse json correctly', () { + final channel = ChannelModel.fromJson(jsonFixture('channel.json')); + expect(channel.id, equals('test')); + expect(channel.type, equals('livestream')); + expect(channel.cid, equals('livestream:test')); + expect(channel.extraData['cats'], equals(true)); + expect(channel.extraData['fruit'], equals(['bananas', 'apples'])); + }); + + test('should serialize to json correctly', () { + final channel = ChannelModel( + type: 'type', + id: 'id', + cid: 'a:a', + extraData: {'name': 'cool'}, + ); + + expect( + channel.toJson(), + {'id': 'id', 'type': 'type', 'frozen': false, 'name': 'cool'}, + ); + }); + + test('should serialize to json correctly when frozen is provided', () { + final channel = ChannelModel( + type: 'type', + id: 'id', + cid: 'a:a', + extraData: {'name': 'cool'}, + ); + + expect( + channel.toJson(), + {'id': 'id', 'type': 'type', 'name': 'cool', 'frozen': false}, + ); + }); + }); +} diff --git a/packages/stream_chat/test/src/models/command_test.dart b/packages/stream_chat/test/src/core/models/command_test.dart similarity index 56% rename from packages/stream_chat/test/src/models/command_test.dart rename to packages/stream_chat/test/src/core/models/command_test.dart index dc44f736..e8ffb81e 100644 --- a/packages/stream_chat/test/src/models/command_test.dart +++ b/packages/stream_chat/test/src/core/models/command_test.dart @@ -1,20 +1,12 @@ -import 'package:stream_chat/src/models/command.dart'; -import 'dart:convert'; - +import 'package:stream_chat/src/core/models/command.dart'; import 'package:test/test.dart'; +import '../../utils.dart'; + void main() { group('src/models/command', () { - const jsonExample = ''' - { - "name": "giphy", - "description": "Post a random gif to the channel", - "args": "[text]" - } - '''; - test('should parse json correctly', () { - final command = Command.fromJson(json.decode(jsonExample)); + final command = Command.fromJson(jsonFixture('command.json')); expect(command.name, 'giphy'); expect(command.description, 'Post a random gif to the channel'); expect(command.args, '[text]'); @@ -30,9 +22,9 @@ void main() { expect( command.toJson(), { - "name": "giphy", - "description": "Post a random gif to the channel", - "args": "[text]", + 'name': 'giphy', + 'description': 'Post a random gif to the channel', + 'args': '[text]', }, ); }); diff --git a/packages/stream_chat/test/src/models/device_test.dart b/packages/stream_chat/test/src/core/models/device_test.dart similarity index 67% rename from packages/stream_chat/test/src/models/device_test.dart rename to packages/stream_chat/test/src/core/models/device_test.dart index 3f8de16e..b3ac707b 100644 --- a/packages/stream_chat/test/src/models/device_test.dart +++ b/packages/stream_chat/test/src/core/models/device_test.dart @@ -1,17 +1,12 @@ -import 'dart:convert'; - +import 'package:stream_chat/src/core/models/device.dart'; import 'package:test/test.dart'; -import 'package:stream_chat/src/models/device.dart'; + +import '../../utils.dart'; void main() { group('src/models/device', () { - const jsonExample = r'''{ - "id": "device-id", - "push_provider": "push-provider" - }'''; - test('should parse json correctly', () { - final device = Device.fromJson(json.decode(jsonExample)); + final device = Device.fromJson(jsonFixture('device.json')); expect(device.id, 'device-id'); expect(device.pushProvider, 'push-provider'); }); diff --git a/packages/stream_chat/test/src/core/models/event_test.dart b/packages/stream_chat/test/src/core/models/event_test.dart new file mode 100644 index 00000000..0cd9a0ad --- /dev/null +++ b/packages/stream_chat/test/src/core/models/event_test.dart @@ -0,0 +1,106 @@ +import 'package:stream_chat/src/core/models/event.dart'; +import 'package:stream_chat/src/core/models/own_user.dart'; +import 'package:stream_chat/stream_chat.dart'; +import 'package:test/test.dart'; + +import '../../utils.dart'; + +void main() { + group('src/models/event', () { + test('should parse json correctly', () { + final event = Event.fromJson(jsonFixture('event.json')); + expect(event.type, 'type'); + expect(event.cid, 'cid'); + expect(event.connectionId, 'connectionId'); + expect(event.createdAt, isA()); + expect(event.me, isA()); + expect(event.user, isA()); + expect(event.isLocal, false); + }); + + test('should serialize to json correctly', () { + final event = Event( + user: User(id: 'id'), + type: 'type', + cid: 'cid', + connectionId: 'connectionId', + createdAt: DateTime.parse('2020-01-29T03:22:47.63613Z'), + me: OwnUser(id: 'id2'), + totalUnreadCount: 1, + unreadChannels: 1, + online: true, + ); + + expect( + event.toJson(), + { + 'type': 'type', + 'cid': 'cid', + 'connection_id': 'connectionId', + 'created_at': '2020-01-29T03:22:47.636130Z', + 'me': {'id': 'id2'}, + 'user': {'id': 'id'}, + 'reaction': null, + 'message': null, + 'channel': null, + 'total_unread_count': 1, + 'unread_channels': 1, + 'online': true, + 'member': null, + 'channel_id': null, + 'channel_type': null, + 'parent_id': null, + 'is_local': true, + }, + ); + }); + + test('copyWith', () { + final event = Event.fromJson(jsonFixture('event.json')); + var newEvent = event.copyWith(); + expect(newEvent.type, 'type'); + expect(newEvent.cid, 'cid'); + expect(newEvent.connectionId, 'connectionId'); + expect(newEvent.createdAt, isA()); + expect(newEvent.me, isA()); + expect(newEvent.user, isA()); + expect(newEvent.isLocal, false); + + newEvent = event.copyWith( + type: 'test', + cid: 'test', + connectionId: 'test', + extraData: {}, + user: User(id: 'test'), + channelId: 'test', + totalUnreadCount: 2, + channelType: 'testtype', + ); + + expect(newEvent.channelType, 'testtype'); + expect(newEvent.totalUnreadCount, 2); + expect(newEvent.type, 'test'); + expect(newEvent.channelId, 'test'); + expect(newEvent.cid, 'test'); + expect(newEvent.connectionId, 'test'); + expect(newEvent.extraData, {}); + expect(newEvent.user!.id, 'test'); + }); + + group('eventChannel', () { + test('should parse json correctly', () { + final eventChannel = + EventChannel.fromJson(jsonFixture('event_channel.json')); + expect(eventChannel.type, 'messaging'); + expect(eventChannel.cid, + 'messaging:!members-v9ktpgmYysZA-MjgC-GMoeEawFHSelkOdTu6JGxFZWU'); + expect(eventChannel.createdBy!.id, 'super-band-9'); + expect(eventChannel.frozen, false); + expect(eventChannel.members!.length, 2); + expect(eventChannel.memberCount, 2); + expect(eventChannel.config, isA()); + expect(eventChannel.name, 'test'); + }); + }); + }); +} diff --git a/packages/stream_chat/test/src/core/models/filter_test.dart b/packages/stream_chat/test/src/core/models/filter_test.dart new file mode 100644 index 00000000..d60ebb50 --- /dev/null +++ b/packages/stream_chat/test/src/core/models/filter_test.dart @@ -0,0 +1,244 @@ +import 'dart:convert'; + +import 'package:test/test.dart'; +import 'package:stream_chat/src/core/models/filter.dart'; + +void main() { + group('operators', () { + test('equal', () { + const key = 'testKey'; + const value = 'testValue'; + final filter = Filter.equal(key, value); + expect(filter.key, key); + expect(filter.value, value); + expect(filter.operator, FilterOperator.equal.rawValue); + }); + + test('notEqual', () { + const key = 'testKey'; + const value = 'testValue'; + final filter = Filter.notEqual(key, value); + expect(filter.key, key); + expect(filter.value, value); + expect(filter.operator, FilterOperator.notEqual.rawValue); + }); + + test('greater', () { + const key = 'testKey'; + const value = 'testValue'; + final filter = Filter.greater(key, value); + expect(filter.key, key); + expect(filter.value, value); + expect(filter.operator, FilterOperator.greater.rawValue); + }); + + test('greaterOrEqual', () { + const key = 'testKey'; + const value = 'testValue'; + final filter = Filter.greaterOrEqual(key, value); + expect(filter.key, key); + expect(filter.value, value); + expect(filter.operator, FilterOperator.greaterOrEqual.rawValue); + }); + + test('less', () { + const key = 'testKey'; + const value = 'testValue'; + final filter = Filter.less(key, value); + expect(filter.key, key); + expect(filter.value, value); + expect(filter.operator, FilterOperator.less.rawValue); + }); + + test('lessOrEqual', () { + const key = 'testKey'; + const value = 'testValue'; + final filter = Filter.lessOrEqual(key, value); + expect(filter.key, key); + expect(filter.value, value); + expect(filter.operator, FilterOperator.lessOrEqual.rawValue); + }); + + test('in', () { + const key = 'testKey'; + const values = ['testValue']; + final filter = Filter.in_(key, values); + expect(filter.key, key); + expect(filter.value, values); + expect(filter.operator, FilterOperator.in_.rawValue); + }); + + test('in', () { + const key = 'testKey'; + const values = ['testValue']; + final filter = Filter.in_(key, values); + expect(filter.key, key); + expect(filter.value, values); + expect(filter.operator, FilterOperator.in_.rawValue); + }); + + test('notIn', () { + const key = 'testKey'; + const values = ['testValue']; + final filter = Filter.notIn(key, values); + expect(filter.key, key); + expect(filter.value, values); + expect(filter.operator, FilterOperator.notIn.rawValue); + }); + + test('query', () { + const key = 'testKey'; + const value = 'testQuery'; + final filter = Filter.query(key, value); + expect(filter.key, key); + expect(filter.value, value); + expect(filter.operator, FilterOperator.query.rawValue); + }); + + test('autoComplete', () { + const key = 'testKey'; + const value = 'testQuery'; + final filter = Filter.autoComplete(key, value); + expect(filter.key, key); + expect(filter.value, value); + expect(filter.operator, FilterOperator.autoComplete.rawValue); + }); + + test('exists', () { + const key = 'testKey'; + final filter = Filter.exists(key); + expect(filter.key, key); + expect(filter.value, isTrue); + expect(filter.operator, FilterOperator.exists.rawValue); + }); + + test('notExists', () { + const key = 'testKey'; + final filter = Filter.exists(key, exists: false); + expect(filter.key, key); + expect(filter.value, isFalse); + expect(filter.operator, FilterOperator.exists.rawValue); + }); + + test('custom', () { + const key = 'testKey'; + const value = 'testValue'; + const operator = '\$customOperator'; + const filter = Filter.custom(operator: operator, key: key, value: value); + expect(filter.key, key); + expect(filter.value, value); + expect(filter.operator, operator); + }); + + test('raw', () { + const value = { + 'test': ['a', 'b'], + }; + const filter = Filter.raw(value: value); + expect(filter.value, value); + }); + + group('groupedOperator', () { + final filter1 = Filter.equal('testKey', 'testValue'); + final filter2 = Filter.in_('testKey', const ['testValue']); + final filters = [filter1, filter2]; + + test('and', () { + final filter = Filter.and(filters); + expect(filter.key, isNull); + expect(filter.value, filters); + expect(filter.operator, FilterOperator.and.rawValue); + }); + + test('or', () { + final filter = Filter.or(filters); + expect(filter.key, isNull); + expect(filter.value, filters); + expect(filter.operator, FilterOperator.or.rawValue); + }); + + test('nor', () { + final filter = Filter.nor(filters); + expect(filter.key, isNull); + expect(filter.value, filters); + expect(filter.operator, FilterOperator.nor.rawValue); + }); + }); + }); + + group('encoding', () { + group('nonGroupedFilter', () { + test('simpleValue', () { + const key = 'testKey'; + const value = 'testValue'; + final filter = Filter.equal(key, value); + final encoded = json.encode(filter); + expect( + encoded, + '{"$key":{"${FilterOperator.equal.rawValue}":${json.encode(value)}}}', + ); + }); + test('listValue', () { + const key = 'testKey'; + const values = ['testValue']; + final filter = Filter.in_(key, values); + final encoded = json.encode(filter); + expect( + encoded, + '{"$key":{"${FilterOperator.in_.rawValue}":${json.encode(values)}}}', + ); + }); + + test('custom with no operator', () { + const key = 'testKey'; + const values = ['testValue']; + const filter = Filter.custom(key: key, value: values); + final encoded = json.encode(filter); + expect( + encoded, + '{"$key":${json.encode(values)}}', + ); + }); + + test('raw', () { + const value = { + 'test': ['a', 'b'], + }; + const filter = Filter.raw(value: value); + + final encoded = json.encode(filter); + expect( + encoded, + json.encode(value), + ); + }); + }); + + test('groupedFilter', () { + final filter1 = Filter.equal('testKey', 'testValue'); + final filter2 = Filter.in_('testKey', const ['testValue']); + final filters = [filter1, filter2]; + + final filter = Filter.and(filters); + final encoded = json.encode(filter); + expect( + encoded, + '{"${FilterOperator.and.rawValue}":${json.encode(filters)}}', + ); + }); + + group('equality', () { + test('simpleFilter', () { + final filter1 = Filter.equal('testKey', 'testValue'); + final filter2 = Filter.equal('testKey', 'testValue'); + expect(filter1, filter2); + }); + + test('groupedFilter', () { + final filter1 = Filter.and([Filter.equal('testKey', 'testValue')]); + final filter2 = Filter.and([Filter.equal('testKey', 'testValue')]); + expect(filter1, filter2); + }); + }); + }); +} diff --git a/packages/stream_chat/test/src/core/models/member_test.dart b/packages/stream_chat/test/src/core/models/member_test.dart new file mode 100644 index 00000000..4cd8efda --- /dev/null +++ b/packages/stream_chat/test/src/core/models/member_test.dart @@ -0,0 +1,17 @@ +import 'package:stream_chat/src/core/models/member.dart'; +import 'package:stream_chat/src/core/models/user.dart'; +import 'package:test/test.dart'; + +import '../../utils.dart'; + +void main() { + group('src/models/member', () { + test('should parse json correctly', () { + final member = Member.fromJson(jsonFixture('member.json')); + expect(member.user, isA()); + expect(member.role, 'member'); + expect(member.createdAt, DateTime.parse('2020-01-28T22:17:30.95443Z')); + expect(member.updatedAt, DateTime.parse('2020-01-28T22:17:30.95443Z')); + }); + }); +} diff --git a/packages/stream_chat/test/src/core/models/message_test.dart b/packages/stream_chat/test/src/core/models/message_test.dart new file mode 100644 index 00000000..a5571430 --- /dev/null +++ b/packages/stream_chat/test/src/core/models/message_test.dart @@ -0,0 +1,68 @@ +import 'package:stream_chat/src/core/models/attachment.dart'; +import 'package:stream_chat/src/core/models/message.dart'; +import 'package:stream_chat/src/core/models/reaction.dart'; +import 'package:stream_chat/src/core/models/user.dart'; +import 'package:test/test.dart'; + +import '../../utils.dart'; + +void main() { + group('src/models/message', () { + test('should parse json correctly', () { + final message = Message.fromJson(jsonFixture('message.json')); + expect(message.id, '4637f7e4-a06b-42db-ba5a-8d8270dd926f'); + expect(message.text, + 'https://giphy.com/gifs/the-lion-king-live-action-5zvN79uTGfLMOVfQaA'); + expect(message.type, 'regular'); + expect(message.user, isA()); + expect(message.silent, isA()); + expect(message.attachments, isA>()); + expect(message.latestReactions, isA>()); + expect(message.ownReactions, isA>()); + expect(message.reactionCounts, {'love': 1}); + expect(message.reactionScores, {'love': 1}); + expect(message.createdAt, DateTime.parse('2020-01-28T22:17:31.107978Z')); + expect(message.updatedAt, DateTime.parse('2020-01-28T22:17:31.130506Z')); + expect(message.mentionedUsers, isA>()); + expect(message.pinned, false); + expect(message.pinnedAt, null); + expect(message.pinExpires, null); + expect(message.pinnedBy, null); + }); + + test('should serialize to json correctly', () { + final message = Message( + id: '4637f7e4-a06b-42db-ba5a-8d8270dd926f', + text: + 'https://giphy.com/gifs/the-lion-king-live-action-5zvN79uTGfLMOVfQaA', + attachments: [ + Attachment.fromJson(const { + 'type': 'video', + 'author_name': 'GIPHY', + 'title': 'The Lion King Disney GIF - Find \u0026 Share on GIPHY', + 'title_link': + 'https://media.giphy.com/media/5zvN79uTGfLMOVfQaA/giphy.gif', + 'text': + '''Discover \u0026 share this Lion King Live Action GIF with everyone you know. GIPHY is how you search, share, discover, and create GIFs.''', + 'image_url': + 'https://media.giphy.com/media/5zvN79uTGfLMOVfQaA/giphy.gif', + 'thumb_url': + 'https://media.giphy.com/media/5zvN79uTGfLMOVfQaA/giphy.gif', + 'asset_url': + 'https://media.giphy.com/media/5zvN79uTGfLMOVfQaA/giphy.mp4', + 'og_scrape_url': + 'https://giphy.com/gifs/the-lion-king-live-action-5zvN79uTGfLMOVfQaA' + }) + ], + showInChannel: true, + parentId: 'parentId', + extraData: const {'hey': 'test'}, + ); + + expect( + message.toJson(), + jsonFixture('message_to_json.json'), + ); + }); + }); +} diff --git a/packages/stream_chat/test/src/core/models/mute_test.dart b/packages/stream_chat/test/src/core/models/mute_test.dart new file mode 100644 index 00000000..f1f7b606 --- /dev/null +++ b/packages/stream_chat/test/src/core/models/mute_test.dart @@ -0,0 +1,17 @@ +import 'package:stream_chat/src/core/models/channel_model.dart'; +import 'package:stream_chat/src/core/models/mute.dart'; +import 'package:stream_chat/src/core/models/user.dart'; +import 'package:test/test.dart'; + +import '../../utils.dart'; + +void main() { + group('src/models/mute', () { + test('should parse json correctly', () { + final mute = Mute.fromJson(jsonFixture('mute.json')); + expect(mute.channel, isA()); + expect(mute.user, isA()); + expect(mute.createdAt, DateTime.parse('2020-12-04T10:39:06.512021Z')); + }); + }); +} diff --git a/packages/stream_chat/test/src/core/models/own_user_test.dart b/packages/stream_chat/test/src/core/models/own_user_test.dart new file mode 100644 index 00000000..7aef859d --- /dev/null +++ b/packages/stream_chat/test/src/core/models/own_user_test.dart @@ -0,0 +1,83 @@ +import 'package:stream_chat/src/core/models/own_user.dart'; +import 'package:stream_chat/src/core/models/user.dart'; +import 'package:test/test.dart'; + +import '../../utils.dart'; + +void main() { + group('src/models/own_user', () { + test('should parse json correctly', () { + final ownUser = OwnUser.fromJson(jsonFixture('own_user.json')); + expect(ownUser.id, 'super-band-9'); + expect(ownUser.role, 'user'); + + expect(ownUser.createdAt, DateTime.parse('2020-03-03T16:48:28.853674Z')); + expect(ownUser.updatedAt, DateTime.parse('2021-05-26T03:22:20.296181Z')); + expect( + ownUser.lastActive, DateTime.parse('2021-06-16T11:59:59.003453014Z')); + expect(ownUser.banned, false); + expect(ownUser.online, true); + expect(ownUser.devices.length, 1); + expect(ownUser.mutes.length, 0); + expect(ownUser.channelMutes.length, 1); + expect(ownUser.totalUnreadCount, 0); + expect(ownUser.unreadChannels, 0); + expect(ownUser.extraData['image'], 'https://placehold.jp/150x150.png'); + expect(ownUser.extraData['name'], 'Proud darkness'); + expect(ownUser.extraData['username'], 'Rioland'); + }); + + test('should initialize a OwnUser from a User correctly', () { + final user = User.fromJson(jsonFixture('user.json')); + final ownUser = OwnUser.fromUser(user); + + expect(ownUser.id, user.id); + expect(ownUser.id, user.id); + expect(ownUser.role, user.role); + expect(ownUser.createdAt, user.createdAt); + expect(ownUser.updatedAt, user.updatedAt); + expect(ownUser.lastActive, user.lastActive); + expect(ownUser.online, user.online); + expect(ownUser.banned, user.banned); + expect(ownUser.extraData, user.extraData); + }); + + test('copyWith', () { + final user = OwnUser.fromJson(jsonFixture('own_user.json')); + var newUser = user.copyWith(); + + expect(newUser.id, user.id); + expect(newUser.role, user.role); + expect(newUser.name, user.name); + + newUser = user.copyWith( + id: 'test', + role: 'test', + extraData: { + 'name': 'test', + }, + ); + + expect(newUser.id, 'test'); + expect(newUser.role, 'test'); + expect(newUser.name, 'test'); + }); + + test('merge', () { + final user = OwnUser.fromJson(jsonFixture('own_user.json')); + final newUser = user.merge(OwnUser( + id: 'test', + role: 'test', + extraData: const { + 'name': 'test', + }, + banned: true, + )); + + expect(newUser.id, 'test'); + expect(newUser.role, 'test'); + expect(newUser.name, 'test'); + expect(newUser.banned, true); + }); + }); +} diff --git a/packages/stream_chat/test/src/core/models/reaction_test.dart b/packages/stream_chat/test/src/core/models/reaction_test.dart new file mode 100644 index 00000000..0891b548 --- /dev/null +++ b/packages/stream_chat/test/src/core/models/reaction_test.dart @@ -0,0 +1,119 @@ +import 'package:stream_chat/src/core/models/reaction.dart'; +import 'package:stream_chat/src/core/models/user.dart'; +import 'package:test/test.dart'; + +import '../../utils.dart'; + +void main() { + group('src/models/reaction', () { + test('should parse json correctly', () { + final reaction = Reaction.fromJson(jsonFixture('reaction.json')); + expect(reaction.messageId, '76cd8c82-b557-4e48-9d12-87995d3a0e04'); + expect(reaction.createdAt, DateTime.parse('2020-01-28T22:17:31.108742Z')); + expect(reaction.type, 'wow'); + expect( + reaction.user?.toJson(), + User(id: '2de0297c-f3f2-489d-b930-ef77342edccf', extraData: const { + 'image': 'https://randomuser.me/api/portraits/women/45.jpg', + 'name': 'Daisy Morgan' + }).toJson(), + ); + expect(reaction.score, 1); + expect(reaction.userId, '2de0297c-f3f2-489d-b930-ef77342edccf'); + expect(reaction.extraData, {'updated_at': '2020-01-28T22:17:31.108742Z'}); + }); + + test('should serialize to json correctly', () { + final reaction = Reaction( + messageId: '76cd8c82-b557-4e48-9d12-87995d3a0e04', + createdAt: DateTime.parse('2020-01-28T22:17:31.108742Z'), + type: 'wow', + user: + User(id: '2de0297c-f3f2-489d-b930-ef77342edccf', extraData: const { + 'image': 'https://randomuser.me/api/portraits/women/45.jpg', + 'name': 'Daisy Morgan' + }), + userId: '2de0297c-f3f2-489d-b930-ef77342edccf', + extraData: {'bananas': 'yes'}, + score: 1, + ); + + expect( + reaction.toJson(), + { + 'message_id': '76cd8c82-b557-4e48-9d12-87995d3a0e04', + 'type': 'wow', + 'score': 1, + 'bananas': 'yes', + }, + ); + }); + + test('copyWith', () { + final reaction = Reaction.fromJson(jsonFixture('reaction.json')); + var newReaction = reaction.copyWith(); + expect(newReaction.messageId, '76cd8c82-b557-4e48-9d12-87995d3a0e04'); + expect( + newReaction.createdAt, DateTime.parse('2020-01-28T22:17:31.108742Z')); + expect(newReaction.type, 'wow'); + expect( + newReaction.user?.toJson(), + User(id: '2de0297c-f3f2-489d-b930-ef77342edccf', extraData: const { + 'image': 'https://randomuser.me/api/portraits/women/45.jpg', + 'name': 'Daisy Morgan', + }).toJson(), + ); + expect(newReaction.score, 1); + expect(newReaction.userId, '2de0297c-f3f2-489d-b930-ef77342edccf'); + expect( + newReaction.extraData, {'updated_at': '2020-01-28T22:17:31.108742Z'}); + + newReaction = reaction.copyWith( + type: 'lol', + createdAt: DateTime.parse('2021-01-28T22:17:31.108742Z'), + extraData: {}, + messageId: 'test', + score: 2, + user: User(id: 'test'), + userId: 'test', + ); + + expect(newReaction.type, 'lol'); + expect( + newReaction.createdAt, + DateTime.parse('2021-01-28T22:17:31.108742Z'), + ); + expect(newReaction.extraData, {}); + expect(newReaction.messageId, 'test'); + expect(newReaction.score, 2); + expect(newReaction.user, User(id: 'test')); + expect(newReaction.userId, 'test'); + }); + + test('merge', () { + final reaction = Reaction.fromJson(jsonFixture('reaction.json')); + final newReaction = reaction.merge( + Reaction( + type: 'lol', + createdAt: DateTime.parse('2021-01-28T22:17:31.108742Z'), + extraData: {}, + messageId: 'test', + score: 2, + user: User(id: 'test'), + userId: 'test', + ), + ); + + expect(newReaction.type, 'lol'); + expect( + newReaction.createdAt, + DateTime.parse('2021-01-28T22:17:31.108742Z'), + ); + expect(newReaction.extraData, {}); + expect(newReaction.messageId, 'test'); + expect(newReaction.score, 2); + expect(newReaction.user, User(id: 'test')); + expect(newReaction.userId, 'test'); + }); + }); +} diff --git a/packages/stream_chat/test/src/core/models/read_test.dart b/packages/stream_chat/test/src/core/models/read_test.dart new file mode 100644 index 00000000..94a28f08 --- /dev/null +++ b/packages/stream_chat/test/src/core/models/read_test.dart @@ -0,0 +1,54 @@ +import 'package:stream_chat/src/core/models/read.dart'; +import 'package:stream_chat/src/core/models/user.dart'; +import 'package:test/test.dart'; + +import '../../utils.dart'; + +void main() { + group('src/models/read', () { + test('should parse json correctly', () { + final read = Read.fromJson(jsonFixture('read.json')); + expect(read.lastRead, DateTime.parse('2020-01-28T22:17:30.966485504Z')); + expect(read.user.id, 'bbb19d9a-ee50-45bc-84e5-0584e79d0c9e'); + expect(read.unreadMessages, 10); + }); + + test('should serialize to json correctly', () { + final read = Read( + lastRead: DateTime.parse('2020-01-28T22:17:30.966485504Z'), + user: User(id: 'bbb19d9a-ee50-45bc-84e5-0584e79d0c9e'), + unreadMessages: 10, + ); + + expect(read.toJson(), { + 'user': {'id': 'bbb19d9a-ee50-45bc-84e5-0584e79d0c9e'}, + 'last_read': '2020-01-28T22:17:30.966485Z', + 'unread_messages': 10, + }); + }); + + test('copyWith', () { + final read = Read.fromJson(jsonFixture('read.json')); + var newRead = read.copyWith(); + expect( + newRead.lastRead, + DateTime.parse('2020-01-28T22:17:30.966485504Z'), + ); + expect(newRead.user.id, 'bbb19d9a-ee50-45bc-84e5-0584e79d0c9e'); + expect(newRead.unreadMessages, 10); + + newRead = read.copyWith( + user: User(id: 'test'), + lastRead: DateTime.parse('2021-01-28T22:17:30.966485504Z'), + unreadMessages: 2, + ); + + expect( + newRead.lastRead, + DateTime.parse('2021-01-28T22:17:30.966485504Z'), + ); + expect(newRead.user.id, 'test'); + expect(newRead.unreadMessages, 2); + }); + }); +} diff --git a/packages/stream_chat/test/src/models/serialization_test.dart b/packages/stream_chat/test/src/core/models/serialization_test.dart similarity index 77% rename from packages/stream_chat/test/src/models/serialization_test.dart rename to packages/stream_chat/test/src/core/models/serialization_test.dart index e8a2de3f..605b5b58 100644 --- a/packages/stream_chat/test/src/models/serialization_test.dart +++ b/packages/stream_chat/test/src/core/models/serialization_test.dart @@ -1,5 +1,5 @@ import 'package:test/test.dart'; -import 'package:stream_chat/src/models/serialization.dart'; +import 'package:stream_chat/src/core/util/serializer.dart'; void main() { group('src/models/serialization', () { @@ -9,7 +9,7 @@ void main() { 'prop2': 123, 'prop3': true, }; - final result = Serialization.moveToExtraDataFromRoot(json, [ + final result = Serializer.moveToExtraDataFromRoot(json, [ 'prop1', 'prop2', ]); @@ -30,7 +30,7 @@ void main() { }); test('should have empty extraData', () { - final result = Serialization.moveToExtraDataFromRoot({ + final result = Serializer.moveToExtraDataFromRoot({ 'prop1': 'test', 'prop2': 123, 'prop3': true, @@ -49,12 +49,12 @@ void main() { }); test('should return null', () { - final result = Serialization.moveToExtraDataFromRoot(null, [ + final result = Serializer.moveToExtraDataFromRoot({}, [ 'prop1', 'prop2', ]); - expect(result, null); + expect(result, {'extra_data': {}}); }); }); } diff --git a/packages/stream_chat/test/src/core/models/user_test.dart b/packages/stream_chat/test/src/core/models/user_test.dart new file mode 100644 index 00000000..ce488cab --- /dev/null +++ b/packages/stream_chat/test/src/core/models/user_test.dart @@ -0,0 +1,46 @@ +import 'package:stream_chat/src/core/models/user.dart'; +import 'package:test/test.dart'; + +import '../../utils.dart'; + +void main() { + group('src/models/user', () { + test('should parse json correctly', () { + final user = User.fromJson(jsonFixture('user.json')); + expect(user.id, 'bbb19d9a-ee50-45bc-84e5-0584e79d0c9e'); + expect(user.name, 'John'); + }); + + test('should serialize to json correctly', () { + final user = User( + id: 'bbb19d9a-ee50-45bc-84e5-0584e79d0c9e', + role: 'abc', + ); + + expect(user.toJson(), { + 'id': 'bbb19d9a-ee50-45bc-84e5-0584e79d0c9e', + }); + }); + + test('copyWith', () { + final user = User.fromJson(jsonFixture('user.json')); + var newUser = user.copyWith(); + + expect(newUser.id, user.id); + expect(newUser.role, user.role); + expect(newUser.name, user.name); + + newUser = user.copyWith( + id: 'test', + role: 'test', + extraData: { + 'name': 'test', + }, + ); + + expect(newUser.id, 'test'); + expect(newUser.role, 'test'); + expect(newUser.name, 'test'); + }); + }); +} diff --git a/packages/stream_chat/test/src/core/platform_detector/platform_detector_test.dart b/packages/stream_chat/test/src/core/platform_detector/platform_detector_test.dart new file mode 100644 index 00000000..110fb64e --- /dev/null +++ b/packages/stream_chat/test/src/core/platform_detector/platform_detector_test.dart @@ -0,0 +1,25 @@ +@TestOn('linux') +import 'package:stream_chat/src/core/platform_detector/platform_detector.dart'; +import 'package:test/test.dart'; + +void main() { + test('`.type` should return current platform', () { + final type = CurrentPlatform.type; + expect(type, PlatformType.linux); + }); + + test('`.name` should return current platform name', () { + final name = CurrentPlatform.name; + expect(name, 'linux'); + }); + + test('flags', () { + expect(CurrentPlatform.isWeb, isFalse); + expect(CurrentPlatform.isIos, isFalse); + expect(CurrentPlatform.isLinux, isTrue); + expect(CurrentPlatform.isAndroid, isFalse); + expect(CurrentPlatform.isMacOS, isFalse); + expect(CurrentPlatform.isWindows, isFalse); + expect(CurrentPlatform.isFuchsia, isFalse); + }); +} diff --git a/packages/stream_chat/test/src/core/util/extension_test.dart b/packages/stream_chat/test/src/core/util/extension_test.dart new file mode 100644 index 00000000..98f9f784 --- /dev/null +++ b/packages/stream_chat/test/src/core/util/extension_test.dart @@ -0,0 +1,48 @@ +import 'package:test/test.dart'; +import 'package:stream_chat/src/core/util/extension.dart'; + +void main() { + test('`.withNullifyer` converts the type into non-nullable', () { + final items = ['A', 'B', null, 'D']; + expect(items, isA>()); + expect(items.length, 4); + + final nullifiedItems = items.withNullifyer; + expect(nullifiedItems, isA>()); + expect(nullifiedItems.length, 3); + }); + + test('`.nullProtected should remove all the null keys, value`', () { + final map = {'name': 'sahil', 'age': null, null: 'India'}; + expect(map, isA>()); + expect(map.length, 3); + + final nullProtectedMap = map.nullProtected; + expect(nullProtectedMap, isA>()); + expect(nullProtectedMap.length, 1); + }); + + group('mimeType', () { + test('should return null if `String` is not a filename', () { + const fileName = 'not-a-file-name'; + final mimeType = fileName.mimeType; + expect(mimeType, isNull); + }); + + test('should return mimeType if string is a filename', () { + const fileName = 'dummyFileName.jpeg'; + final mimeType = fileName.mimeType; + expect(mimeType, isNotNull); + expect(mimeType!.type, 'image'); + expect(mimeType.subtype, 'jpeg'); + }); + + test('should return `image/heic` if ends with `heic`', () { + const fileName = 'dummyFileName.heic'; + final mimeType = fileName.mimeType; + expect(mimeType, isNotNull); + expect(mimeType!.type, 'image'); + expect(mimeType.subtype, 'heic'); + }); + }); +} diff --git a/packages/stream_chat/test/src/core/util/serializer_test.dart b/packages/stream_chat/test/src/core/util/serializer_test.dart new file mode 100644 index 00000000..fbbb96a2 --- /dev/null +++ b/packages/stream_chat/test/src/core/util/serializer_test.dart @@ -0,0 +1,43 @@ +import 'package:stream_chat/src/core/util/serializer.dart'; +import 'package:test/test.dart'; + +void main() { + group('Serializer', () { + test('moveKeysToMapInPlace', () { + final serializer = Serializer.moveToExtraDataFromRoot( + { + 'test': 'test', + 'name': 'Sahil', + 'age': 22, + 'country': 'India', + }, + ['test'], + ); + expect(serializer, { + 'test': 'test', + 'extra_data': { + 'name': 'Sahil', + 'age': 22, + 'country': 'India', + } + }); + }); + + test('moveKeysToMapInPlace', () { + final serializer = Serializer.moveFromExtraDataToRoot({ + 'test': 'test', + 'extra_data': { + 'name': 'Sahil', + 'age': 22, + 'country': 'India', + } + }); + expect(serializer, { + 'test': 'test', + 'name': 'Sahil', + 'age': 22, + 'country': 'India', + }); + }); + }); +} diff --git a/packages/stream_chat/test/src/core/util/utils_test.dart b/packages/stream_chat/test/src/core/util/utils_test.dart new file mode 100644 index 00000000..5d4c272f --- /dev/null +++ b/packages/stream_chat/test/src/core/util/utils_test.dart @@ -0,0 +1,15 @@ +import 'package:stream_chat/src/core/util/utils.dart'; +import 'package:test/test.dart'; + +void main() { + test('should generate a `randomId` of length 33', () { + final id = randomId(size: 33); + expect(id.length, 33); + }); + + test('should `generateHash` for the passed objects', () { + final objects = ['Sahil', 23, 'Flutter Engineer', 'India']; + final hash = generateHash(objects); + expect(hash, 'WyJTYWhpbCIsMjMsIkZsdXR0ZXIgRW5naW5lZXIiLCJJbmRpYSJd'); + }); +} diff --git a/packages/stream_chat/test/src/db/chat_persistence_client_test.dart b/packages/stream_chat/test/src/db/chat_persistence_client_test.dart new file mode 100644 index 00000000..22aee1a9 --- /dev/null +++ b/packages/stream_chat/test/src/db/chat_persistence_client_test.dart @@ -0,0 +1,190 @@ +import 'package:stream_chat/src/core/api/requests.dart'; +import 'package:stream_chat/src/core/models/channel_model.dart'; +import 'package:stream_chat/src/core/models/channel_state.dart'; +import 'package:stream_chat/src/core/models/event.dart'; +import 'package:stream_chat/src/core/models/filter.dart'; +import 'package:stream_chat/src/core/models/member.dart'; +import 'package:stream_chat/src/core/models/message.dart'; +import 'package:stream_chat/src/core/models/reaction.dart'; +import 'package:stream_chat/src/core/models/read.dart'; +import 'package:stream_chat/src/core/models/user.dart'; +import 'package:stream_chat/src/db/chat_persistence_client.dart'; +import 'package:test/test.dart'; + +class TestPersistenceClient extends ChatPersistenceClient { + @override + Future connect(String userId) => throw UnimplementedError(); + + @override + Future deleteChannels(List cids) => throw UnimplementedError(); + + @override + Future deleteMembersByCids(List cids) => Future.value(); + + @override + Future deleteMessageByCids(List cids) => Future.value(); + + @override + Future deleteMessageByIds(List messageIds) => Future.value(); + + @override + Future deletePinnedMessageByCids(List cids) => Future.value(); + + @override + Future deletePinnedMessageByIds(List messageIds) => + Future.value(); + + @override + Future deleteReactionsByMessageId(List messageIds) => + Future.value(); + + @override + Future disconnect({bool flush = false}) => throw UnimplementedError(); + + @override + Future getChannelByCid(String cid) async => + ChannelModel(cid: cid); + + @override + Future> getChannelCids() => throw UnimplementedError(); + + @override + Future> getChannelStates( + {Filter? filter, + List>? sort, + PaginationParams? paginationParams}) => + throw UnimplementedError(); + + @override + Future>> getChannelThreads(String cid) => + throw UnimplementedError(); + + @override + Future getConnectionInfo() => throw UnimplementedError(); + + @override + Future getLastSyncAt() => throw UnimplementedError(); + + @override + Future> getMembersByCid(String cid) async => []; + + @override + Future> getMessagesByCid(String cid, + {PaginationParams? messagePagination}) async => + []; + + @override + Future> getPinnedMessagesByCid(String cid, + {PaginationParams? messagePagination}) async => + []; + + @override + Future> getReadsByCid(String cid) async => []; + + @override + Future> getReplies(String parentId, + {PaginationParams? options}) => + throw UnimplementedError(); + + @override + Future updateChannelQueries(Filter? filter, List cids, + {bool clearQueryCache = false}) => + throw UnimplementedError(); + + @override + Future updateChannels(List channels) => Future.value(); + + @override + Future updateConnectionInfo(Event event) => throw UnimplementedError(); + + @override + Future updateLastSyncAt(DateTime lastSyncAt) => + throw UnimplementedError(); + + @override + Future updateMembers(String cid, List members) => + Future.value(); + + @override + Future updateMessages(String cid, List messages) => + Future.value(); + + @override + Future updatePinnedMessages(String cid, List messages) => + Future.value(); + + @override + Future updateReactions(List reactions) => Future.value(); + + @override + Future updateReads(String cid, List reads) => Future.value(); + + @override + Future updateUsers(List users) => Future.value(); +} + +void main() { + group('chatPersistenceClient', () { + final persistenceClient = TestPersistenceClient(); + + test('deleteMessageById', () { + const messageId = 'message-id'; + persistenceClient.deleteMessageById(messageId); + }); + + test('deleteMessageByCid', () { + const messageId = 'message-id'; + persistenceClient.deleteMessageByCid(messageId); + }); + + test('deletePinnedMessageById', () { + const messageId = 'message-id'; + persistenceClient.deletePinnedMessageById(messageId); + }); + + test('deletePinnedMessageByCid', () { + const messageId = 'message-id'; + persistenceClient.deletePinnedMessageByCid(messageId); + }); + + test('getChannelStateByCid', () async { + const cid = 'test:cid'; + final channelState = await persistenceClient.getChannelStateByCid(cid); + expect(channelState, isNotNull); + }); + + test('updateChannelState', () async { + final channelState = ChannelState(); + persistenceClient.updateChannelState(channelState); + }); + + test('updateChannelStates', () async { + const cid = 'test:cid'; + final user = User(id: 'test-user-id'); + final channelState = ChannelState( + channel: ChannelModel(cid: cid, createdBy: user), + messages: [ + Message( + id: 'test-message', + text: 'test-message', + user: user, + ownReactions: [Reaction(type: 'test', user: user)], + latestReactions: [Reaction(type: 'test', user: user)], + ) + ], + pinnedMessages: [ + Message( + id: 'test-message', + text: 'test-message', + user: user, + ownReactions: [Reaction(type: 'test', user: user)], + latestReactions: [Reaction(type: 'test', user: user)], + ) + ], + read: [Read(lastRead: DateTime.now(), user: user)], + members: [Member(user: user)], + ); + persistenceClient.updateChannelStates([channelState]); + }); + }); +} diff --git a/packages/stream_chat/test/src/fakes.dart b/packages/stream_chat/test/src/fakes.dart new file mode 100644 index 00000000..15d5991c --- /dev/null +++ b/packages/stream_chat/test/src/fakes.dart @@ -0,0 +1,187 @@ +import 'dart:async'; + +import 'package:dio/dio.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:rxdart/rxdart.dart'; +import 'package:stream_chat/src/core/api/channel_api.dart'; +import 'package:stream_chat/src/core/api/device_api.dart'; +import 'package:stream_chat/src/core/api/general_api.dart'; +import 'package:stream_chat/src/core/api/message_api.dart'; +import 'package:stream_chat/src/core/api/moderation_api.dart'; +import 'package:stream_chat/src/core/api/stream_chat_api.dart'; +import 'package:stream_chat/src/core/api/user_api.dart'; +import 'package:stream_chat/src/core/api/guest_api.dart'; +import 'package:stream_chat/src/core/http/token.dart'; +import 'package:stream_chat/src/core/http/token_manager.dart'; +import 'package:stream_chat/src/ws/websocket.dart'; +import 'package:stream_chat/stream_chat.dart'; + +import 'mocks.dart'; + +class FakeTokenManager extends Fake implements TokenManager { + final token = Token.development('test-user-id'); + + @override + bool get isStatic => true; + + @override + String? get userId => token.userId; + + @override + Future loadToken({bool refresh = false}) async => token; + + @override + Future setTokenOrProvider( + String userId, { + Token? token, + TokenProvider? provider, + }) async => + this.token; + + @override + void reset() {} +} + +class FakeMultiPartFile extends Fake implements MultipartFile {} + +class FakeChatApi extends Fake implements StreamChatApi { + UserApi? _user; + + @override + UserApi get user => _user ??= MockUserApi(); + + GuestApi? _guest; + + @override + GuestApi get guest => _guest ??= MockGuestApi(); + + MessageApi? _message; + + @override + MessageApi get message => _message ??= MockMessageApi(); + + ChannelApi? _channel; + + @override + ChannelApi get channel => _channel ??= MockChannelApi(); + + DeviceApi? _device; + + @override + DeviceApi get device => _device ??= MockDeviceApi(); + + ModerationApi? _moderation; + + @override + ModerationApi get moderation => _moderation ??= MockModerationApi(); + + GeneralApi? _general; + + @override + GeneralApi get general => _general ??= MockGeneralApi(); + + AttachmentFileUploader? _fileUploader; + + @override + AttachmentFileUploader get fileUploader => + _fileUploader ??= MockAttachmentFileUploader(); +} + +class FakeClientState extends Fake implements ClientState { + @override + OwnUser? get user => OwnUser(id: 'test-user-id'); + + @override + int totalUnreadCount = 0; +} + +class FakeMessage extends Fake implements Message {} + +class FakeAttachmentFile extends Fake implements AttachmentFile {} + +class FakeEvent extends Fake implements Event {} + +class FakeUser extends Fake implements User {} + +class FakeWebSocket extends Fake implements WebSocket { + BehaviorSubject? _connectionStatusController; + + BehaviorSubject get connectionStatusController => + _connectionStatusController ??= + BehaviorSubject.seeded(ConnectionStatus.disconnected); + + set connectionStatus(ConnectionStatus value) { + connectionStatusController.add(value); + } + + @override + ConnectionStatus get connectionStatus => connectionStatusController.value; + + @override + Stream get connectionStatusStream => + connectionStatusController.stream; + + @override + Completer? connectionCompleter; + + @override + Future connect(User user) async { + connectionStatus = ConnectionStatus.connecting; + final event = Event( + type: EventType.healthCheck, + connectionId: 'fake-connection-id', + me: OwnUser.fromUser(user), + ); + connectionCompleter = Completer()..complete(event); + connectionStatus = ConnectionStatus.connected; + return connectionCompleter!.future; + } + + @override + void disconnect() { + connectionStatus = ConnectionStatus.disconnected; + connectionCompleter = null; + _connectionStatusController?.close(); + _connectionStatusController = null; + } +} + +class FakeWebSocketWithConnectionError extends Fake implements WebSocket { + BehaviorSubject? _connectionStatusController; + + BehaviorSubject get connectionStatusController => + _connectionStatusController ??= + BehaviorSubject.seeded(ConnectionStatus.disconnected); + + set connectionStatus(ConnectionStatus value) { + connectionStatusController.add(value); + } + + @override + ConnectionStatus get connectionStatus => connectionStatusController.value; + + @override + Stream get connectionStatusStream => + connectionStatusController.stream; + + @override + Completer? connectionCompleter; + + @override + Future connect(User user) async { + connectionStatus = ConnectionStatus.connecting; + const error = StreamWebSocketError('Error Connecting'); + connectionCompleter = Completer()..completeError(error); + return connectionCompleter!.future; + } + + @override + void disconnect() { + connectionStatus = ConnectionStatus.disconnected; + connectionCompleter = null; + _connectionStatusController?.close(); + _connectionStatusController = null; + } +} + +class FakeChannelState extends Fake implements ChannelState {} diff --git a/packages/stream_chat/test/src/matchers.dart b/packages/stream_chat/test/src/matchers.dart new file mode 100644 index 00000000..27cfa43d --- /dev/null +++ b/packages/stream_chat/test/src/matchers.dart @@ -0,0 +1,133 @@ +import 'package:collection/collection.dart'; +import 'package:dio/dio.dart' show MultipartFile; +import 'package:stream_chat/src/client/channel.dart'; +import 'package:stream_chat/src/core/models/channel_state.dart'; +import 'package:stream_chat/src/core/models/event.dart'; +import 'package:stream_chat/src/core/models/message.dart'; +import 'package:stream_chat/src/core/models/user.dart'; +import 'package:test/test.dart'; + +Matcher isSameMultipartFileAs(MultipartFile targetFile) => + _IsSameMultipartFileAs(targetFile: targetFile); + +class _IsSameMultipartFileAs extends Matcher { + const _IsSameMultipartFileAs({required this.targetFile}); + + final MultipartFile targetFile; + + @override + Description describe(Description description) => + description.add('is same multipartFile as $targetFile'); + + @override + bool matches(covariant MultipartFile file, Map matchState) => + file.length == targetFile.length; +} + +Matcher isSameEventAs(Event targetEvent) => + _IsSameEventAs(targetEvent: targetEvent); + +class _IsSameEventAs extends Matcher { + const _IsSameEventAs({required this.targetEvent}); + + final Event targetEvent; + + @override + Description describe(Description description) => + description.add('is same event as $targetEvent'); + + @override + bool matches(covariant Event event, Map matchState) => + event.type == targetEvent.type; +} + +Matcher isSameMessageAs( + Message targetMessage, { + bool matchText = false, + bool matchReactions = false, + bool matchSendingStatus = false, +}) => + _IsSameMessageAs( + targetMessage: targetMessage, + matchText: matchText, + matchReactions: matchReactions, + matchSendingStatus: matchSendingStatus, + ); + +class _IsSameMessageAs extends Matcher { + const _IsSameMessageAs({ + required this.targetMessage, + this.matchText = false, + this.matchReactions = false, + this.matchSendingStatus = false, + }); + + final Message targetMessage; + final bool matchText; + final bool matchReactions; + final bool matchSendingStatus; + + @override + Description describe(Description description) => + description.add('is same message as $targetMessage'); + + @override + bool matches(covariant Message message, Map matchState) { + var matches = message.id == targetMessage.id; + if (matchText) { + matches &= message.text == targetMessage.text; + } + if (matchSendingStatus) { + matches &= message.status == targetMessage.status; + } + if (matchReactions) { + matches &= const ListEquality().equals( + message.ownReactions + ?.map((it) => '${it.type}-${it.messageId}') + .toList(), + targetMessage.ownReactions + ?.map((it) => '${it.type}-${it.messageId}') + .toList()); + matches &= const ListEquality().equals( + message.latestReactions + ?.map((it) => '${it.type}-${it.messageId}') + .toList(), + targetMessage.latestReactions + ?.map((it) => '${it.type}-${it.messageId}') + .toList()); + } + return matches; + } +} + +Matcher isSameUserAs(User targetUser) => _IsSameUserAs(targetUser: targetUser); + +class _IsSameUserAs extends Matcher { + const _IsSameUserAs({required this.targetUser}); + + final User targetUser; + + @override + Description describe(Description description) => + description.add('is same user as $targetUser'); + + @override + bool matches(covariant User user, Map matchState) => user.id == targetUser.id; +} + +Matcher isCorrectChannelFor(ChannelState channelState) => + _IsCorrectChannelFor(channelState: channelState); + +class _IsCorrectChannelFor extends Matcher { + const _IsCorrectChannelFor({required this.channelState}); + + final ChannelState channelState; + + @override + Description describe(Description description) => + description.add('is correct channel for $channelState'); + + @override + bool matches(covariant Channel channel, Map matchState) => + channel.cid == channelState.channel?.cid; +} diff --git a/packages/stream_chat/test/src/mocks.dart b/packages/stream_chat/test/src/mocks.dart new file mode 100644 index 00000000..44078519 --- /dev/null +++ b/packages/stream_chat/test/src/mocks.dart @@ -0,0 +1,112 @@ +import 'package:dio/dio.dart'; +import 'package:logging/logging.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:stream_chat/src/client/channel.dart'; +import 'package:stream_chat/src/client/client.dart'; +import 'package:stream_chat/src/core/api/attachment_file_uploader.dart'; +import 'package:stream_chat/src/core/api/channel_api.dart'; +import 'package:stream_chat/src/core/api/device_api.dart'; +import 'package:stream_chat/src/core/api/general_api.dart'; +import 'package:stream_chat/src/core/api/guest_api.dart'; +import 'package:stream_chat/src/core/api/message_api.dart'; +import 'package:stream_chat/src/core/api/moderation_api.dart'; +import 'package:stream_chat/src/core/api/user_api.dart'; +import 'package:stream_chat/src/core/http/connection_id_manager.dart'; +import 'package:stream_chat/src/core/http/stream_http_client.dart'; +import 'package:stream_chat/src/core/http/token_manager.dart'; +import 'package:stream_chat/src/core/models/channel_config.dart'; +import 'package:stream_chat/src/db/chat_persistence_client.dart'; +import 'package:stream_chat/src/ws/websocket.dart'; +import 'package:web_socket_channel/web_socket_channel.dart'; + +class MockWebSocketChannel extends Mock implements WebSocketChannel {} + +class MockWebSocketSink extends Mock implements WebSocketSink {} + +class MockDio extends Mock implements Dio { + BaseOptions? _options; + + @override + BaseOptions get options => _options ??= BaseOptions(); + + Interceptors? _interceptors; + + @override + Interceptors get interceptors => _interceptors ??= Interceptors(); +} + +class MockLogger extends Mock implements Logger { + @override + Level get level => Level.ALL; +} + +class MockHttpClient extends Mock implements StreamHttpClient {} + +class MockTokenManager extends Mock implements TokenManager {} + +class MockConnectionIdManager extends Mock implements ConnectionIdManager {} + +class MockUserApi extends Mock implements UserApi {} + +class MockGuestApi extends Mock implements GuestApi {} + +class MockMessageApi extends Mock implements MessageApi {} + +class MockChannelApi extends Mock implements ChannelApi {} + +class MockDeviceApi extends Mock implements DeviceApi {} + +class MockModerationApi extends Mock implements ModerationApi {} + +class MockGeneralApi extends Mock implements GeneralApi {} + +class MockAttachmentFileUploader extends Mock + implements AttachmentFileUploader {} + +class MockPersistenceClient extends Mock implements ChatPersistenceClient { + @override + Future connect(String userId) => Future.value(); + + @override + Future disconnect({bool flush = false}) => Future.value(); +} + +class MockStreamChatClient extends Mock implements StreamChatClient { + @override + bool get persistenceEnabled => false; +} + +class MockStreamChatClientWithPersistence extends Mock + implements StreamChatClient { + ChatPersistenceClient? _persistenceClient; + + @override + ChatPersistenceClient get chatPersistenceClient => + _persistenceClient ??= MockPersistenceClient(); + + @override + bool get persistenceEnabled => true; +} + +class MockChannelConfig extends Mock implements ChannelConfig {} + +class MockRetryQueueChannel extends Mock implements Channel { + final channelId = 'test-channel-id'; + final channelType = 'test-channel-type'; + + @override + String? get id => channelId; + + @override + String get type => channelType; + + @override + String? get cid => '$channelType:$channelId'; + + StreamChatClient? _client; + + @override + StreamChatClient get client => _client ??= MockStreamChatClient(); +} + +class MockWebSocket extends Mock implements WebSocket {} diff --git a/packages/stream_chat/test/src/models/attachment_test.dart b/packages/stream_chat/test/src/models/attachment_test.dart deleted file mode 100644 index b15458ad..00000000 --- a/packages/stream_chat/test/src/models/attachment_test.dart +++ /dev/null @@ -1,69 +0,0 @@ -import 'package:stream_chat/src/models/attachment.dart'; -import 'package:stream_chat/src/models/action.dart'; -import 'dart:convert'; - -import 'package:test/test.dart'; - -void main() { - group('src/models/attachment', () { - const jsonExample = r'''{ - "type": "giphy", - "title": "awesome", - "title_link": "https://giphy.com/gifs/nrkp3-dance-happy-3o7TKnCdBx5cMg0qti", - "thumb_url": "https://media0.giphy.com/media/3o7TKnCdBx5cMg0qti/giphy.gif", - "actions": [ - { - "name": "image_action", - "text": "Send", - "style": "primary", - "type": "button", - "value": "send" - }, - { - "name": "image_action", - "text": "Shuffle", - "style": "default", - "type": "button", - "value": "shuffle" - }, - { - "name": "image_action", - "text": "Cancel", - "style": "default", - "type": "button", - "value": "cancel" - } - ] -}'''; - - test('should parse json correctly', () { - final attachment = Attachment.fromJson(json.decode(jsonExample)); - expect(attachment.type, "giphy"); - expect(attachment.title, "awesome"); - expect(attachment.titleLink, - "https://giphy.com/gifs/nrkp3-dance-happy-3o7TKnCdBx5cMg0qti"); - expect(attachment.thumbUrl, - "https://media0.giphy.com/media/3o7TKnCdBx5cMg0qti/giphy.gif"); - expect(attachment.actions, hasLength(3)); - expect(attachment.actions[0], isA()); - }); - - test('should serialize to json correctly', () { - final channel = Attachment( - type: "image", - title: "soo", - titleLink: - "https://giphy.com/gifs/nrkp3-dance-happy-3o7TKnCdBx5cMg0qti"); - - expect( - channel.toJson(), - { - 'type': 'image', - 'title': 'soo', - 'title_link': - 'https://giphy.com/gifs/nrkp3-dance-happy-3o7TKnCdBx5cMg0qti' - }, - ); - }); - }); -} diff --git a/packages/stream_chat/test/src/models/channel_state_test.dart b/packages/stream_chat/test/src/models/channel_state_test.dart deleted file mode 100644 index 0a14cb76..00000000 --- a/packages/stream_chat/test/src/models/channel_state_test.dart +++ /dev/null @@ -1,1340 +0,0 @@ -import 'dart:convert'; - -import 'package:test/test.dart'; -import 'package:stream_chat/src/models/channel_config.dart'; -import 'package:stream_chat/src/models/channel_state.dart'; -import 'package:stream_chat/src/models/command.dart'; -import 'package:stream_chat/src/models/message.dart'; -import 'package:stream_chat/src/models/user.dart'; -import 'package:stream_chat/stream_chat.dart'; - -void main() { - group('src/models/channel_state', () { - const jsonExample = r'''{ - "channel": { - "id": "dev", - "type": "team", - "cid": "team:dev", - "last_message_at": "2020-01-30T13:43:41.062362Z", - "created_at": "2019-04-03T18:43:33.213373Z", - "updated_at": "2019-04-03T18:43:33.213374Z", - "team": "test", - "created_by": { - "id": "guido", - "role": "user", - "created_at": "2019-04-03T18:43:33.201036Z", - "updated_at": "2019-04-03T18:43:33.204713Z", - "banned": false, - "online": false, - "name": "Guido" - }, - "frozen": true, - "config": { - "created_at": "2019-11-07T22:29:26.776526Z", - "updated_at": "2019-11-07T22:29:48.286746Z", - "name": "team", - "typing_events": true, - "read_events": true, - "connect_events": true, - "search": true, - "reactions": true, - "replies": true, - "mutes": true, - "uploads": true, - "url_enrichment": true, - "message_retention": "infinite", - "max_message_length": 5000, - "automod": "disabled", - "automod_behavior": "flag", - "commands": [ - { - "name": "giphy", - "description": "Post a random gif to the channel", - "args": "[text]", - "set": "fun_set" - } - ] - }, - "name": "#dev", - "image": "https://cdn.chrisshort.net/testing-certificate-chains-in-go/GOPHER_MIC_DROP.png", - "example": 1 - }, - "messages": [ - { - "id": "dry-meadow-0-2b73cc8b-cd86-4a01-8d40-bd82ad07a030", - "text": "fasdfa", - "type": "regular", - "status": "SENT", - "silent": false, - "user": { - "id": "dry-meadow-0", - "role": "user", - "created_at": "2019-03-27T17:40:17.155892Z", - "updated_at": "2020-01-29T03:22:47.641589Z", - "last_active": "2020-01-29T03:22:47.63613Z", - "banned": false, - "online": false, - "image": "https://getstream.io/random_svg/?name=Dry+meadow", - "name": "Dry meadow" - }, - "attachments": [], - "latest_reactions": [], - "own_reactions": [], - "reaction_counts": {}, - "reaction_scores": {}, - "reply_count": 0, - "created_at": "2020-01-29T03:23:02.843948Z", - "updated_at": "2020-01-29T03:23:02.843949Z", - "mentioned_users": [], - "status": "SENT", - "silent": false, - "pinned": false, - "pinned_at": null, - "pin_expires": null, - "pinned_by": null - }, - { - "id": "dry-meadow-0-e8e74482-b4cd-48db-9d1e-30e6c191786f", - "text": "test message", - "type": "regular", - "user": { - "id": "dry-meadow-0", - "role": "user", - "created_at": "2019-03-27T17:40:17.155892Z", - "updated_at": "2020-01-29T03:22:47.641589Z", - "last_active": "2020-01-29T03:22:47.63613Z", - "banned": false, - "online": false, - "image": "https://getstream.io/random_svg/?name=Dry+meadow", - "name": "Dry meadow" - }, - "attachments": [], - "latest_reactions": [], - "own_reactions": [], - "reaction_counts": {}, - "reaction_scores": {}, - "reply_count": 0, - "created_at": "2020-01-29T03:23:07.981091Z", - "updated_at": "2020-01-29T03:23:07.981091Z", - "mentioned_users": [], - "status": "SENT", - "silent": false, - "pinned": false, - "pinned_at": null, - "pin_expires": null, - "pinned_by": null - }, - { - "id": "dry-meadow-0-53e6299f-9b97-4a9c-a27e-7e2dde49b7e0", - "text": "test message", - "type": "regular", - "user": { - "id": "dry-meadow-0", - "role": "user", - "created_at": "2019-03-27T17:40:17.155892Z", - "updated_at": "2020-01-29T03:22:47.641589Z", - "last_active": "2020-01-29T03:22:47.63613Z", - "banned": false, - "online": false, - "image": "https://getstream.io/random_svg/?name=Dry+meadow", - "name": "Dry meadow" - }, - "attachments": [], - "latest_reactions": [], - "own_reactions": [], - "reaction_counts": {}, - "reaction_scores": {}, - "reply_count": 0, - "created_at": "2020-01-29T03:23:11.568022Z", - "updated_at": "2020-01-29T03:23:11.568022Z", - "mentioned_users": [], - "status": "SENT", - "silent": false, - "pinned": false, - "pinned_at": null, - "pin_expires": null, - "pinned_by": null - }, - { - "id": "dry-meadow-0-80925be0-786e-40a5-b225-486518dafd35", - "text": "asdfadf", - "type": "regular", - "user": { - "id": "dry-meadow-0", - "role": "user", - "created_at": "2019-03-27T17:40:17.155892Z", - "updated_at": "2020-01-29T03:22:47.641589Z", - "last_active": "2020-01-29T03:22:47.63613Z", - "banned": false, - "online": false, - "image": "https://getstream.io/random_svg/?name=Dry+meadow", - "name": "Dry meadow" - }, - "attachments": [], - "latest_reactions": [], - "own_reactions": [], - "reaction_counts": {}, - "reaction_scores": {}, - "reply_count": 0, - "created_at": "2020-01-29T03:32:57.403566Z", - "updated_at": "2020-01-29T03:32:57.403566Z", - "mentioned_users": [], - "status": "SENT", - "silent": false, - "pinned": false, - "pinned_at": null, - "pin_expires": null, - "pinned_by": null - }, - { - "id": "dry-meadow-0-64d7970f-ede8-4b31-9738-1bc1756d2bfe", - "text": "test", - "type": "regular", - "user": { - "id": "dry-meadow-0", - "role": "user", - "created_at": "2019-03-27T17:40:17.155892Z", - "updated_at": "2020-01-29T03:22:47.641589Z", - "last_active": "2020-01-29T03:22:47.63613Z", - "banned": false, - "online": false, - "image": "https://getstream.io/random_svg/?name=Dry+meadow", - "name": "Dry meadow" - }, - "attachments": [], - "latest_reactions": [], - "own_reactions": [], - "reaction_counts": {}, - "reaction_scores": {}, - "reply_count": 0, - "created_at": "2020-01-29T03:33:35.294802Z", - "updated_at": "2020-01-29T03:33:35.294802Z", - "mentioned_users": [], - "status": "SENT", - "silent": false, - "pinned": false, - "pinned_at": null, - "pin_expires": null, - "pinned_by": null - }, - { - "id": "withered-cell-0-84cbd760-cf55-4f7e-9207-c5f66cccc6dc", - "text": "hi", - "type": "regular", - "user": { - "id": "withered-cell-0", - "role": "user", - "created_at": "2020-01-29T03:34:01.698106Z", - "updated_at": "2020-01-29T03:34:01.708808Z", - "last_active": "2020-01-29T03:34:01.70353Z", - "banned": false, - "online": false, - "name": "Withered cell", - "image": "https://getstream.io/random_svg/?name=Withered+cell" - }, - "attachments": [], - "latest_reactions": [], - "own_reactions": [], - "reaction_counts": {}, - "reaction_scores": {}, - "reply_count": 0, - "created_at": "2020-01-29T03:34:27.393296Z", - "updated_at": "2020-01-29T03:34:27.393296Z", - "mentioned_users": [], - "status": "SENT", - "silent": false, - "pinned": false, - "pinned_at": null, - "pin_expires": null, - "pinned_by": null - }, - { - "id": "dry-meadow-0-e9203588-43c3-40b1-91f7-f217fc42aa53", - "text": "fantastic", - "type": "regular", - "user": { - "id": "dry-meadow-0", - "role": "user", - "created_at": "2019-03-27T17:40:17.155892Z", - "updated_at": "2020-01-29T03:22:47.641589Z", - "last_active": "2020-01-29T03:22:47.63613Z", - "banned": false, - "online": false, - "image": "https://getstream.io/random_svg/?name=Dry+meadow", - "name": "Dry meadow" - }, - "attachments": [], - "latest_reactions": [], - "own_reactions": [], - "reaction_counts": {}, - "reaction_scores": {}, - "reply_count": 0, - "created_at": "2020-01-29T03:34:37.638376Z", - "updated_at": "2020-01-29T03:34:37.638376Z", - "mentioned_users": [], - "status": "SENT", - "silent": false, - "pinned": false, - "pinned_at": null, - "pin_expires": null, - "pinned_by": null - }, - { - "id": "withered-cell-0-7e3552d7-7a0d-45f2-a856-e91b23a7e240", - "text": "nice to meet you", - "type": "regular", - "user": { - "id": "withered-cell-0", - "role": "user", - "created_at": "2020-01-29T03:34:01.698106Z", - "updated_at": "2020-01-29T03:34:01.708808Z", - "last_active": "2020-01-29T03:34:01.70353Z", - "banned": false, - "online": false, - "image": "https://getstream.io/random_svg/?name=Withered+cell", - "name": "Withered cell" - }, - "attachments": [], - "latest_reactions": [], - "own_reactions": [], - "reaction_counts": {}, - "reaction_scores": {}, - "reply_count": 0, - "created_at": "2020-01-29T03:35:04.301566Z", - "updated_at": "2020-01-29T03:35:04.301566Z", - "mentioned_users": [], - "status": "SENT", - "silent": false, - "pinned": false, - "pinned_at": null, - "pin_expires": null, - "pinned_by": null - }, - { - "id": "dry-meadow-0-1ffeafd4-e4fc-4c84-9394-9d7cb10fff42", - "text": "hey", - "type": "regular", - "user": { - "id": "dry-meadow-0", - "role": "user", - "created_at": "2019-03-27T17:40:17.155892Z", - "updated_at": "2020-01-29T03:22:47.641589Z", - "last_active": "2020-01-29T03:22:47.63613Z", - "banned": false, - "online": false, - "image": "https://getstream.io/random_svg/?name=Dry+meadow", - "name": "Dry meadow" - }, - "attachments": [], - "latest_reactions": [], - "own_reactions": [], - "reaction_counts": {}, - "reaction_scores": {}, - "reply_count": 0, - "created_at": "2020-01-29T03:35:24.939084Z", - "updated_at": "2020-01-29T03:35:24.939085Z", - "mentioned_users": [], - "status": "SENT", - "silent": false, - "pinned": false, - "pinned_at": null, - "pin_expires": null, - "pinned_by": null - }, - { - "id": "dry-meadow-0-3f147324-12c8-4b41-9fb5-2db88d065efa", - "text": "hello, everyone", - "type": "regular", - "user": { - "id": "dry-meadow-0", - "role": "user", - "created_at": "2019-03-27T17:40:17.155892Z", - "updated_at": "2020-01-29T03:22:47.641589Z", - "last_active": "2020-01-29T03:22:47.63613Z", - "banned": false, - "online": false, - "name": "Dry meadow", - "image": "https://getstream.io/random_svg/?name=Dry+meadow" - }, - "attachments": [], - "latest_reactions": [], - "own_reactions": [], - "reaction_counts": {}, - "reaction_scores": {}, - "reply_count": 0, - "created_at": "2020-01-29T03:35:33.101566Z", - "updated_at": "2020-01-29T03:35:33.101566Z", - "mentioned_users": [], - "status": "SENT", - "silent": false, - "pinned": false, - "pinned_at": null, - "pin_expires": null, - "pinned_by": null - }, - { - "id": "dry-meadow-0-51a348ae-0c0a-44de-a556-eac7891c0cf0", - "text": "who is there?", - "type": "regular", - "user": { - "id": "dry-meadow-0", - "role": "user", - "created_at": "2019-03-27T17:40:17.155892Z", - "updated_at": "2020-01-29T03:22:47.641589Z", - "last_active": "2020-01-29T03:22:47.63613Z", - "banned": false, - "online": false, - "name": "Dry meadow", - "image": "https://getstream.io/random_svg/?name=Dry+meadow" - }, - "attachments": [], - "latest_reactions": [], - "own_reactions": [], - "reaction_counts": {}, - "reaction_scores": {}, - "reply_count": 0, - "created_at": "2020-01-29T03:35:45.458685Z", - "updated_at": "2020-01-29T03:35:45.458685Z", - "mentioned_users": [], - "status": "SENT", - "silent": false, - "pinned": false, - "pinned_at": null, - "pin_expires": null, - "pinned_by": null - }, - { - "id": "icy-recipe-7-a29e237b-8d81-4a97-9bc8-d42bca3f1356", - "text": "하이", - "type": "regular", - "user": { - "id": "icy-recipe-7", - "role": "user", - "created_at": "2020-01-21T11:36:22.284503Z", - "updated_at": "2020-01-29T07:01:59.69882Z", - "last_active": "2020-01-29T07:01:59.693378Z", - "banned": false, - "online": false, - "image": "https://getstream.io/random_svg/?name=Icy+recipe", - "name": "Icy recipe" - }, - "attachments": [], - "latest_reactions": [], - "own_reactions": [], - "reaction_counts": {}, - "reaction_scores": {}, - "reply_count": 0, - "created_at": "2020-01-29T07:02:11.535395Z", - "updated_at": "2020-01-29T07:02:11.535395Z", - "mentioned_users": [], - "status": "SENT", - "silent": false, - "pinned": false, - "pinned_at": null, - "pin_expires": null, - "pinned_by": null - }, - { - "id": "icy-recipe-7-935c396e-ddf8-4a9a-951c-0a12fa5bf055", - "text": "what are you doing?", - "type": "regular", - "user": { - "id": "icy-recipe-7", - "role": "user", - "created_at": "2020-01-21T11:36:22.284503Z", - "updated_at": "2020-01-29T07:01:59.69882Z", - "last_active": "2020-01-29T07:01:59.693378Z", - "banned": false, - "online": false, - "image": "https://getstream.io/random_svg/?name=Icy+recipe", - "name": "Icy recipe" - }, - "attachments": [], - "latest_reactions": [], - "own_reactions": [], - "reaction_counts": {}, - "reaction_scores": {}, - "reply_count": 0, - "created_at": "2020-01-29T07:02:22.485136Z", - "updated_at": "2020-01-29T07:02:22.485136Z", - "mentioned_users": [], - "status": "SENT", - "silent": false, - "pinned": false, - "pinned_at": null, - "pin_expires": null, - "pinned_by": null - }, - { - "id": "throbbing-boat-5-1e4d5730-5ff0-4d25-9948-9f34ffda43e4", - "text": "👍", - "type": "regular", - "user": { - "id": "throbbing-boat-5", - "role": "user", - "created_at": "2019-07-30T06:29:53.060413Z", - "updated_at": "2020-01-29T14:11:27.80176Z", - "last_active": "2020-01-29T14:11:27.7963Z", - "banned": false, - "online": false, - "image": "https://getstream.io/random_svg/?name=Throbbing+boat", - "name": "Throbbing boat" - }, - "attachments": [], - "latest_reactions": [], - "own_reactions": [], - "reaction_counts": {}, - "reaction_scores": {}, - "reply_count": 0, - "created_at": "2020-01-29T14:12:04.688552Z", - "updated_at": "2020-01-29T14:12:04.688552Z", - "mentioned_users": [], - "status": "SENT", - "silent": false, - "pinned": false, - "pinned_at": null, - "pin_expires": null, - "pinned_by": null - }, - { - "id": "snowy-credit-3-3e0c1a0d-d22f-42ee-b2a1-f9f49477bf21", - "text": "sdasas", - "type": "regular", - "user": { - "id": "snowy-credit-3", - "role": "user", - "created_at": "2020-01-29T15:29:03.693312Z", - "updated_at": "2020-01-29T15:29:03.702648Z", - "last_active": "2020-01-29T15:29:03.696144Z", - "banned": false, - "online": false, - "image": "https://getstream.io/random_svg/?name=Snowy+credit", - "name": "Snowy credit" - }, - "attachments": [], - "latest_reactions": [], - "own_reactions": [], - "reaction_counts": {}, - "reaction_scores": {}, - "reply_count": 0, - "created_at": "2020-01-29T15:29:36.011315Z", - "updated_at": "2020-01-29T15:29:36.011316Z", - "mentioned_users": [], - "status": "SENT", - "silent": false, - "pinned": false, - "pinned_at": null, - "pin_expires": null, - "pinned_by": null - }, - { - "id": "snowy-credit-3-3319537e-2d0e-4876-8170-a54f046e4b7d", - "text": "cjshsa", - "type": "regular", - "user": { - "id": "snowy-credit-3", - "role": "user", - "created_at": "2020-01-29T15:29:03.693312Z", - "updated_at": "2020-01-29T15:29:03.702648Z", - "last_active": "2020-01-29T15:29:03.696144Z", - "banned": false, - "online": false, - "image": "https://getstream.io/random_svg/?name=Snowy+credit", - "name": "Snowy credit" - }, - "attachments": [], - "latest_reactions": [], - "own_reactions": [], - "reaction_counts": {}, - "reaction_scores": {}, - "reply_count": 0, - "created_at": "2020-01-29T15:29:41.677819Z", - "updated_at": "2020-01-29T15:29:41.677819Z", - "mentioned_users": [], - "status": "SENT", - "silent": false, - "pinned": false, - "pinned_at": null, - "pin_expires": null, - "pinned_by": null - }, - { - "id": "snowy-credit-3-cfaf0b46-1daa-49c5-947c-b16d6697487d", - "text": "nhisagdhsadz", - "type": "regular", - "user": { - "id": "snowy-credit-3", - "role": "user", - "created_at": "2020-01-29T15:29:03.693312Z", - "updated_at": "2020-01-29T15:29:03.702648Z", - "last_active": "2020-01-29T15:29:03.696144Z", - "banned": false, - "online": false, - "image": "https://getstream.io/random_svg/?name=Snowy+credit", - "name": "Snowy credit" - }, - "attachments": [], - "latest_reactions": [], - "own_reactions": [], - "reaction_counts": {}, - "reaction_scores": {}, - "reply_count": 0, - "created_at": "2020-01-29T15:29:43.354177Z", - "updated_at": "2020-01-29T15:29:43.354177Z", - "mentioned_users": [], - "status": "SENT", - "silent": false, - "pinned": false, - "pinned_at": null, - "pin_expires": null, - "pinned_by": null - }, - { - "id": "snowy-credit-3-cebe25a7-a3a3-49fc-9919-91c6725e81f3", - "text": "hvadhsahzd", - "type": "regular", - "user": { - "id": "snowy-credit-3", - "role": "user", - "created_at": "2020-01-29T15:29:03.693312Z", - "updated_at": "2020-01-29T15:29:03.702648Z", - "last_active": "2020-01-29T15:29:03.696144Z", - "banned": false, - "online": false, - "image": "https://getstream.io/random_svg/?name=Snowy+credit", - "name": "Snowy credit" - }, - "attachments": [], - "latest_reactions": [], - "own_reactions": [], - "reaction_counts": {}, - "reaction_scores": {}, - "reply_count": 0, - "created_at": "2020-01-29T15:29:44.754713Z", - "updated_at": "2020-01-29T15:29:44.754713Z", - "mentioned_users": [], - "status": "SENT", - "silent": false, - "pinned": false, - "pinned_at": null, - "pin_expires": null, - "pinned_by": null - }, - { - "id": "divine-glade-9-0cea9262-5766-48e9-8b22-311870aed3bf", - "text": "hello", - "type": "regular", - "user": { - "id": "divine-glade-9", - "role": "user", - "created_at": "2020-01-29T17:02:18.312524Z", - "updated_at": "2020-01-29T17:02:18.320187Z", - "last_active": "2020-01-29T17:02:18.315074Z", - "banned": false, - "online": false, - "image": "https://getstream.io/random_svg/?name=Divine+glade", - "name": "Divine glade" - }, - "attachments": [], - "latest_reactions": [], - "own_reactions": [], - "reaction_counts": {}, - "reaction_scores": {}, - "reply_count": 0, - "created_at": "2020-01-29T17:02:36.933852Z", - "updated_at": "2020-01-29T17:02:36.933852Z", - "mentioned_users": [], - "status": "SENT", - "silent": false, - "pinned": false, - "pinned_at": null, - "pin_expires": null, - "pinned_by": null - }, - { - "id": "red-firefly-9-c4e9007b-bb7d-4238-ae08-5f8e3cd03d73", - "text": "hello", - "type": "regular", - "user": { - "id": "red-firefly-9", - "role": "user", - "created_at": "2019-08-02T18:56:39.366516Z", - "updated_at": "2020-01-29T22:13:50.491769Z", - "last_active": "2020-01-29T22:13:50.450215Z", - "banned": false, - "online": false, - "image": "https://getstream.io/random_svg/?name=Red+firefly", - "name": "Red firefly" - }, - "attachments": [], - "latest_reactions": [], - "own_reactions": [], - "reaction_counts": {}, - "reaction_scores": {}, - "reply_count": 0, - "created_at": "2020-01-29T22:14:08.54062Z", - "updated_at": "2020-01-29T22:14:08.54062Z", - "mentioned_users": [], - "status": "SENT", - "silent": false, - "pinned": false, - "pinned_at": null, - "pin_expires": null, - "pinned_by": null - }, - { - "id": "bitter-glade-2-02aee4eb-4093-4736-808b-2de75820e854", - "text": "hello", - "type": "regular", - "user": { - "id": "bitter-glade-2", - "role": "user", - "created_at": "2020-01-30T13:08:56.190678Z", - "updated_at": "2020-01-30T13:08:56.200333Z", - "last_active": "2020-01-30T13:08:56.193882Z", - "banned": false, - "online": false, - "image": "https://getstream.io/random_svg/?name=Bitter+glade", - "name": "Bitter glade" - }, - "attachments": [], - "latest_reactions": [], - "own_reactions": [], - "reaction_counts": {}, - "reaction_scores": {}, - "reply_count": 0, - "created_at": "2020-01-30T13:11:37.191293Z", - "updated_at": "2020-01-30T13:11:37.191293Z", - "mentioned_users": [], - "status": "SENT", - "silent": false, - "pinned": false, - "pinned_at": null, - "pin_expires": null, - "pinned_by": null - }, - { - "id": "morning-sea-1-0c700bcb-46dd-4224-b590-e77bdbccc480", - "text": "http://jaeger.ui.gtstrm.com/", - "type": "regular", - "user": { - "id": "morning-sea-1", - "role": "user", - "created_at": "2019-07-22T09:19:07.505207Z", - "updated_at": "2020-01-30T13:33:05.831856Z", - "last_active": "2020-01-30T13:33:05.825369Z", - "banned": false, - "online": false, - "image": "https://getstream.io/random_svg/?name=Morning+sea", - "name": "Morning sea" - }, - "attachments": [], - "latest_reactions": [], - "own_reactions": [], - "reaction_counts": {}, - "reaction_scores": {}, - "reply_count": 0, - "created_at": "2020-01-30T13:33:16.853116Z", - "updated_at": "2020-01-30T13:33:16.853116Z", - "mentioned_users": [], - "status": "SENT", - "silent": false, - "pinned": false, - "pinned_at": null, - "pin_expires": null, - "pinned_by": null - }, - { - "id": "ancient-salad-0-53e8b4e6-5b7b-43ad-aeee-8bfb6a9ed0be", - "text": "hi", - "type": "regular", - "user": { - "id": "ancient-salad-0", - "role": "user", - "created_at": "2020-01-30T13:34:29.286813Z", - "updated_at": "2020-01-30T13:34:29.296196Z", - "last_active": "2020-01-30T13:34:29.289964Z", - "banned": false, - "online": true, - "image": "https://getstream.io/random_svg/?name=Ancient+salad", - "name": "Ancient salad" - }, - "attachments": [], - "latest_reactions": [], - "own_reactions": [], - "reaction_counts": {}, - "reaction_scores": {}, - "reply_count": 0, - "created_at": "2020-01-30T13:36:52.749731Z", - "updated_at": "2020-01-30T13:36:52.749732Z", - "mentioned_users": [], - "status": "SENT", - "silent": false, - "pinned": false, - "pinned_at": null, - "pin_expires": null, - "pinned_by": null - }, - { - "id": "ancient-salad-0-8c225075-bd4c-42e2-8024-530aae13cd40", - "text": "hi", - "type": "regular", - "user": { - "id": "ancient-salad-0", - "role": "user", - "created_at": "2020-01-30T13:34:29.286813Z", - "updated_at": "2020-01-30T13:34:29.296196Z", - "last_active": "2020-01-30T13:34:29.289964Z", - "banned": false, - "online": true, - "image": "https://getstream.io/random_svg/?name=Ancient+salad", - "name": "Ancient salad" - }, - "attachments": [], - "latest_reactions": [], - "own_reactions": [], - "reaction_counts": {}, - "reaction_scores": {}, - "reply_count": 0, - "created_at": "2020-01-30T13:37:41.631056Z", - "updated_at": "2020-01-30T13:37:41.631056Z", - "mentioned_users": [], - "status": "SENT", - "silent": false, - "pinned": false, - "pinned_at": null, - "pin_expires": null, - "pinned_by": null - }, - { - "id": "proud-sea-7-17802096-cbf8-4e3c-addd-4ee31f4c8b5c", - "text": "😃", - "type": "regular", - "user": { - "id": "proud-sea-7", - "role": "user", - "created_at": "2020-01-30T13:43:03.903006Z", - "updated_at": "2020-01-30T13:43:03.912307Z", - "last_active": "2020-01-30T13:43:03.906236Z", - "banned": false, - "online": false, - "image": "https://getstream.io/random_svg/?name=Proud+sea", - "name": "Proud sea" - }, - "attachments": [], - "latest_reactions": [], - "own_reactions": [], - "reaction_counts": {}, - "reaction_scores": {}, - "reply_count": 0, - "created_at": "2020-01-30T13:43:41.062362Z", - "updated_at": "2020-01-30T13:43:41.062362Z", - "mentioned_users": [], - "status": "SENT", - "silent": false, - "pinned": false, - "pinned_at": null, - "pin_expires": null, - "pinned_by": null - } - ], - "watcher_count": 5, - "members": [] - }'''; - - test('should parse json correctly', () { - final channelState = ChannelState.fromJson(json.decode(jsonExample)); - expect(channelState.channel.cid, 'team:dev'); - expect(channelState.channel.id, 'dev'); - expect(channelState.channel.team, 'test'); - expect(channelState.channel.type, 'team'); - expect(channelState.channel.config, isA()); - expect(channelState.channel.config, isNotNull); - expect(channelState.channel.config.commands, hasLength(1)); - expect(channelState.channel.config.commands[0], isA()); - expect(channelState.channel.lastMessageAt, - DateTime.parse("2020-01-30T13:43:41.062362Z")); - expect(channelState.channel.createdAt, - DateTime.parse("2019-04-03T18:43:33.213373Z")); - expect(channelState.channel.updatedAt, - DateTime.parse("2019-04-03T18:43:33.213374Z")); - expect(channelState.channel.createdBy, isA()); - expect(channelState.channel.frozen, true); - expect(channelState.channel.extraData['example'], 1); - expect(channelState.channel.extraData['name'], "#dev"); - expect(channelState.channel.extraData['image'], - "https://cdn.chrisshort.net/testing-certificate-chains-in-go/GOPHER_MIC_DROP.png"); - expect(channelState.messages, hasLength(25)); - expect(channelState.messages[0], isA()); - expect(channelState.messages[0], isNotNull); - expect(channelState.messages[0].createdAt, - DateTime.parse("2020-01-29T03:23:02.843948Z")); - expect(channelState.messages[0].user, isA()); - expect(channelState.watcherCount, 5); - }); - - test('should serialize to json correctly', () { - const toJsonExample = r''' - { - "channel": { - "id": "dev", - "type": "team", - "frozen": true, - "name": "#dev", - "image": "https://cdn.chrisshort.net/testing-certificate-chains-in-go/GOPHER_MIC_DROP.png", - "example": 1 - }, - "watchers": null, - "read": null, - "messages": [ - { - "id": "dry-meadow-0-2b73cc8b-cd86-4a01-8d40-bd82ad07a030", - "text": "fasdfa", - "attachments": [], - "parent_id": null, - "quoted_message": null, - "quoted_message_id": null, - "show_in_channel": null, - "mentioned_users": [], - "status": "SENT", - "skip_push": null, - "silent": false, - "pinned": false, - "pinned_at": null, - "pin_expires": null, - "pinned_by": null - }, - { - "id": "dry-meadow-0-e8e74482-b4cd-48db-9d1e-30e6c191786f", - "text": "test message", - "attachments": [], - "parent_id": null, - "quoted_message": null, - "quoted_message_id": null, - "show_in_channel": null, - "mentioned_users": [], - "status": "SENT", - "skip_push": null, - "silent": false, - "pinned": false, - "pinned_at": null, - "pin_expires": null, - "pinned_by": null - }, - { - "id": "dry-meadow-0-53e6299f-9b97-4a9c-a27e-7e2dde49b7e0", - "text": "test message", - "skip_push": null, - "attachments": [], - "parent_id": null, - "quoted_message": null, - "quoted_message_id": null, - "show_in_channel": null, - "mentioned_users": [], - "status": "SENT", - "silent": false, - "pinned": false, - "pinned_at": null, - "pin_expires": null, - "pinned_by": null - }, - { - "id": "dry-meadow-0-80925be0-786e-40a5-b225-486518dafd35", - "text": "asdfadf", - "attachments": [], - "parent_id": null, - "quoted_message": null, - "quoted_message_id": null, - "show_in_channel": null, - "mentioned_users": [], - "skip_push": null, - "status": "SENT", - "silent": false, - "pinned": false, - "pinned_at": null, - "pin_expires": null, - "pinned_by": null - }, - { - "id": "dry-meadow-0-64d7970f-ede8-4b31-9738-1bc1756d2bfe", - "text": "test", - "attachments": [], - "skip_push": null, - "parent_id": null, - "quoted_message": null, - "quoted_message_id": null, - "show_in_channel": null, - "mentioned_users": [], - "status": "SENT", - "silent": false, - "pinned": false, - "pinned_at": null, - "pin_expires": null, - "pinned_by": null - }, - { - "id": "withered-cell-0-84cbd760-cf55-4f7e-9207-c5f66cccc6dc", - "text": "hi", - "attachments": [], - "parent_id": null, - "skip_push": null, - "quoted_message": null, - "quoted_message_id": null, - "show_in_channel": null, - "mentioned_users": [], - "status": "SENT", - "silent": false, - "pinned": false, - "pinned_at": null, - "pin_expires": null, - "pinned_by": null - }, - { - "id": "dry-meadow-0-e9203588-43c3-40b1-91f7-f217fc42aa53", - "text": "fantastic", - "attachments": [], - "parent_id": null, - "quoted_message": null, - "skip_push": null, - "quoted_message_id": null, - "show_in_channel": null, - "mentioned_users": [], - "status": "SENT", - "silent": false, - "pinned": false, - "pinned_at": null, - "pin_expires": null, - "pinned_by": null - }, - { - "id": "withered-cell-0-7e3552d7-7a0d-45f2-a856-e91b23a7e240", - "text": "nice to meet you", - "attachments": [], - "parent_id": null, - "quoted_message": null, - "quoted_message_id": null, - "show_in_channel": null, - "mentioned_users": [], - "status": "SENT", - "silent": false, - "pinned": false, - "skip_push": null, - "pinned_at": null, - "pin_expires": null, - "pinned_by": null - }, - { - "id": "dry-meadow-0-1ffeafd4-e4fc-4c84-9394-9d7cb10fff42", - "text": "hey", - "attachments": [], - "parent_id": null, - "quoted_message": null, - "quoted_message_id": null, - "show_in_channel": null, - "mentioned_users": [], - "status": "SENT", - "silent": false, - "pinned": false, - "pinned_at": null, - "skip_push": null, - "pin_expires": null, - "pinned_by": null - }, - { - "id": "dry-meadow-0-3f147324-12c8-4b41-9fb5-2db88d065efa", - "text": "hello, everyone", - "attachments": [], - "parent_id": null, - "quoted_message": null, - "quoted_message_id": null, - "show_in_channel": null, - "skip_push": null, - "mentioned_users": [], - "status": "SENT", - "silent": false, - "pinned": false, - "pinned_at": null, - "pin_expires": null, - "pinned_by": null - }, - { - "id": "dry-meadow-0-51a348ae-0c0a-44de-a556-eac7891c0cf0", - "text": "who is there?", - "attachments": [], - "parent_id": null, - "quoted_message": null, - "quoted_message_id": null, - "show_in_channel": null, - "mentioned_users": [], - "skip_push": null, - "status": "SENT", - "silent": false, - "pinned": false, - "pinned_at": null, - "pin_expires": null, - "pinned_by": null - }, - { - "id": "icy-recipe-7-a29e237b-8d81-4a97-9bc8-d42bca3f1356", - "text": "하이", - "attachments": [], - "parent_id": null, - "quoted_message": null, - "quoted_message_id": null, - "show_in_channel": null, - "mentioned_users": [], - "status": "SENT", - "silent": false, - "skip_push": null, - "pinned": false, - "pinned_at": null, - "pin_expires": null, - "pinned_by": null - }, - { - "id": "icy-recipe-7-935c396e-ddf8-4a9a-951c-0a12fa5bf055", - "text": "what are you doing?", - "attachments": [], - "skip_push": null, - "parent_id": null, - "quoted_message": null, - "quoted_message_id": null, - "show_in_channel": null, - "mentioned_users": [], - "status": "SENT", - "silent": false, - "pinned": false, - "pinned_at": null, - "pin_expires": null, - "pinned_by": null - }, - { - "id": "throbbing-boat-5-1e4d5730-5ff0-4d25-9948-9f34ffda43e4", - "text": "👍", - "attachments": [], - "parent_id": null, - "skip_push": null, - "quoted_message": null, - "quoted_message_id": null, - "show_in_channel": null, - "mentioned_users": [], - "status": "SENT", - "silent": false, - "pinned": false, - "pinned_at": null, - "pin_expires": null, - "pinned_by": null - }, - { - "id": "snowy-credit-3-3e0c1a0d-d22f-42ee-b2a1-f9f49477bf21", - "text": "sdasas", - "attachments": [], - "skip_push": null, - "parent_id": null, - "quoted_message": null, - "quoted_message_id": null, - "show_in_channel": null, - "mentioned_users": [], - "status": "SENT", - "silent": false, - "pinned": false, - "pinned_at": null, - "pin_expires": null, - "pinned_by": null - }, - { - "id": "snowy-credit-3-3319537e-2d0e-4876-8170-a54f046e4b7d", - "text": "cjshsa", - "attachments": [], - "parent_id": null, - "quoted_message": null, - "quoted_message_id": null, - "show_in_channel": null, - "skip_push": null, - "mentioned_users": [], - "status": "SENT", - "silent": false, - "pinned": false, - "pinned_at": null, - "pin_expires": null, - "pinned_by": null - }, - { - "id": "snowy-credit-3-cfaf0b46-1daa-49c5-947c-b16d6697487d", - "text": "nhisagdhsadz", - "attachments": [], - "skip_push": null, - "parent_id": null, - "quoted_message": null, - "quoted_message_id": null, - "show_in_channel": null, - "mentioned_users": [], - "status": "SENT", - "silent": false, - "pinned": false, - "pinned_at": null, - "pin_expires": null, - "pinned_by": null - }, - { - "id": "snowy-credit-3-cebe25a7-a3a3-49fc-9919-91c6725e81f3", - "text": "hvadhsahzd", - "attachments": [], - "parent_id": null, - "quoted_message": null, - "skip_push": null, - "quoted_message_id": null, - "show_in_channel": null, - "mentioned_users": [], - "status": "SENT", - "silent": false, - "pinned": false, - "pinned_at": null, - "pin_expires": null, - "pinned_by": null - }, - { - "id": "divine-glade-9-0cea9262-5766-48e9-8b22-311870aed3bf", - "text": "hello", - "attachments": [], - "parent_id": null, - "quoted_message": null, - "skip_push": null, - "quoted_message_id": null, - "show_in_channel": null, - "mentioned_users": [], - "status": "SENT", - "silent": false, - "pinned": false, - "pinned_at": null, - "skip_push": null, - "pin_expires": null, - "pinned_by": null - }, - { - "id": "red-firefly-9-c4e9007b-bb7d-4238-ae08-5f8e3cd03d73", - "text": "hello", - "attachments": [], - "parent_id": null, - "quoted_message": null, - "quoted_message_id": null, - "show_in_channel": null, - "mentioned_users": [], - "status": "SENT", - "silent": false, - "pinned": false, - "pinned_at": null, - "skip_push": null, - "pin_expires": null, - "pinned_by": null - }, - { - "id": "bitter-glade-2-02aee4eb-4093-4736-808b-2de75820e854", - "text": "hello", - "attachments": [], - "parent_id": null, - "quoted_message": null, - "quoted_message_id": null, - "show_in_channel": null, - "mentioned_users": [], - "status": "SENT", - "silent": false, - "pinned": false, - "pinned_at": null, - "skip_push": null, - "pin_expires": null, - "pinned_by": null - }, - { - "id": "morning-sea-1-0c700bcb-46dd-4224-b590-e77bdbccc480", - "text": "http://jaeger.ui.gtstrm.com/", - "attachments": [], - "parent_id": null, - "quoted_message": null, - "quoted_message_id": null, - "show_in_channel": null, - "mentioned_users": [], - "status": "SENT", - "silent": false, - "pinned": false, - "pinned_at": null, - "skip_push": null, - "pin_expires": null, - "pinned_by": null - }, - { - "id": "ancient-salad-0-53e8b4e6-5b7b-43ad-aeee-8bfb6a9ed0be", - "text": "hi", - "attachments": [], - "parent_id": null, - "quoted_message": null, - "quoted_message_id": null, - "show_in_channel": null, - "mentioned_users": [], - "status": "SENT", - "silent": false, - "pinned": false, - "pinned_at": null, - "skip_push": null, - "pin_expires": null, - "pinned_by": null - }, - { - "id": "ancient-salad-0-8c225075-bd4c-42e2-8024-530aae13cd40", - "text": "hi", - "attachments": [], - "parent_id": null, - "quoted_message": null, - "quoted_message_id": null, - "show_in_channel": null, - "mentioned_users": [], - "status": "SENT", - "silent": false, - "pinned": false, - "pinned_at": null, - "skip_push": null, - "pin_expires": null, - "pinned_by": null - }, - { - "id": "proud-sea-7-17802096-cbf8-4e3c-addd-4ee31f4c8b5c", - "text": "😃", - "attachments": [], - "parent_id": null, - "quoted_message": null, - "quoted_message_id": null, - "show_in_channel": null, - "mentioned_users": [], - "status": "SENT", - "silent": false, - "pinned": false, - "pinned_at": null, - "skip_push": null, - "pin_expires": null, - "pinned_by": null - } - ], - "pinned_messages": [], - "members": [], - "watcher_count": 5 - } - '''; - final j = jsonDecode(jsonExample); - final channelState = ChannelState( - channel: ChannelModel.fromJson(j['channel']), - members: [], - messages: - (j['messages'] as List).map((m) => Message.fromJson(m)).toList(), - read: null, - watcherCount: 5, - pinnedMessages: [], - watchers: null, - ); - - expect( - channelState.toJson(), - jsonDecode(toJsonExample), - ); - }); - }); -} diff --git a/packages/stream_chat/test/src/models/channel_test.dart b/packages/stream_chat/test/src/models/channel_test.dart deleted file mode 100644 index 69b18197..00000000 --- a/packages/stream_chat/test/src/models/channel_test.dart +++ /dev/null @@ -1,56 +0,0 @@ -import 'dart:convert'; - -import 'package:test/test.dart'; -import 'package:stream_chat/src/models/channel_model.dart'; - -void main() { - group('src/models/channel', () { - const jsonExample = ''' - { - "id": "test", - "type": "livestream", - "cid": "test:livestream", - "cats": true, - "fruit": ["bananas", "apples"] - } - '''; - - test('should parse json correctly', () { - final channel = ChannelModel.fromJson(json.decode(jsonExample)); - expect(channel.id, equals("test")); - expect(channel.type, equals("livestream")); - expect(channel.cid, equals("test:livestream")); - expect(channel.extraData["cats"], equals(true)); - expect(channel.extraData["fruit"], equals(["bananas", "apples"])); - }); - - test('should serialize to json correctly', () { - final channel = ChannelModel( - type: "type", - id: "id", - cid: "a:a", - extraData: {"name": "cool"}, - ); - - expect( - channel.toJson(), - {'id': 'id', 'type': 'type', 'name': 'cool'}, - ); - }); - - test('should serialize to json correctly when frozen is provided', () { - final channel = ChannelModel( - type: "type", - id: "id", - cid: "a:a", - extraData: {"name": "cool"}, - frozen: false, - ); - - expect( - channel.toJson(), - {'id': 'id', 'type': 'type', 'name': 'cool', 'frozen': false}, - ); - }); - }); -} diff --git a/packages/stream_chat/test/src/models/event_test.dart b/packages/stream_chat/test/src/models/event_test.dart deleted file mode 100644 index db99b01a..00000000 --- a/packages/stream_chat/test/src/models/event_test.dart +++ /dev/null @@ -1,89 +0,0 @@ -import 'dart:convert'; - -import 'package:test/test.dart'; -import 'package:stream_chat/src/models/event.dart'; -import 'package:stream_chat/src/models/own_user.dart'; -import 'package:stream_chat/stream_chat.dart'; - -void main() { - group('src/models/event', () { - const jsonExample = ''' - { - "type": "type", - "cid": "cid", - "connection_id": "connectionId", - "created_at": "2019-04-03T18:43:33.213374Z", - "me": { - "id": "dry-meadow-0", - "role": "user", - "created_at": "2019-03-27T17:40:17.155892Z", - "updated_at": "2020-01-29T03:22:47.641589Z", - "last_active": "2020-01-29T03:22:47.63613Z", - "banned": false, - "online": false, - "image": "https://getstream.io/random_svg/?name=Dry+meadow", - "name": "Dry meadow" - }, - "parent_id": null, - "user": { - "id": "dry-meadow-0", - "role": "user", - "created_at": "2019-03-27T17:40:17.155892Z", - "updated_at": "2020-01-29T03:22:47.641589Z", - "last_active": "2020-01-29T03:22:47.63613Z", - "banned": false, - "online": false, - "image": "https://getstream.io/random_svg/?name=Dry+meadow", - "name": "Dry meadow" - } - } - '''; - - test('should parse json correctly', () { - final event = Event.fromJson(json.decode(jsonExample)); - expect(event.type, 'type'); - expect(event.cid, 'cid'); - expect(event.connectionId, 'connectionId'); - expect(event.createdAt, isA()); - expect(event.me, isA()); - expect(event.user, isA()); - }); - - test('should serialize to json correctly', () { - final event = Event( - user: User(id: 'id'), - type: 'type', - cid: 'cid', - connectionId: 'connectionId', - createdAt: DateTime.parse("2020-01-29T03:22:47.63613Z"), - me: OwnUser(id: 'id2'), - totalUnreadCount: 1, - unreadChannels: 1, - online: true, - ); - - expect( - event.toJson(), - { - 'type': 'type', - 'cid': 'cid', - 'connection_id': 'connectionId', - 'created_at': '2020-01-29T03:22:47.636130Z', - 'me': {'id': 'id2'}, - 'user': {'id': 'id'}, - 'reaction': null, - 'message': null, - 'channel': null, - 'total_unread_count': 1, - 'unread_channels': 1, - 'online': true, - 'is_local': true, - 'member': null, - 'channel_id': null, - 'channel_type': null, - 'parent_id': null, - }, - ); - }); - }); -} diff --git a/packages/stream_chat/test/src/models/member_test.dart b/packages/stream_chat/test/src/models/member_test.dart deleted file mode 100644 index 237f0030..00000000 --- a/packages/stream_chat/test/src/models/member_test.dart +++ /dev/null @@ -1,35 +0,0 @@ -import 'dart:convert'; - -import 'package:test/test.dart'; -import 'package:stream_chat/src/models/member.dart'; -import 'package:stream_chat/src/models/user.dart'; - -void main() { - group('src/models/member', () { - const jsonExample = ''' - { - "user": { - "id": "bbb19d9a-ee50-45bc-84e5-0584e79d0c9e", - "role": "user", - "created_at": "2020-01-28T22:17:30.826259Z", - "updated_at": "2020-01-28T22:17:31.101222Z", - "banned": false, - "online": false, - "name": "Robin Papa", - "image": "https://pbs.twimg.com/profile_images/669512187778498560/L7wQctBt.jpg" - }, - "role": "member", - "created_at": "2020-01-28T22:17:30.95443Z", - "updated_at": "2020-01-28T22:17:30.95443Z" - } - '''; - - test('should parse json correctly', () { - final member = Member.fromJson(json.decode(jsonExample)); - expect(member.user, isA()); - expect(member.role, 'member'); - expect(member.createdAt, DateTime.parse("2020-01-28T22:17:30.95443Z")); - expect(member.updatedAt, DateTime.parse("2020-01-28T22:17:30.95443Z")); - }); - }); -} diff --git a/packages/stream_chat/test/src/models/message_test.dart b/packages/stream_chat/test/src/models/message_test.dart deleted file mode 100644 index 3541b2c0..00000000 --- a/packages/stream_chat/test/src/models/message_test.dart +++ /dev/null @@ -1,166 +0,0 @@ -import 'dart:convert'; - -import 'package:test/test.dart'; -import 'package:stream_chat/src/models/attachment.dart'; -import 'package:stream_chat/src/models/message.dart'; -import 'package:stream_chat/src/models/reaction.dart'; -import 'package:stream_chat/src/models/user.dart'; - -void main() { - group('src/models/message', () { - const jsonExample = r'''{ - "id": "4637f7e4-a06b-42db-ba5a-8d8270dd926f", - "text": "https://giphy.com/gifs/the-lion-king-live-action-5zvN79uTGfLMOVfQaA", - "type": "regular", - "silent": false, - "status": "SENT", - "user": { - "id": "c1c9b454-2bcc-402d-8bb0-2f3706ce1680", - "role": "user", - "created_at": "2020-01-28T22:17:30.83015Z", - "updated_at": "2020-01-28T22:17:31.19435Z", - "banned": false, - "online": false, - "image": "https://randomuser.me/api/portraits/women/2.jpg", - "name": "Mia Denys" - }, - "attachments": [ - { - "type": "video", - "author_name": "GIPHY", - "title": "The Lion King Disney GIF - Find \u0026 Share on GIPHY", - "title_link": "https://media.giphy.com/media/5zvN79uTGfLMOVfQaA/giphy.gif", - "text": "Discover \u0026 share this Lion King Live Action GIF with everyone you know. GIPHY is how you search, share, discover, and create GIFs.", - "image_url": "https://media.giphy.com/media/5zvN79uTGfLMOVfQaA/giphy.gif", - "thumb_url": "https://media.giphy.com/media/5zvN79uTGfLMOVfQaA/giphy.gif", - "asset_url": "https://media.giphy.com/media/5zvN79uTGfLMOVfQaA/giphy.mp4", - "og_scrape_url": "https://giphy.com/gifs/the-lion-king-live-action-5zvN79uTGfLMOVfQaA" - } - ], - "latest_reactions": [ - { - "message_id": "4637f7e4-a06b-42db-ba5a-8d8270dd926f", - "user_id": "c1c9b454-2bcc-402d-8bb0-2f3706ce1680", - "user": { - "id": "c1c9b454-2bcc-402d-8bb0-2f3706ce1680", - "role": "user", - "created_at": "2020-01-28T22:17:30.83015Z", - "updated_at": "2020-01-28T22:17:31.19435Z", - "banned": false, - "online": false, - "image": "https://randomuser.me/api/portraits/women/2.jpg", - "name": "Mia Denys" - }, - "type": "love", - "score": 1, - "created_at": "2020-01-28T22:17:31.128376Z", - "updated_at": "2020-01-28T22:17:31.128376Z" - } - ], - "own_reactions": [], - "reaction_counts": { - "love": 1 - }, - "reaction_scores": { - "love": 1 - }, - "pinned": false, - "pinned_at": null, - "pin_expires": null, - "pinned_by": null, - "reply_count": 0, - "created_at": "2020-01-28T22:17:31.107978Z", - "updated_at": "2020-01-28T22:17:31.130506Z", - "mentioned_users": [] - }'''; - - test('should parse json correctly', () { - final message = Message.fromJson(json.decode(jsonExample)); - expect(message.id, "4637f7e4-a06b-42db-ba5a-8d8270dd926f"); - expect(message.text, - "https://giphy.com/gifs/the-lion-king-live-action-5zvN79uTGfLMOVfQaA"); - expect(message.type, "regular"); - expect(message.user, isA()); - expect(message.silent, isA()); - expect(message.attachments, isA>()); - expect(message.latestReactions, isA>()); - expect(message.ownReactions, isA>()); - expect(message.reactionCounts, {'love': 1}); - expect(message.reactionScores, {'love': 1}); - expect(message.createdAt, DateTime.parse("2020-01-28T22:17:31.107978Z")); - expect(message.updatedAt, DateTime.parse("2020-01-28T22:17:31.130506Z")); - expect(message.mentionedUsers, isA>()); - expect(message.pinned, false); - expect(message.pinnedAt, null); - expect(message.pinExpires, null); - expect(message.pinnedBy, null); - }); - - test('should serialize to json correctly', () { - final message = Message( - id: "4637f7e4-a06b-42db-ba5a-8d8270dd926f", - text: - "https://giphy.com/gifs/the-lion-king-live-action-5zvN79uTGfLMOVfQaA", - silent: false, - attachments: [ - Attachment.fromJson({ - "type": "video", - "author_name": "GIPHY", - "title": "The Lion King Disney GIF - Find \u0026 Share on GIPHY", - "title_link": - "https://media.giphy.com/media/5zvN79uTGfLMOVfQaA/giphy.gif", - "text": - "Discover \u0026 share this Lion King Live Action GIF with everyone you know. GIPHY is how you search, share, discover, and create GIFs.", - "image_url": - "https://media.giphy.com/media/5zvN79uTGfLMOVfQaA/giphy.gif", - "thumb_url": - "https://media.giphy.com/media/5zvN79uTGfLMOVfQaA/giphy.gif", - "asset_url": - "https://media.giphy.com/media/5zvN79uTGfLMOVfQaA/giphy.mp4", - "og_scrape_url": - "https://giphy.com/gifs/the-lion-king-live-action-5zvN79uTGfLMOVfQaA" - }) - ], - showInChannel: true, - parentId: 'parentId', - extraData: {'hey': 'test'}, - status: MessageSendingStatus.sent, - ); - - expect( - message.toJson(), - json.decode(r''' - { - "id": "4637f7e4-a06b-42db-ba5a-8d8270dd926f", - "text": "https://giphy.com/gifs/the-lion-king-live-action-5zvN79uTGfLMOVfQaA", - "silent": false, - "skip_push": null, - "attachments": [ - { - "type": "video", - "title_link": "https://media.giphy.com/media/5zvN79uTGfLMOVfQaA/giphy.gif", - "title": "The Lion King Disney GIF - Find & Share on GIPHY", - "thumb_url": "https://media.giphy.com/media/5zvN79uTGfLMOVfQaA/giphy.gif", - "text": "Discover & share this Lion King Live Action GIF with everyone you know. GIPHY is how you search, share, discover, and create GIFs.", - "og_scrape_url": "https://giphy.com/gifs/the-lion-king-live-action-5zvN79uTGfLMOVfQaA", - "image_url": "https://media.giphy.com/media/5zvN79uTGfLMOVfQaA/giphy.gif", - "author_name": "GIPHY", - "asset_url": "https://media.giphy.com/media/5zvN79uTGfLMOVfQaA/giphy.mp4" - } - ], - "mentioned_users": null, - "parent_id": "parentId", - "quoted_message": null, - "quoted_message_id": null, - "pinned": false, - "pinned_at": null, - "pin_expires": null, - "pinned_by": null, - "show_in_channel": true, - "hey": "test" - } - '''), - ); - }); - }); -} diff --git a/packages/stream_chat/test/src/models/reaction_test.dart b/packages/stream_chat/test/src/models/reaction_test.dart deleted file mode 100644 index 8bc92081..00000000 --- a/packages/stream_chat/test/src/models/reaction_test.dart +++ /dev/null @@ -1,72 +0,0 @@ -import 'package:test/test.dart'; -import 'package:stream_chat/src/models/reaction.dart'; -import 'dart:convert'; - -import 'package:stream_chat/src/models/user.dart'; - -void main() { - group('src/models/reaction', () { - const jsonExample = ''' - { - "message_id": "76cd8c82-b557-4e48-9d12-87995d3a0e04", - "user_id": "2de0297c-f3f2-489d-b930-ef77342edccf", - "user": { - "id": "2de0297c-f3f2-489d-b930-ef77342edccf", - "role": "user", - "created_at": "2020-01-28T22:17:30.810011Z", - "updated_at": "2020-01-28T22:17:31.077195Z", - "banned": false, - "online": false, - "image": "https://randomuser.me/api/portraits/women/45.jpg", - "name": "Daisy Morgan" - }, - "type": "wow", - "score": 1, - "created_at": "2020-01-28T22:17:31.108742Z", - "updated_at": "2020-01-28T22:17:31.108742Z" - } - '''; - - test('should parse json correctly', () { - final reaction = Reaction.fromJson(json.decode(jsonExample)); - expect(reaction.messageId, '76cd8c82-b557-4e48-9d12-87995d3a0e04'); - expect(reaction.createdAt, DateTime.parse("2020-01-28T22:17:31.108742Z")); - expect(reaction.type, 'wow'); - expect( - reaction.user.toJson(), - User.init("2de0297c-f3f2-489d-b930-ef77342edccf", extraData: { - "image": "https://randomuser.me/api/portraits/women/45.jpg", - "name": "Daisy Morgan" - }).toJson(), - ); - expect(reaction.score, 1); - expect(reaction.userId, '2de0297c-f3f2-489d-b930-ef77342edccf'); - expect(reaction.extraData, {'updated_at': '2020-01-28T22:17:31.108742Z'}); - }); - - test('should serialize to json correctly', () { - final reaction = Reaction( - messageId: '76cd8c82-b557-4e48-9d12-87995d3a0e04', - createdAt: DateTime.parse("2020-01-28T22:17:31.108742Z"), - type: 'wow', - user: User.init("2de0297c-f3f2-489d-b930-ef77342edccf", extraData: { - "image": "https://randomuser.me/api/portraits/women/45.jpg", - "name": "Daisy Morgan" - }), - userId: "2de0297c-f3f2-489d-b930-ef77342edccf", - extraData: {'bananas': 'yes'}, - score: 1, - ); - - expect( - reaction.toJson(), - { - "message_id": "76cd8c82-b557-4e48-9d12-87995d3a0e04", - "type": "wow", - "score": 1, - "bananas": 'yes', - }, - ); - }); - }); -} diff --git a/packages/stream_chat/test/src/models/read_test.dart b/packages/stream_chat/test/src/models/read_test.dart deleted file mode 100644 index b15ce66a..00000000 --- a/packages/stream_chat/test/src/models/read_test.dart +++ /dev/null @@ -1,40 +0,0 @@ -import 'dart:convert'; - -import 'package:test/test.dart'; -import 'package:stream_chat/src/models/read.dart'; -import 'package:stream_chat/src/models/user.dart'; - -void main() { - group('src/models/read', () { - const jsonExample = ''' - { - "user": { - "id": "bbb19d9a-ee50-45bc-84e5-0584e79d0c9e" - }, - "last_read": "2020-01-28T22:17:30.966485504Z", - "unread_messages": 10 - } - '''; - - test('should parse json correctly', () { - final read = Read.fromJson(json.decode(jsonExample)); - expect(read.lastRead, DateTime.parse('2020-01-28T22:17:30.966485504Z')); - expect(read.user.id, 'bbb19d9a-ee50-45bc-84e5-0584e79d0c9e'); - expect(read.unreadMessages, 10); - }); - - test('should serialize to json correctly', () { - final read = Read( - lastRead: DateTime.parse('2020-01-28T22:17:30.966485504Z'), - user: User.init('bbb19d9a-ee50-45bc-84e5-0584e79d0c9e'), - unreadMessages: 10, - ); - - expect(read.toJson(), { - "user": {"id": "bbb19d9a-ee50-45bc-84e5-0584e79d0c9e"}, - "last_read": "2020-01-28T22:17:30.966485Z", - 'unread_messages': 10, - }); - }); - }); -} diff --git a/packages/stream_chat/test/src/models/user_test.dart b/packages/stream_chat/test/src/models/user_test.dart deleted file mode 100644 index 6100b218..00000000 --- a/packages/stream_chat/test/src/models/user_test.dart +++ /dev/null @@ -1,28 +0,0 @@ -import 'dart:convert'; - -import 'package:test/test.dart'; -import 'package:stream_chat/src/models/user.dart'; - -void main() { - group('src/models/user', () { - const jsonExample = ''' - { - "id": "bbb19d9a-ee50-45bc-84e5-0584e79d0c9e" - } - '''; - - test('should parse json correctly', () { - final user = User.fromJson(json.decode(jsonExample)); - expect(user.id, 'bbb19d9a-ee50-45bc-84e5-0584e79d0c9e'); - }); - - test('should serialize to json correctly', () { - final user = - User(id: 'bbb19d9a-ee50-45bc-84e5-0584e79d0c9e', role: "abc"); - - expect(user.toJson(), { - 'id': "bbb19d9a-ee50-45bc-84e5-0584e79d0c9e", - }); - }); - }); -} diff --git a/packages/stream_chat/test/src/utils.dart b/packages/stream_chat/test/src/utils.dart new file mode 100644 index 00000000..768d2fe2 --- /dev/null +++ b/packages/stream_chat/test/src/utils.dart @@ -0,0 +1,32 @@ +import 'dart:convert'; +import 'dart:io'; + +String fixture(String name) { + final dir = currentDirectory.path; + return File('$dir/test/fixtures/$name').readAsStringSync(); +} + +File assetFile(String name) { + final dir = currentDirectory.path; + return File('$dir/test/assets/$name'); +} + +Map jsonFixture(String name) => json.decode(fixture(name)); + +// https://github.com/flutter/flutter/issues/20907 +Directory get currentDirectory { + var directory = Directory.current; + if (directory.path.endsWith('/test')) { + directory = directory.parent; + } + return directory; +} + +// Extension function to convert int into durations +extension IntX on num { + Duration toDuration() => Duration(milliseconds: toInt()); +} + +// Top level util function to delay the code execution +Future delay(num milliseconds) => + Future.delayed(Duration(milliseconds: milliseconds.toInt())); diff --git a/packages/stream_chat/test/src/ws/timer_helper_test.dart b/packages/stream_chat/test/src/ws/timer_helper_test.dart new file mode 100644 index 00000000..ecafd6d2 --- /dev/null +++ b/packages/stream_chat/test/src/ws/timer_helper_test.dart @@ -0,0 +1,98 @@ +import 'package:stream_chat/src/ws/timer_helper.dart'; +import 'package:test/test.dart'; + +void main() { + late TimerHelper timerHelper; + + setUp(() { + timerHelper = TimerHelper(); + }); + + tearDown(() { + timerHelper.cancelAllTimers(); + }); + + test('setTimer', () async { + expect(timerHelper.hasTimers, isFalse); + + var count = 0; + void callback() => count += 1; + + timerHelper.setTimer( + const Duration(milliseconds: 500), + callback, + ); + + expect(count, 0); + await Future.delayed(const Duration(milliseconds: 500)); + expect(count, 1); + + expect(timerHelper.hasTimers, isTrue); + }); + + test('setImmediateTimer', () async { + var count = 0; + void callback() => count += 1; + + timerHelper.setTimer( + const Duration(milliseconds: 500), + callback, + immediate: true, + ); + + expect(count, 1); + await Future.delayed(const Duration(milliseconds: 500)); + expect(count, 2); + }); + + test('setPeriodicTimer', () async { + expect(timerHelper.hasTimers, isFalse); + var count = 0; + void callback() => count += 1; + + timerHelper.setTimer( + const Duration(milliseconds: 500), + callback, + ); + + expect(count, 0); + await Future.delayed(const Duration(milliseconds: 500)); + expect(count, 1); + + expect(timerHelper.hasTimers, isTrue); + }); + + test('setImmediatePeriodicTimer', () async { + var count = 0; + void callback(_) => count += 1; + + timerHelper.setPeriodicTimer( + const Duration(milliseconds: 500), + callback, + immediate: true, + ); + + expect(count, 1); + await Future.delayed(const Duration(milliseconds: 500)); + expect(count, 2); + }); + + test('cancelTimer', () { + expect(timerHelper.hasTimers, isFalse); + final id = timerHelper.setTimer(const Duration(seconds: 3), () {}); + expect(timerHelper.hasTimers, isTrue); + timerHelper.cancelTimer(id); + expect(timerHelper.hasTimers, isFalse); + }); + + test('cancelAllTimers', () { + expect(timerHelper.hasTimers, isFalse); + timerHelper + ..setTimer(const Duration(seconds: 3), () {}) + ..setTimer(const Duration(seconds: 6), () {}) + ..setTimer(const Duration(seconds: 9), () {}); + expect(timerHelper.hasTimers, isTrue); + timerHelper.cancelAllTimers(); + expect(timerHelper.hasTimers, isFalse); + }); +} diff --git a/packages/stream_chat/test/src/ws/websocket_test.dart b/packages/stream_chat/test/src/ws/websocket_test.dart new file mode 100644 index 00000000..94033bf0 --- /dev/null +++ b/packages/stream_chat/test/src/ws/websocket_test.dart @@ -0,0 +1,343 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:stream_chat/src/core/http/token_manager.dart'; +import 'package:stream_chat/stream_chat.dart'; +import 'package:test/test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:stream_chat/src/ws/websocket.dart'; +import 'package:web_socket_channel/web_socket_channel.dart'; + +import '../fakes.dart'; +import '../mocks.dart'; + +void main() { + late TokenManager tokenManager; + late WebSocketChannel webSocketChannel; + late WebSocketSink webSocketSink; + late WebSocket webSocket; + + setUp(() { + tokenManager = FakeTokenManager(); + webSocketChannel = MockWebSocketChannel(); + + WebSocketChannel channelProvider( + Uri uri, { + Iterable? protocols, + }) => + webSocketChannel; + + webSocket = WebSocket( + apiKey: 'api-key', + baseUrl: 'base-url', + tokenManager: tokenManager, + webSocketChannelProvider: channelProvider, + ); + + webSocketSink = MockWebSocketSink(); + when(() => webSocketChannel.sink).thenReturn(webSocketSink); + + var webSocketController = StreamController.broadcast(); + when(() => webSocketChannel.stream).thenAnswer( + (_) => webSocketController.stream, + ); + when(() => webSocketSink.add(any())).thenAnswer((invocation) { + webSocketController.add(invocation.positionalArguments.first); + }); + when(() => webSocketSink.close(any(), any())).thenAnswer( + (_) async { + webSocketController.close(); + // re-initializing for future events + webSocketController = StreamController.broadcast(); + }, + ); + }); + + tearDown(() { + tokenManager.reset(); + webSocket.disconnect(); + }); + + test('`connect` successfully with the provided user', () async { + final user = OwnUser(id: 'test-user'); + const connectionId = 'test-connection-id'; + // Sends connect event to web-socket stream + final timer = Timer(const Duration(milliseconds: 300), () { + final event = Event( + type: EventType.healthCheck, + connectionId: connectionId, + me: user, + ); + webSocketSink.add(json.encode(event)); + }); + + expectLater( + webSocket.connectionStatusStream, + emitsInOrder([ + ConnectionStatus.disconnected, + ConnectionStatus.connecting, + ConnectionStatus.connected, + ]), + ); + + final event = await webSocket.connect(user); + + expect(event.type, EventType.healthCheck); + expect(event.connectionId, connectionId); + expect(event.me, isNotNull); + expect(event.me!.id, user.id); + + addTearDown(timer.cancel); + }); + + test('`connect` should throw if already in connection attempt', () async { + final user = OwnUser(id: 'test-user'); + webSocket.connect(user); + try { + // calling again before previous attempt finishes + await webSocket.connect(user); + } catch (e) { + expect(e, isA()); + } + }); + + test('`connect` should throw if `onMessage` contains error', () async { + final user = OwnUser(id: 'test-user'); + final error = ErrorResponse() + ..code = 333 + ..message = 'Invalid request'; + // Sends error event to web-socket stream + final timer = Timer(const Duration(milliseconds: 300), () { + webSocketSink.add(json.encode({'error': error})); + }); + + expectLater( + webSocket.connectionStatusStream, + emitsInOrder([ + ConnectionStatus.disconnected, + ConnectionStatus.connecting, + ConnectionStatus.disconnected, + ]), + ); + + try { + await webSocket.connect(user); + } catch (e) { + expect(e, isA()); + final err = e as StreamWebSocketError; + expect(err.code, error.code); + expect(err.message, error.message); + } + + addTearDown(timer.cancel); + }); + + test( + 'should `reconnect` automatically ' + 'if `onMessage` throws error after getting connected', + () async { + final user = OwnUser(id: 'test-user'); + const connectionId = 'test-connection-id'; + // Sends connect event to web-socket stream + final timer = Timer(const Duration(milliseconds: 300), () { + final event = Event( + type: EventType.healthCheck, + connectionId: connectionId, + me: user, + ); + webSocketSink.add(json.encode(event)); + }); + + expectLater( + webSocket.connectionStatusStream, + emitsInOrder([ + ConnectionStatus.disconnected, + ConnectionStatus.connecting, + ConnectionStatus.connected, + // starts reconnecting + ConnectionStatus.connecting, + ConnectionStatus.connected, + ]), + ); + + await webSocket.connect(user); + + final error = ErrorResponse() + ..code = 333 + ..message = 'Invalid request'; + // Sends error event to web-socket stream + webSocketSink.add(json.encode({'error': error})); + + final reconnectTimer = Timer(const Duration(seconds: 3), () { + final event = Event( + type: EventType.healthCheck, + connectionId: connectionId, + me: user, + ); + webSocketSink.add(json.encode(event)); + }); + + expect(webSocket.connectionId, connectionId); + + addTearDown(() { + timer.cancel(); + reconnectTimer.cancel(); + }); + }, + ); + + test( + '`onMessage` should handle `health.check` event if `me` is null', + () async { + final user = OwnUser(id: 'test-user'); + const connectionId = 'test-connection-id'; + // Sends connect event to web-socket stream + final timer = Timer(const Duration(milliseconds: 300), () { + final event = Event( + type: EventType.healthCheck, + connectionId: connectionId, + me: user, + ); + webSocketSink.add(json.encode(event)); + }); + + expectLater( + webSocket.connectionStatusStream, + emitsInOrder([ + ConnectionStatus.disconnected, + ConnectionStatus.connecting, + ConnectionStatus.connected, + ]), + ); + + final event = await webSocket.connect(user); + + expect(event.type, EventType.healthCheck); + expect(event.connectionId, connectionId); + expect(event.me, isNotNull); + expect(event.me!.id, user.id); + + const newConnectionId = 'new-connection-id'; + final healthCheckEvent = Event( + type: EventType.healthCheck, + connectionId: newConnectionId, + ); + webSocketSink.add(json.encode(healthCheckEvent)); + + await Future.delayed(const Duration(milliseconds: 300)); + + expectLater(webSocket.connectionId, newConnectionId); + + addTearDown(timer.cancel); + }, + ); + + test('should call `onConnectionError` if web-socket stream throws', () async { + final user = OwnUser(id: 'test-user'); + // Sends connect event to web-socket stream + final timer = Timer(const Duration(milliseconds: 300), () { + const error = StreamWebSocketError('test-error'); + webSocketSink.addError(error); + }); + + expectLater( + webSocket.connectionStatusStream, + emitsInOrder([ + ConnectionStatus.disconnected, + ConnectionStatus.connecting, + // throws error, reconnects + ConnectionStatus.connected, + ]), + ); + + webSocket.connect(user); + + // Assuming web-socket stream will add error + // and web-socket now trying to reconnect + await Future.delayed(const Duration(seconds: 3)); + + const connectionId = 'test-connection-id'; + // Sends connect event to web-socket stream + final event = Event( + type: EventType.healthCheck, + connectionId: connectionId, + me: user, + ); + webSocketSink.add(json.encode(event)); + + addTearDown(timer.cancel); + }); + + test( + 'should call `onConnectionClosed` if web-socket stream throws', + () async { + final user = OwnUser(id: 'test-user'); + // Sends connect event to web-socket stream + final timer = Timer(const Duration(milliseconds: 300), () { + webSocketSink.close(); + }); + + expectLater( + webSocket.connectionStatusStream, + emitsInOrder([ + ConnectionStatus.disconnected, + ConnectionStatus.connecting, + // throws error, reconnects + ConnectionStatus.connected, + ]), + ); + + webSocket.connect(user); + + // Assuming web-socket stream will add error + // and web-socket now trying to reconnect + await Future.delayed(const Duration(seconds: 3)); + + const connectionId = 'test-connection-id'; + // Sends connect event to web-socket stream + final event = Event( + type: EventType.healthCheck, + connectionId: connectionId, + me: user, + ); + webSocketSink.add(json.encode(event)); + + addTearDown(timer.cancel); + }, + ); + + test('`disconnect` successfully disconnects the current user', () async { + final user = OwnUser(id: 'test-user'); + const connectionId = 'test-connection-id'; + // Sends connect event to web-socket stream + final timer = Timer(const Duration(milliseconds: 300), () { + final event = Event( + type: EventType.healthCheck, + connectionId: connectionId, + me: user, + ); + webSocketSink.add(json.encode(event)); + }); + + expectLater( + webSocket.connectionStatusStream, + emitsInOrder([ + ConnectionStatus.disconnected, + ConnectionStatus.connecting, + ConnectionStatus.connected, + // after disconnect + ConnectionStatus.disconnected, + ]), + ); + + final event = await webSocket.connect(user); + + expect(event.type, EventType.healthCheck); + expect(event.connectionId, connectionId); + expect(event.me?.id, user.id); + + webSocket.disconnect(); + + addTearDown(timer.cancel); + }); +} diff --git a/packages/stream_chat/test/version_test.dart b/packages/stream_chat/test/version_test.dart index 55b1818a..34ecc750 100644 --- a/packages/stream_chat/test/version_test.dart +++ b/packages/stream_chat/test/version_test.dart @@ -1,8 +1,7 @@ import 'dart:io'; -import 'package:rxdart/rxdart.dart'; -import 'package:test/test.dart'; import 'package:stream_chat/version.dart'; +import 'package:test/test.dart'; void prepareTest() { // https://github.com/flutter/flutter/issues/20907 @@ -14,11 +13,12 @@ void prepareTest() { void main() { prepareTest(); test('stream chat version matches pubspec', () { - final String pubspecPath = '${Directory.current.path}/pubspec.yaml'; - final String pubspec = File(pubspecPath).readAsStringSync(); - final RegExp regex = RegExp('version:\s*(.*)'); - final RegExpMatch match = regex.firstMatch(pubspec); + final pubspecPath = '${Directory.current.path}/pubspec.yaml'; + final pubspec = File(pubspecPath).readAsStringSync(); + // ignore: unnecessary_string_escapes + final regex = RegExp('version:\s*(.*)'); + final match = regex.firstMatch(pubspec); expect(match, isNotNull); - expect(PACKAGE_VERSION, match.group(1).trim()); + expect(PACKAGE_VERSION, match?.group(1)?.trim()); }); } diff --git a/packages/stream_chat_flutter/.gitignore b/packages/stream_chat_flutter/.gitignore index 1d3bae26..fe82edc9 100644 --- a/packages/stream_chat_flutter/.gitignore +++ b/packages/stream_chat_flutter/.gitignore @@ -62,4 +62,7 @@ doc/api/ fvm google-services.json example/ios/dist -.vscode/ \ No newline at end of file +.vscode/ + +# don't check in golden failure output +**/failures/*.png \ No newline at end of file diff --git a/packages/stream_chat_flutter/CHANGELOG.md b/packages/stream_chat_flutter/CHANGELOG.md index 989c2c2b..e606d32d 100644 --- a/packages/stream_chat_flutter/CHANGELOG.md +++ b/packages/stream_chat_flutter/CHANGELOG.md @@ -1,3 +1,160 @@ +## 2.0.0 + +🛑️ Breaking Changes from `1.5.4` + +- Migrate this package to null safety +- Renamed `ChannelImage` to `ChannelAvatar` +- Updated `StreamChatThemeData.reactionIcons` to accept custom builder +- Renamed `ColorTheme` properties to reflect the purpose of the colors + - `ColorTheme.black` -> `ColorTheme.textHighEmphasis` + - `ColorTheme.grey` -> `ColorTheme.textLowEmphasis` + - `ColorTheme.greyGainsboro` -> `ColorTheme.disabled` + - `ColorTheme.greyWhisper` -> `ColorTheme.borders` + - `ColorTheme.whiteSmoke` -> `ColorTheme.inputBg` + - `ColorTheme.whiteSnow` -> `ColorTheme.appBg` + - `ColorTheme.white` -> `ColorTheme.barsBg` + - `ColorTheme.blueAlice` -> `ColorTheme.linkBg` + - `ColorTheme.accentBlue` -> `ColorTheme.accentPrimary` + - `ColorTheme.accentRed` -> `ColorTheme.accentError` + - `ColorTheme.accentGreen` -> `ColorTheme.accentInfo` + +- `ChannelListCore` options property is removed in favor of individual properties + - `options.state` -> bool state + - `options.watch` -> bool watch + - `options.presence` -> bool presence +- `UserListView` options property is removed in favor of individual properties + - `options.presence` -> bool presence +- Renamed `ImageHeader` to `GalleryHeader` +- Renamed `ImageFooter` to `GalleryFooter` +- `MessageBuilder` and `ParentMessageBuilder` signature is now + +```dart +typedef MessageBuilder = Widget Function( + BuildContext, + MessageDetails, + List, + MessageWidget defaultMessageWidget, + ); +``` + +the last parameter is the default `MessageWidget` +You can call `.copyWith` to customize just a subset of properties + + +✅ Added + +- Added video compress options (frame and quality) to `MessageInput` +- TypingIndicator now has a property called `parentId` to show typing indicator specific to threads +- [#493](https://github.com/GetStream/stream-chat-flutter/pull/493): add support for messageListView header/footer +- `MessageWidget` accepts a `userAvatarBuilder` +- Added pinMessage ui support +- Added `MessageListView.threadSeparatorBuilder` property +- Added `MessageInput.onError` property to allow error handling +- Added `GalleryHeader/GalleryFooter` theme classes + +🐞 Fixed + +- [#483](https://github.com/GetStream/stream-chat-flutter/issues/483): Keyboard covers input text box when editing + message +- Modals are shown using the nearest `Navigator` to make using the SDK easier in a nested navigator use case +- [#484](https://github.com/GetStream/stream-chat-flutter/issues/484): messages don't update without a reload +- `MessageListView` not rendering if the user is not a member of the channel +- Fix `MessageInput` overflow when there are no actions +- Minor fixes and improvements + +## 2.0.0-nullsafety.9 + +🛑️ Breaking Changes from `2.0.0-nullsafety.8` + +- Renamed `ColorTheme` properties to reflect the purpose of the colors + - `ColorTheme.black` -> `ColorTheme.textHighEmphasis` + - `ColorTheme.grey` -> `ColorTheme.textLowEmphasis` + - `ColorTheme.greyGainsboro` -> `ColorTheme.disabled` + - `ColorTheme.greyWhisper` -> `ColorTheme.borders` + - `ColorTheme.whiteSmoke` -> `ColorTheme.inputBg` + - `ColorTheme.whiteSnow` -> `ColorTheme.appBg` + - `ColorTheme.white` -> `ColorTheme.barsBg` + - `ColorTheme.blueAlice` -> `ColorTheme.linkBg` + - `ColorTheme.accentBlue` -> `ColorTheme.accentPrimary` + - `ColorTheme.accentRed` -> `ColorTheme.accentError` + - `ColorTheme.accentGreen` -> `ColorTheme.accentInfo` + +✅ Added + +- Added video compress options (frame and quality) to `MessageInput` + +## 2.0.0-nullsafety.8 + +🛑️ Breaking Changes from `2.0.0-nullsafety.7` + +- `ChannelListCore` options property is removed in favor of individual properties + - `options.state` -> bool state + - `options.watch` -> bool watch + - `options.presence` -> bool presence +- `UserListView` options property is removed in favor of individual properties + - `options.presence` -> bool presence +- `MessageBuilder` and `ParentMessageBuilder` signature is now + +```dart +typedef MessageBuilder = Widget Function( + BuildContext, + MessageDetails, + List, + MessageWidget defaultMessageWidget, + ); +``` + +the last parameter is the default `MessageWidget` +You can call `.copyWith` to customize just a subset of properties + +✅ Added + +- TypingIndicator now has a property called `parentId` to show typing indicator specific to threads +- [#493](https://github.com/GetStream/stream-chat-flutter/pull/493): add support for messageListView header/footer +- `MessageWidget` accepts a `userAvatarBuilder` + +🐞 Fixed + +- [#483](https://github.com/GetStream/stream-chat-flutter/issues/483): Keyboard covers input text box when editing + message +- Modals are shown using the nearest `Navigator` to make using the SDK easier in a nested navigator use case +- [#484](https://github.com/GetStream/stream-chat-flutter/issues/484): messages don't update without a reload +- `MessageListView` not rendering if the user is not a member of the channel + +## 2.0.0-nullsafety.7 + +- Minor fixes and improvements +- Updated `stream_chat_core` dependency +- Fixed a bug with connectivity implementation + +## 2.0.0-nullsafety.6 + +- Minor fixes and improvements +- Updated `stream_chat_core` dependency +- 🛑 **BREAKING** Updated StreamChatThemeData.reactionIcons to accept custom builder + +## 2.0.0-nullsafety.5 + +- Minor fixes and improvements +- Updated `stream_chat_core` dependency +- Performance improvements +- Added pinMessage ui support +- Added `MessageListView.threadSeparatorBuilder` property + +## 2.0.0-nullsafety.4 + +- Minor fixes and improvements +- Updated `stream_chat_core` dependency +- Improved performance of `MessageWidget` component + +## 2.0.0-nullsafety.3 + +- Fix MessageInput overflow when there are no actions + +## 2.0.0-nullsafety.2 + +- Migrate this package to null safety + ## 1.5.4 - Updated `stream_chat_core` dependency @@ -105,7 +262,8 @@ ## 0.2.20+2 -- Added `shouldAddChannel` to ChannelsBloc in order to check if a channel has to be added to the list when a new message arrives +- Added `shouldAddChannel` to ChannelsBloc in order to check if a channel has to be added to the list when a new message + arrives ## 0.2.20+1 @@ -324,27 +482,27 @@ ## 0.2.1-alpha+1 -- Removed the additional `Navigator` in `StreamChat` widget. - It was added to make the app have the `StreamChat` widget as ancestor in every route. - Now the recommended way to add `StreamChat` to your app is using the `builder` property of your `MaterialApp` widget. - Otherwise you can use it in the usual way, but you need to add a `StreamChat` widget to every route of your app. - Read [this issue](https://github.com/GetStream/stream-chat-flutter/issues/47) for more information. +- Removed the additional `Navigator` in `StreamChat` widget. It was added to make the app have the `StreamChat` widget + as ancestor in every route. Now the recommended way to add `StreamChat` to your app is using the `builder` property of + your `MaterialApp` widget. Otherwise you can use it in the usual way, but you need to add a `StreamChat` widget to + every route of your app. Read [this issue](https://github.com/GetStream/stream-chat-flutter/issues/47) for more + information. ```dart @override - Widget build(BuildContext context) { - return MaterialApp( - theme: ThemeData.light(), - darkTheme: ThemeData.dark(), - themeMode: ThemeMode.system, - builder: (context, widget) { - return StreamChat( - child: widget, - client: client, - ); - }, - home: ChannelListPage(), - ); +Widget build(BuildContext context) { + return MaterialApp( + theme: ThemeData.light(), + darkTheme: ThemeData.dark(), + themeMode: ThemeMode.system, + builder: (context, widget) { + return StreamChat( + child: widget, + client: client, + ); + }, + home: ChannelListPage(), + ); ``` - Fix reaction bubble going below previous message on iOS @@ -428,7 +586,8 @@ - Add gesture (vertical drag down) to close the keyboard -- Add keyboard type parameters (set it to TextInputType.text to show the submit button that will even close the keyboard) +- Add keyboard type parameters (set it to TextInputType.text to show the submit button that will even close the + keyboard) The property showVideoFullScreen was added mainly because of this issue brianegan/chewie#261 diff --git a/packages/stream_chat_flutter/analysis_options.yaml b/packages/stream_chat_flutter/analysis_options.yaml deleted file mode 100644 index 36d1fbb7..00000000 --- a/packages/stream_chat_flutter/analysis_options.yaml +++ /dev/null @@ -1,63 +0,0 @@ -include: package:pedantic/analysis_options.yaml - -analyzer: - enable-experiment: - - extension-methods - exclude: - - lib/**/*.g.dart - - example/** - -linter: - rules: - # these rules are documented on and in the same order as - # the Dart Lint rules page to make maintenance easier - # https://github.com/dart-lang/linter/blob/master/example/all.yaml - # - always_declare_return_types - # - always_specify_types - # - annotate_overrides - # - avoid_as - - avoid_empty_else - - avoid_init_to_null - - avoid_return_types_on_setters - - avoid_web_libraries_in_flutter - - await_only_futures - - camel_case_types - - cancel_subscriptions - - close_sinks - # - comment_references # we do not presume as to what people want to reference in their dartdocs - # - constant_identifier_names # https://github.com/dart-lang/linter/issues/204 - - control_flow_in_finally - - empty_constructor_bodies - - empty_statements - - hash_and_equals - - implementation_imports - # - invariant_booleans - # - iterable_contains_unrelated_type - - library_names - # - library_prefixes - # - list_remove_unrelated_type - # - literal_only_boolean_expressions - - non_constant_identifier_names - # - one_member_abstracts - # - only_throw_errors - # - overridden_fields -# - package_api_docs - - package_names - - package_prefixed_library_names - - prefer_is_not_empty - # - prefer_mixin # https://github.com/dart-lang/language/issues/32 - # - public_member_api_docs - - slash_for_doc_comments - # - sort_constructors_first - # - sort_unnamed_constructors_first - # - super_goes_last # no longer needed w/ Dart 2 - - test_types_in_equals - - throw_in_finally - # - type_annotate_public_apis # subset of always_specify_types - - type_init_formals - # - unawaited_futures - - unnecessary_brace_in_string_interps - - unnecessary_getters_setters - - unnecessary_statements - - unrelated_type_equality_checks - - valid_regexps diff --git a/packages/stream_chat_flutter/example/.fvm/flutter_sdk b/packages/stream_chat_flutter/example/.fvm/flutter_sdk deleted file mode 120000 index cdf17889..00000000 --- a/packages/stream_chat_flutter/example/.fvm/flutter_sdk +++ /dev/null @@ -1 +0,0 @@ -/Users/salvatoregiordano/fvm/versions/beta \ No newline at end of file diff --git a/packages/stream_chat_flutter/example/.fvm/fvm_config.json b/packages/stream_chat_flutter/example/.fvm/fvm_config.json deleted file mode 100644 index 6504dcd0..00000000 --- a/packages/stream_chat_flutter/example/.fvm/fvm_config.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "flutterSdkVersion": "beta" -} \ No newline at end of file diff --git a/packages/stream_chat_flutter/example/lib/main.dart b/packages/stream_chat_flutter/example/lib/main.dart index 731ef080..89362d81 100644 --- a/packages/stream_chat_flutter/example/lib/main.dart +++ b/packages/stream_chat_flutter/example/lib/main.dart @@ -2,99 +2,108 @@ import 'package:flutter/material.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; import 'package:stream_chat_persistence/stream_chat_persistence.dart'; +/// A chat-persisted StreamChatClient final chatPersistentClient = StreamChatPersistenceClient( logLevel: Level.INFO, - connectionMode: ConnectionMode.background, ); void main() async { WidgetsFlutterBinding.ensureInitialized(); - /// Create a new instance of [StreamChatClient] passing the apikey obtained from your - /// project dashboard. + /// Create a new instance of [StreamChatClient] passing the apikey obtained + /// from your project dashboard. final client = StreamChatClient( 's2dxdhpxd94g', logLevel: Level.INFO, )..chatPersistenceClient = chatPersistentClient; - /// Set the current user and connect the websocket. In a production scenario, this should be done using - /// a backend to generate a user token using our server SDK. + /// Set the current user and connect the websocket. In a production + /// scenario, this should be done using a backend to generate a user token + /// using our server SDK. + /// /// Please see the following for more information: /// https://getstream.io/chat/docs/ios_user_setup_and_tokens/ await client.connectUser( User(id: 'super-band-9'), - 'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VyX2lkIjoic3VwZXItYmFuZC05In0.0L6lGoeLwkz0aZRUcpZKsvaXtNEDHBcezVTZ0oPq40A', + 'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VyX2lkIjoic3VwZXItYmFuZC05In0.' + '0L6lGoeLwkz0aZRUcpZKsvaXtNEDHBcezVTZ0oPq40A', ); final channel = client.channel('messaging', id: 'godevs'); await channel.watch(); - runApp(MyApp(client, channel)); + runApp( + MyApp( + client: client, + channel: channel, + ), + ); } /// Example application using Stream Chat Flutter widgets. -/// Stream Chat Flutter is a set of Flutter widgets which provide full chat functionalities -/// for building Flutter applications using Stream. -/// If you'd prefer using minimal wrapper widgets for your app, please see our other +/// +/// Stream Chat Flutter is a set of Flutter widgets which provide full chat +/// functionalities for building Flutter applications using Stream. If you'd +/// prefer using minimal wrapper widgets for your app, please see our other /// package, `stream_chat_flutter_core`. class MyApp extends StatelessWidget { + /// Example using Stream's Flutter package. + /// + /// If you'd prefer using minimal wrapper widgets for your app, please see + /// our other package, `stream_chat_flutter_core`. + const MyApp({ + Key? key, + required this.client, + required this.channel, + }) : super(key: key); + /// Instance of Stream Client. - /// Stream's [StreamChatClient] can be used to connect to our servers and set the default - /// user for the application. Performing these actions trigger a websocket connection - /// allowing for real-time updates. + /// + /// Stream's [StreamChatClient] can be used to connect to our servers and + /// set the default user for the application. Performing these actions + /// trigger a websocket connection allowing for real-time updates. final StreamChatClient client; /// Instance of the Channel final Channel channel; - /// Example using Stream's Flutter package. - /// If you'd prefer using minimal wrapper widgets for your app, please see our other - /// package, `stream_chat_flutter_core`. - MyApp(this.client, this.channel); - @override - Widget build(BuildContext context) { - return MaterialApp( - theme: ThemeData.light(), - darkTheme: ThemeData.dark(), - themeMode: ThemeMode.system, - builder: (context, widget) { - return StreamChat( - child: widget, + Widget build(BuildContext context) => MaterialApp( + theme: ThemeData.light(), + darkTheme: ThemeData.dark(), + builder: (context, widget) => StreamChat( client: client, - ); - }, - home: StreamChannel( - channel: channel, - child: ChannelPage(), - ), - ); - } + child: widget, + ), + home: StreamChannel( + channel: channel, + child: const ChannelPage(), + ), + ); } /// A list of messages sent in the current channel. /// -/// This is implemented using [MessageListView], a widget that provides query functionalities -/// fetching the messages from the api and showing them in a listView +/// This is implemented using [MessageListView], a widget that provides query +/// functionalities fetching the messages from the api and showing them in a +/// listView. class ChannelPage extends StatelessWidget { /// Creates the page that shows the list of messages const ChannelPage({ - Key key, + Key? key, }) : super(key: key); @override - Widget build(BuildContext context) { - return Scaffold( - appBar: ChannelHeader(), - body: Column( - children: [ - Expanded( - child: MessageListView(), - ), - MessageInput(), - ], - ), - ); - } + Widget build(BuildContext context) => Scaffold( + appBar: const ChannelHeader(), + body: Column( + children: const [ + Expanded( + child: MessageListView(), + ), + MessageInput(), + ], + ), + ); } diff --git a/packages/stream_chat_flutter/example/lib/split_view.dart b/packages/stream_chat_flutter/example/lib/split_view.dart index 2026b101..454071a6 100644 --- a/packages/stream_chat_flutter/example/lib/split_view.dart +++ b/packages/stream_chat_flutter/example/lib/split_view.dart @@ -10,131 +10,131 @@ void main() async { await client.connectUser( User(id: 'super-band-9'), - 'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VyX2lkIjoic3VwZXItYmFuZC05In0.0L6lGoeLwkz0aZRUcpZKsvaXtNEDHBcezVTZ0oPq40A', + '''eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VyX2lkIjoic3VwZXItYmFuZC05In0.0L6lGoeLwkz0aZRUcpZKsvaXtNEDHBcezVTZ0oPq40A''', ); - runApp(MyApp(client)); + runApp( + MyApp( + client: client, + ), + ); } class MyApp extends StatelessWidget { + const MyApp({ + Key? key, + required this.client, + }) : super(key: key); + final StreamChatClient client; - MyApp(this.client); - @override - Widget build(BuildContext context) { - return MaterialApp( - builder: (context, child) => StreamChat( - child: child, - client: client, - ), - home: SplitView(), - ); - } + Widget build(BuildContext context) => MaterialApp( + builder: (context, child) => StreamChat( + client: client, + child: child, + ), + home: const SplitView(), + ); } class SplitView extends StatefulWidget { + const SplitView({ + Key? key, + }) : super(key: key); + @override _SplitViewState createState() => _SplitViewState(); } class _SplitViewState extends State { - Channel selectedChannel; + Channel? selectedChannel; @override - Widget build(BuildContext context) { - return Flex( - direction: Axis.horizontal, - children: [ - Flexible( - child: ChannelListPage( - onTap: (channel) { - setState(() { - selectedChannel = channel; - }); - }, + Widget build(BuildContext context) => Flex( + direction: Axis.horizontal, + children: [ + Flexible( + child: ChannelListPage( + onTap: (channel) { + setState(() { + selectedChannel = channel; + }); + }, + ), ), - flex: 1, - ), - Flexible( - child: Scaffold( - body: selectedChannel != null - ? StreamChannel( - key: ValueKey(selectedChannel.cid), - child: ChannelPage(), - channel: selectedChannel, - ) - : Center( - child: Text( - 'Pick a channel to show the messages 💬', - style: Theme.of(context).textTheme.headline5, + Flexible( + flex: 2, + child: Scaffold( + body: selectedChannel != null + ? StreamChannel( + key: ValueKey(selectedChannel!.cid), + channel: selectedChannel!, + child: const ChannelPage(), + ) + : Center( + child: Text( + 'Pick a channel to show the messages 💬', + style: Theme.of(context).textTheme.headline5, + ), ), - ), + ), ), - flex: 2, - ), - ], - ); - } + ], + ); } class ChannelListPage extends StatelessWidget { - final void Function(Channel) onTap; + const ChannelListPage({ + Key? key, + this.onTap, + }) : super(key: key); - ChannelListPage({this.onTap}); + final void Function(Channel)? onTap; @override - Widget build(BuildContext context) { - return Scaffold( - body: ChannelsBloc( - child: ChannelListView( - onChannelTap: onTap != null - ? (channel, _) { - onTap(channel); - } - : null, - filter: { - 'members': { - '\$in': [StreamChat.of(context).user.id], - } - }, - sort: [SortOption('last_message_at')], - pagination: PaginationParams( - limit: 20, + Widget build(BuildContext context) => Scaffold( + body: ChannelsBloc( + child: ChannelListView( + onChannelTap: onTap != null + ? (channel, _) { + onTap!(channel); + } + : null, + filter: Filter.in_( + 'members', + [StreamChat.of(context).user!.id], + ), + sort: const [SortOption('last_message_at')], + pagination: const PaginationParams( + limit: 20, + ), ), ), - ), - ); - } + ); } class ChannelPage extends StatelessWidget { const ChannelPage({ - Key key, + Key? key, }) : super(key: key); @override - Widget build(BuildContext context) { - return Navigator( - onGenerateRoute: (settings) { - return MaterialPageRoute( - builder: (context) { - return Scaffold( - appBar: ChannelHeader( - showBackButton: false, - ), - body: Column( - children: [ - Expanded( - child: MessageListView(), - ), - MessageInput(), - ], - ), - ); - }, - ); - }, - ); - } + Widget build(BuildContext context) => Navigator( + onGenerateRoute: (settings) => MaterialPageRoute( + builder: (context) => Scaffold( + appBar: const ChannelHeader( + showBackButton: false, + ), + body: Column( + children: const [ + Expanded( + child: MessageListView(), + ), + MessageInput(), + ], + ), + ), + ), + ); } diff --git a/packages/stream_chat_flutter/example/lib/tutorial-part-1.dart b/packages/stream_chat_flutter/example/lib/tutorial_part_1.dart similarity index 66% rename from packages/stream_chat_flutter/example/lib/tutorial-part-1.dart rename to packages/stream_chat_flutter/example/lib/tutorial_part_1.dart index 1fd53f55..459d8690 100644 --- a/packages/stream_chat_flutter/example/lib/tutorial-part-1.dart +++ b/packages/stream_chat_flutter/example/lib/tutorial_part_1.dart @@ -4,25 +4,30 @@ import 'package:stream_chat_flutter/stream_chat_flutter.dart'; /// First step of the [tutorial](https://getstream.io/chat/flutter/tutorial/) /// -/// There are three important things to notice that are common to all Flutter application using StreamChat: +/// There are three important things to notice that are common to all Flutter +/// application using StreamChat: /// /// 1. The Dart API [StreamChatClient] is initialized with your API Key /// 2. The current user is set by calling [StreamChatClient.connectUser] /// 3. The client is then passed to the top-level [StreamChat] widget -/// [StreamChat] is an inherited widget and must be the parent of all Chat related widgets. +/// [StreamChat] is an inherited widget and must be the parent of all +/// Chat related widgets. /// -/// Please note that while Flutter can be used to build both mobile and web applications; -/// in this tutorial we focus on mobile, make sure when running the app you use a mobile device. +/// Please note that while Flutter can be used to build both mobile and web +/// applications, in this tutorial we focus on mobile. Make sure when running +/// the app that you use a mobile device. /// /// Let's have a look at what we've built: /// /// - We set up the Chat [StreamChatClient] with the API key /// -/// - We set the the current user for Chat with [StreamChatClient.connectUser] and a pre-generated user token +/// - We set the the current user for Chat with [StreamChatClient.connectUser] +/// and a pre-generated user token /// /// - We make [StreamChat] the root Widget of our application /// -/// - We create a single [ChannelPage] widget under [StreamChat] with three widgets: [ChannelHeader], [MessageListView] and [MessageInput] +/// - We create a single [ChannelPage] widget under [StreamChat] with three +/// widgets: [ChannelHeader], [MessageListView] and [MessageInput] /// /// If you now run the simulator you will see a single channel UI. void main() async { @@ -33,35 +38,47 @@ void main() async { await client.connectUser( User(id: 'super-band-9'), - 'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VyX2lkIjoic3VwZXItYmFuZC05In0.0L6lGoeLwkz0aZRUcpZKsvaXtNEDHBcezVTZ0oPq40A', + '''eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VyX2lkIjoic3VwZXItYmFuZC05In0.0L6lGoeLwkz0aZRUcpZKsvaXtNEDHBcezVTZ0oPq40A''', ); final channel = client.channel('messaging', id: 'godevs'); - // ignore: unawaited_futures + // ignore: unawaited_futures, cascade_invocations channel.watch(); - runApp(MyApp(client, channel)); + runApp( + MyApp( + client: client, + channel: channel, + ), + ); } class MyApp extends StatelessWidget { + const MyApp({ + Key? key, + required this.client, + required this.channel, + }) : super(key: key); + final StreamChatClient client; + final Channel channel; - MyApp(this.client, this.channel); - @override + // ignore: prefer_expression_function_bodies Widget build(BuildContext context) { return MaterialApp( + // ignore: prefer_expression_function_bodies builder: (context, widget) { return StreamChat( - child: widget, client: client, + child: widget, ); }, home: StreamChannel( channel: channel, - child: ChannelPage(), + child: const ChannelPage(), ), ); } @@ -69,15 +86,16 @@ class MyApp extends StatelessWidget { class ChannelPage extends StatelessWidget { const ChannelPage({ - Key key, + Key? key, }) : super(key: key); @override + // ignore: prefer_expression_function_bodies Widget build(BuildContext context) { return Scaffold( - appBar: ChannelHeader(), + appBar: const ChannelHeader(), body: Column( - children: [ + children: const [ Expanded( child: MessageListView(), ), diff --git a/packages/stream_chat_flutter/example/lib/tutorial-part-2.dart b/packages/stream_chat_flutter/example/lib/tutorial_part_2.dart similarity index 50% rename from packages/stream_chat_flutter/example/lib/tutorial-part-2.dart rename to packages/stream_chat_flutter/example/lib/tutorial_part_2.dart index 8b3009c8..79f8674c 100644 --- a/packages/stream_chat_flutter/example/lib/tutorial-part-2.dart +++ b/packages/stream_chat_flutter/example/lib/tutorial_part_2.dart @@ -5,20 +5,29 @@ import 'package:stream_chat_flutter/stream_chat_flutter.dart'; /// Second step of the [tutorial](https://getstream.io/chat/flutter/tutorial/) /// /// Most chat applications handle more than just one single conversation. -/// Apps like Facebook Messenger, Whatsapp and Telegram allows you to have multiple one to one and group conversations. +/// Apps like Facebook Messenger, Whatsapp and Telegram allows you to have +/// multiple one-to-one and group conversations. /// -/// Let’s find out how we can change our application chat screen to display the list of conversations and navigate between them. +/// Let’s find out how we can change our application chat screen to display +/// the list of conversations and navigate between them. /// -/// > Note: the SDK uses Flutter’s [Navigator] to move from one route to another, this allows us to avoid any boiler-plate code. -/// > Of course you can take total control of how navigation works by customizing widgets like [Channel] and [ChannelList]. +/// > Note: the SDK uses Flutter’s [Navigator] to move from one route to +/// another. This allows us to avoid any boiler-plate code. +/// > Of course, you can take total control of how navigation works by +/// customizing widgets like [Channel] and [ChannelList]. /// -/// If you run the application, you will see that the first screen shows a list of conversations, you can open each by tapping and go back to the list. +/// If you run the application, you will see that the first screen shows a +/// list of conversations, you can open each by tapping and go back to the list. /// -/// Every single widget involved in this UI can be customized or swapped with your own. +/// Every single widget involved in this UI can be customized or swapped +/// with your own. /// -/// The [ChannelListPage] widget retrieves the list of channels based on a custom query and ordering. -/// In this case we are showing the list of channels the current user is a member and we order them based on the time they had a new message. -/// [ChannelListView] handles pagination and updates automatically out of the box when new channels are created or when a new message is added to a channel. +/// The [ChannelListPage] widget retrieves the list of channels based on a +/// custom query and ordering. In this case we are showing the list of +/// channels in which the current user is a member and we order them based +/// on the time they had a new message. [ChannelListView] handles pagination +/// and updates automatically when new channels are created or when a new +/// message is added to a channel. void main() async { final client = StreamChatClient( 's2dxdhpxd94g', @@ -27,45 +36,57 @@ void main() async { await client.connectUser( User(id: 'super-band-9'), - 'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VyX2lkIjoic3VwZXItYmFuZC05In0.0L6lGoeLwkz0aZRUcpZKsvaXtNEDHBcezVTZ0oPq40A', + '''eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VyX2lkIjoic3VwZXItYmFuZC05In0.0L6lGoeLwkz0aZRUcpZKsvaXtNEDHBcezVTZ0oPq40A''', ); - runApp(MyApp(client)); + runApp( + MyApp( + client: client, + ), + ); } class MyApp extends StatelessWidget { + const MyApp({ + Key? key, + required this.client, + }) : super(key: key); + final StreamChatClient client; - MyApp(this.client); - @override + // ignore: prefer_expression_function_bodies Widget build(BuildContext context) { return MaterialApp( builder: (context, child) => StreamChat( client: client, child: child, ), - home: ChannelListPage(), + home: const ChannelListPage(), ); } } class ChannelListPage extends StatelessWidget { + const ChannelListPage({ + Key? key, + }) : super(key: key); + @override + // ignore: prefer_expression_function_bodies Widget build(BuildContext context) { return Scaffold( body: ChannelsBloc( child: ChannelListView( - filter: { - // 'members': { - // '\$in': [StreamChat.of(context).user.id], - // } - }, - sort: [SortOption('last_message_at')], - pagination: PaginationParams( + filter: Filter.in_( + 'members', + [StreamChat.of(context).user!.id], + ), + sort: const [SortOption('last_message_at')], + pagination: const PaginationParams( limit: 20, ), - channelWidget: ChannelPage(), + channelWidget: const ChannelPage(), ), ), ); @@ -74,15 +95,16 @@ class ChannelListPage extends StatelessWidget { class ChannelPage extends StatelessWidget { const ChannelPage({ - Key key, + Key? key, }) : super(key: key); @override + // ignore: prefer_expression_function_bodies Widget build(BuildContext context) { return Scaffold( - appBar: ChannelHeader(), + appBar: const ChannelHeader(), body: Column( - children: [ + children: const [ Expanded( child: MessageListView(), ), diff --git a/packages/stream_chat_flutter/example/lib/tutorial-part-3.dart b/packages/stream_chat_flutter/example/lib/tutorial_part_3.dart similarity index 59% rename from packages/stream_chat_flutter/example/lib/tutorial-part-3.dart rename to packages/stream_chat_flutter/example/lib/tutorial_part_3.dart index 3a34dc01..78081c85 100644 --- a/packages/stream_chat_flutter/example/lib/tutorial-part-3.dart +++ b/packages/stream_chat_flutter/example/lib/tutorial_part_3.dart @@ -1,26 +1,35 @@ // ignore_for_file: public_member_api_docs +import 'package:collection/collection.dart' show IterableExtension; import 'package:flutter/material.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; /// Third step of the [tutorial](https://getstream.io/chat/flutter/tutorial/) /// /// So far you’ve learned how to use the default widgets. -/// The library has been designed with composition in mind and to allow all common customizations to be very easy. -/// This means that you can change any component in your application by swapping the default widgets with the ones you build yourself. +/// The library has been designed with composition in mind and to allow all +/// common customizations to be very easy. +/// This means that you can change any component in your application by +/// swapping the default widgets with the ones you build yourself. /// /// Let’s see how we can make some changes to the SDK’s UI components. -/// We start by changing how channel previews are shown in the channel list and include the number of unread messages for each. +/// We start by changing how channel previews are shown in the channel list +/// and include the number of unread messages for each. /// -/// We're passing a custom widget to [ChannelListView.channelPreviewBuilder], this will override the default [ChannelPreview] and allows you to create one yourself. +/// We're passing a custom widget to [ChannelListView.channelPreviewBuilder]; +/// this will override the default [ChannelPreview] and allows you to create +/// one yourself. /// /// There are a couple interesting things we do in this widget: /// -/// - Instead of creating a whole new style for the channel name, we inherit the text style from the parent theme ([StreamChatTheme.of]) and only change the color attribute +/// - Instead of creating a whole new style for the channel name, we inherit +/// the text style from the parent theme ([StreamChatTheme.of]) and only +/// change the color attribute /// -/// - We loop over the list of channel messages to search for the first not deleted message ([Channel.state.messages]) +/// - We loop over the list of channel messages to search for the first not +/// deleted message ([Channel.state.messages]) /// /// - We retrieve the count of unread messages from [Channel.state] -void main() async { +Future main() async { final client = StreamChatClient( 's2dxdhpxd94g', logLevel: Level.INFO, @@ -28,59 +37,70 @@ void main() async { await client.connectUser( User(id: 'super-band-9'), - 'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VyX2lkIjoic3VwZXItYmFuZC05In0.0L6lGoeLwkz0aZRUcpZKsvaXtNEDHBcezVTZ0oPq40A', + '''eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VyX2lkIjoic3VwZXItYmFuZC05In0.0L6lGoeLwkz0aZRUcpZKsvaXtNEDHBcezVTZ0oPq40A''', ); - runApp(MyApp(client)); + runApp( + MyApp( + client: client, + ), + ); } class MyApp extends StatelessWidget { + const MyApp({ + Key? key, + required this.client, + }) : super(key: key); + final StreamChatClient client; - MyApp(this.client); - @override + // ignore: prefer_expression_function_bodies Widget build(BuildContext context) { return MaterialApp( builder: (context, child) => StreamChat( - child: child, client: client, + child: child, ), - home: ChannelListPage(), + home: const ChannelListPage(), ); } } class ChannelListPage extends StatelessWidget { + const ChannelListPage({ + Key? key, + }) : super(key: key); + @override + // ignore: prefer_expression_function_bodies Widget build(BuildContext context) { return Scaffold( body: ChannelsBloc( child: ChannelListView( - filter: { - 'members': { - '\$in': [StreamChat.of(context).user.id], - } - }, + filter: Filter.in_( + 'members', + [StreamChat.of(context).user!.id], + ), channelPreviewBuilder: _channelPreviewBuilder, // sort: [SortOption('last_message_at')], - pagination: PaginationParams( + pagination: const PaginationParams( limit: 20, ), - channelWidget: ChannelPage(), + channelWidget: const ChannelPage(), ), ), ); } Widget _channelPreviewBuilder(BuildContext context, Channel channel) { - final lastMessage = channel.state.messages.reversed.firstWhere( + final lastMessage = channel.state?.messages.reversed.firstWhereOrNull( (message) => !message.isDeleted, - orElse: () => null, ); - final subtitle = (lastMessage == null ? 'nothing yet' : lastMessage.text); - final opacity = channel.state.unreadCount > .0 ? 1.0 : 0.5; + final subtitle = lastMessage == null ? 'nothing yet' : lastMessage.text!; + final opacity = (channel.state?.unreadCount ?? 0) > 0 ? 1.0 : 0.5; return ListTile( onTap: () { @@ -88,46 +108,47 @@ class ChannelListPage extends StatelessWidget { context, MaterialPageRoute( builder: (_) => StreamChannel( - child: ChannelPage(), channel: channel, + child: const ChannelPage(), ), ), ); }, - leading: ChannelImage( + leading: ChannelAvatar( channel: channel, ), title: ChannelName( textStyle: - StreamChatTheme.of(context).channelPreviewTheme.title.copyWith( + StreamChatTheme.of(context).channelPreviewTheme.title!.copyWith( color: StreamChatTheme.of(context) .colorTheme - .black + .textHighEmphasis .withOpacity(opacity), ), ), subtitle: Text(subtitle), - trailing: channel.state.unreadCount > 0 + trailing: channel.state!.unreadCount > 0 ? CircleAvatar( radius: 10, - child: Text(channel.state.unreadCount.toString()), + child: Text(channel.state!.unreadCount.toString()), ) - : SizedBox(), + : const SizedBox(), ); } } class ChannelPage extends StatelessWidget { const ChannelPage({ - Key key, + Key? key, }) : super(key: key); @override + // ignore: prefer_expression_function_bodies Widget build(BuildContext context) { return Scaffold( - appBar: ChannelHeader(), + appBar: const ChannelHeader(), body: Column( - children: [ + children: const [ Expanded( child: MessageListView(), ), diff --git a/packages/stream_chat_flutter/example/lib/tutorial-part-4.dart b/packages/stream_chat_flutter/example/lib/tutorial_part_4.dart similarity index 55% rename from packages/stream_chat_flutter/example/lib/tutorial-part-4.dart rename to packages/stream_chat_flutter/example/lib/tutorial_part_4.dart index 5bdd5472..07942302 100644 --- a/packages/stream_chat_flutter/example/lib/tutorial-part-4.dart +++ b/packages/stream_chat_flutter/example/lib/tutorial_part_4.dart @@ -4,13 +4,17 @@ import 'package:stream_chat_flutter/stream_chat_flutter.dart'; /// Fourth step of the [tutorial](https://getstream.io/chat/flutter/tutorial/) /// -/// Stream Chat supports message threads out of the box. Threads allows users to create sub-conversations inside the same channel. +/// Stream Chat supports message threads out of the box. Threads allows users +/// to create sub-conversations inside the same channel. /// -/// Using threaded conversations is very simple and mostly a matter of plugging the [MessageListView] to another widget that renders the widget. -/// To make this simple, such a widget only needs to build [MessageListView] with the parent attribute set to the thread’s root message. +/// Using threaded conversations is very simple and mostly a matter of +/// plugging the [MessageListView] to another widget that renders the widget. +/// To make this simple, such a widget only needs to build [MessageListView] +/// with the parent attribute set to the thread’s root message. /// -/// Now we can open threads and create new ones as well, if you long press a message you can tap on Reply and it will open the same [ThreadPage]. -void main() async { +/// Now we can open threads and create new ones as well. If you long-press a +/// message, you can tap on "Reply" and it will open the same [ThreadPage]. +Future main() async { final client = StreamChatClient( 's2dxdhpxd94g', logLevel: Level.INFO, @@ -18,47 +22,57 @@ void main() async { await client.connectUser( User(id: 'super-band-9'), - 'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VyX2lkIjoic3VwZXItYmFuZC05In0.0L6lGoeLwkz0aZRUcpZKsvaXtNEDHBcezVTZ0oPq40A', + '''eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VyX2lkIjoic3VwZXItYmFuZC05In0.0L6lGoeLwkz0aZRUcpZKsvaXtNEDHBcezVTZ0oPq40A''', ); - runApp(MyApp(client)); + runApp( + MyApp( + client: client, + ), + ); } class MyApp extends StatelessWidget { + const MyApp({ + Key? key, + required this.client, + }) : super(key: key); + final StreamChatClient client; - MyApp(this.client); - @override + // ignore: prefer_expression_function_bodies Widget build(BuildContext context) { return MaterialApp( builder: (context, child) => StreamChat( - child: child, client: client, + child: child, ), - home: Container( - child: ChannelListPage(), - ), + home: const ChannelListPage(), ); } } class ChannelListPage extends StatelessWidget { + const ChannelListPage({ + Key? key, + }) : super(key: key); + @override + // ignore: prefer_expression_function_bodies Widget build(BuildContext context) { return Scaffold( body: ChannelsBloc( child: ChannelListView( - filter: { - 'members': { - '\$in': [StreamChat.of(context).user.id], - } - }, - sort: [SortOption('last_message_at')], - pagination: PaginationParams( + filter: Filter.in_( + 'members', + [StreamChat.of(context).user!.id], + ), + sort: const [SortOption('last_message_at')], + pagination: const PaginationParams( limit: 20, ), - channelWidget: ChannelPage(), + channelWidget: const ChannelPage(), ), ), ); @@ -67,25 +81,24 @@ class ChannelListPage extends StatelessWidget { class ChannelPage extends StatelessWidget { const ChannelPage({ - Key key, + Key? key, }) : super(key: key); @override + // ignore: prefer_expression_function_bodies Widget build(BuildContext context) { return Scaffold( - appBar: ChannelHeader(), + appBar: const ChannelHeader(), body: Column( children: [ Expanded( child: MessageListView( - threadBuilder: (_, parentMessage) { - return ThreadPage( - parent: parentMessage, - ); - }, + threadBuilder: (_, parentMessage) => ThreadPage( + parent: parentMessage, + ), ), ), - MessageInput(), + const MessageInput(), ], ), ); @@ -93,18 +106,19 @@ class ChannelPage extends StatelessWidget { } class ThreadPage extends StatelessWidget { - final Message parent; - - ThreadPage({ - Key key, + const ThreadPage({ + Key? key, this.parent, }) : super(key: key); + final Message? parent; + @override + // ignore: prefer_expression_function_bodies Widget build(BuildContext context) { return Scaffold( appBar: ThreadHeader( - parent: parent, + parent: parent!, ), body: Column( children: [ diff --git a/packages/stream_chat_flutter/example/lib/tutorial-part-5.dart b/packages/stream_chat_flutter/example/lib/tutorial_part_5.dart similarity index 54% rename from packages/stream_chat_flutter/example/lib/tutorial-part-5.dart rename to packages/stream_chat_flutter/example/lib/tutorial_part_5.dart index 18ae5ff8..dbbf31a0 100644 --- a/packages/stream_chat_flutter/example/lib/tutorial-part-5.dart +++ b/packages/stream_chat_flutter/example/lib/tutorial_part_5.dart @@ -4,18 +4,23 @@ import 'package:stream_chat_flutter/stream_chat_flutter.dart'; /// Fifth step of the [tutorial](https://getstream.io/chat/flutter/tutorial/) /// -/// Customizing how messages are rendered is another very common use-case that the SDK supports easily. +/// Customizing how messages are rendered is another very common use-case that +/// the SDK supports easily. /// -/// Replace the built-in message component with your own is done by passing it as a builder function to the [MessageListView] widget. +/// Replacing the built-in message component with your own is done by passing +/// it as a builder function to the [MessageListView] widget. /// -/// The message builder function will get the usual [BuildContext] argument as well as the [Message] object and its position inside the list. +/// The message builder function will get the usual [BuildContext] argument +/// as well as the [Message] object and its position inside the list. /// -/// If you look at the code you can see that we use [StreamChat.of] to retrieve the current user so that we can style messages own messages in a different way. +/// If you look at the code you can see that we use [StreamChat.of] to +/// retrieve the current user so that we can style messages in a different way. /// -/// Since custom widgets and builders are always children of [StreamChat] or part of a [Channel], -/// you can use [StreamChat.of], [StreamChannel.of] and [StreamChatTheme.of] to use the API client directly -/// or to retrieve outer scope needed such as messages from the [Channel.state]. -void main() async { +/// Since custom widgets and builders are always children of [StreamChat] or +/// part of a [Channel], you can use [StreamChat.of], [StreamChannel.of], +/// and [StreamChatTheme.of] to use the API client directly or to retrieve +/// outer scope needed such as messages from the [Channel.state]. +Future main() async { final client = StreamChatClient( 's2dxdhpxd94g', logLevel: Level.INFO, @@ -23,45 +28,57 @@ void main() async { await client.connectUser( User(id: 'super-band-9'), - 'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VyX2lkIjoic3VwZXItYmFuZC05In0.0L6lGoeLwkz0aZRUcpZKsvaXtNEDHBcezVTZ0oPq40A', + '''eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VyX2lkIjoic3VwZXItYmFuZC05In0.0L6lGoeLwkz0aZRUcpZKsvaXtNEDHBcezVTZ0oPq40A''', ); - runApp(MyApp(client)); + runApp( + MyApp( + client: client, + ), + ); } class MyApp extends StatelessWidget { + const MyApp({ + Key? key, + required this.client, + }) : super(key: key); + final StreamChatClient client; - MyApp(this.client); - @override + // ignore: prefer_expression_function_bodies Widget build(BuildContext context) { return MaterialApp( builder: (context, child) => StreamChat( - child: child, client: client, + child: child, ), - home: ChannelListPage(), + home: const ChannelListPage(), ); } } class ChannelListPage extends StatelessWidget { + const ChannelListPage({ + Key? key, + }) : super(key: key); + @override + // ignore: prefer_expression_function_bodies Widget build(BuildContext context) { return Scaffold( body: ChannelsBloc( child: ChannelListView( - filter: { - 'members': { - '\$in': [StreamChat.of(context).user.id], - } - }, - sort: [SortOption('last_message_at')], - pagination: PaginationParams( + filter: Filter.in_( + 'members', + [StreamChat.of(context).user!.id], + ), + sort: const [SortOption('last_message_at')], + pagination: const PaginationParams( limit: 20, ), - channelWidget: ChannelPage(), + channelWidget: const ChannelPage(), ), ), ); @@ -70,13 +87,14 @@ class ChannelListPage extends StatelessWidget { class ChannelPage extends StatelessWidget { const ChannelPage({ - Key key, + Key? key, }) : super(key: key); @override + // ignore: prefer_expression_function_bodies Widget build(BuildContext context) { return Scaffold( - appBar: ChannelHeader(), + appBar: const ChannelHeader(), body: Column( children: [ Expanded( @@ -84,7 +102,7 @@ class ChannelPage extends StatelessWidget { messageBuilder: _messageBuilder, ), ), - MessageInput(), + const MessageInput(), ], ), ); @@ -94,28 +112,31 @@ class ChannelPage extends StatelessWidget { BuildContext context, MessageDetails details, List messages, + MessageWidget _, ) { final message = details.message; - final isCurrentUser = StreamChat.of(context).user.id == message.user.id; + final isCurrentUser = StreamChat.of(context).user!.id == message.user!.id; final textAlign = isCurrentUser ? TextAlign.right : TextAlign.left; final color = isCurrentUser ? Colors.blueGrey : Colors.blue; return Padding( - padding: EdgeInsets.all(5.0), + padding: const EdgeInsets.all(5), child: Container( decoration: BoxDecoration( - border: Border.all(color: color, width: 1), - borderRadius: BorderRadius.all( - Radius.circular(5.0), + border: Border.all( + color: color, + ), + borderRadius: const BorderRadius.all( + Radius.circular(5), ), ), child: ListTile( title: Text( - message.text, + message.text!, textAlign: textAlign, ), subtitle: Text( - message.user.extraData['name'], + message.user!.name, textAlign: textAlign, ), ), diff --git a/packages/stream_chat_flutter/example/lib/tutorial-part-6.dart b/packages/stream_chat_flutter/example/lib/tutorial_part_6.dart similarity index 56% rename from packages/stream_chat_flutter/example/lib/tutorial-part-6.dart rename to packages/stream_chat_flutter/example/lib/tutorial_part_6.dart index 110cc1dc..fcf86b5c 100644 --- a/packages/stream_chat_flutter/example/lib/tutorial-part-6.dart +++ b/packages/stream_chat_flutter/example/lib/tutorial_part_6.dart @@ -4,22 +4,29 @@ import 'package:stream_chat_flutter/stream_chat_flutter.dart'; /// Sixth step of the [tutorial](https://getstream.io/chat/flutter/tutorial/) /// -/// The Flutter SDK comes with a fully designed set of widgets which you can customize to fit with your application style and typography. -/// Changing the theme of Chat widgets works in a very similar way that [MaterialApp] and [Theme] do. +/// The Flutter SDK comes with a fully designed set of widgets which you can +/// customize to fit with your application style and typography. +/// Changing the theme of Chat widgets works in a very similar way that +/// [MaterialApp] and [Theme] do. /// -/// Out of the box all chat widgets use their own default styling, there are two ways to change the styling: +/// All chat widgets use their own default styling out of the box. There are +/// two ways to change the styling: /// /// 1. Initialize the [StreamChatTheme] from your existing [MaterialApp] style /// 2. Construct a custom theme and provide all the customizations needed /// -/// First we create a new Material [Theme] and pick [Colors.green] as swatch color. The theme is then passed to [MaterialApp] as usual. +/// First, we create a new Material [Theme] and pick [Colors.green] as the +/// swatch color. The theme is then passed to [MaterialApp] as usual. /// -/// Then we create a new [StreamChatTheme] from the green theme we just created. -/// After saving the app you will see the UI will update several widgets to match with the new color. +/// Then, we create a new [StreamChatTheme] from the green theme we just +/// created. After saving the app you will see that several widgets have +/// been updated with the new color. /// /// We also change the message color posted by the current user. -/// You can perform these more granular style changes using [StreamChatTheme.copyWith]. -void main() async { +/// +/// You can perform these more granular style changes using +/// [StreamChatTheme.copyWith]. +Future main() async { final client = StreamChatClient( 's2dxdhpxd94g', logLevel: Level.INFO, @@ -27,16 +34,23 @@ void main() async { await client.connectUser( User(id: 'super-band-9'), - 'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VyX2lkIjoic3VwZXItYmFuZC05In0.0L6lGoeLwkz0aZRUcpZKsvaXtNEDHBcezVTZ0oPq40A', + '''eyJ0eXAiO«iJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VyX2lkIjoic3VwZXItYmFuZC05In0.0L6lGoeLwkz0aZRUcpZKsvaXtNEDHBcezVTZ0oPq40A''', ); - runApp(MyApp(client)); + runApp( + MyApp( + client: client, + ), + ); } class MyApp extends StatelessWidget { - final StreamChatClient client; + const MyApp({ + Key? key, + required this.client, + }) : super(key: key); - MyApp(this.client); + final StreamChatClient client; @override Widget build(BuildContext context) { @@ -50,9 +64,9 @@ class MyApp extends StatelessWidget { ), ), otherMessageTheme: MessageTheme( - messageBackgroundColor: colorTheme.black, + messageBackgroundColor: colorTheme.textHighEmphasis, messageText: TextStyle( - color: colorTheme.white, + color: colorTheme.barsBg, ), avatarTheme: AvatarTheme( borderRadius: BorderRadius.circular(8), @@ -62,34 +76,36 @@ class MyApp extends StatelessWidget { return MaterialApp( theme: themeData, - builder: (context, child) { - return StreamChat( - child: child, - client: client, - streamChatThemeData: customTheme, - ); - }, - home: ChannelListPage(), + builder: (context, child) => StreamChat( + client: client, + streamChatThemeData: customTheme, + child: child, + ), + home: const ChannelListPage(), ); } } class ChannelListPage extends StatelessWidget { + const ChannelListPage({ + Key? key, + }) : super(key: key); + @override + // ignore: prefer_expression_function_bodies Widget build(BuildContext context) { return Scaffold( body: ChannelsBloc( child: ChannelListView( - filter: { - 'members': { - '\$in': [StreamChat.of(context).user.id], - } - }, - sort: [SortOption('last_message_at')], - pagination: PaginationParams( + filter: Filter.in_( + 'members', + [StreamChat.of(context).user!.id], + ), + sort: const [SortOption('last_message_at')], + pagination: const PaginationParams( limit: 20, ), - channelWidget: ChannelPage(), + channelWidget: const ChannelPage(), ), ), ); @@ -98,25 +114,24 @@ class ChannelListPage extends StatelessWidget { class ChannelPage extends StatelessWidget { const ChannelPage({ - Key key, + Key? key, }) : super(key: key); @override + // ignore: prefer_expression_function_bodies Widget build(BuildContext context) { return Scaffold( - appBar: ChannelHeader(), + appBar: const ChannelHeader(), body: Column( children: [ Expanded( child: MessageListView( - threadBuilder: (_, parentMessage) { - return ThreadPage( - parent: parentMessage, - ); - }, + threadBuilder: (_, parentMessage) => ThreadPage( + parent: parentMessage, + ), ), ), - MessageInput(), + const MessageInput(), ], ), ); @@ -124,18 +139,19 @@ class ChannelPage extends StatelessWidget { } class ThreadPage extends StatelessWidget { - final Message parent; - - ThreadPage({ - Key key, + const ThreadPage({ + Key? key, this.parent, }) : super(key: key); + final Message? parent; + @override + // ignore: prefer_expression_function_bodies Widget build(BuildContext context) { return Scaffold( appBar: ThreadHeader( - parent: parent, + parent: parent!, ), body: Column( children: [ diff --git a/packages/stream_chat_flutter/example/pubspec.yaml b/packages/stream_chat_flutter/example/pubspec.yaml index 8a5270df..d8b29d3f 100644 --- a/packages/stream_chat_flutter/example/pubspec.yaml +++ b/packages/stream_chat_flutter/example/pubspec.yaml @@ -18,31 +18,25 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev version: 1.0.0+1 environment: - sdk: ">=2.7.0 <3.0.0" + sdk: '>=2.12.0 <3.0.0' dependencies: - flutter: - sdk: flutter - stream_chat_flutter: - path: ../ - stream_chat_persistence: - git: - url: https://github.com/GetStream/stream-chat-flutter.git - ref: develop - path: packages/stream_chat_persistence - # The following adds the Cupertino Icons font to your application. # Use with the CupertinoIcons class for iOS style icons. - cupertino_icons: ^1.0.0 - -dependency_overrides: - stream_chat: - path: ../../stream_chat - stream_chat_flutter_core: - path: ../../stream_chat_flutter_core + collection: ^1.15.0 + cupertino_icons: ^1.0.3 + flutter: + sdk: flutter + # stream_chat: + # path: ../../stream_chat + # stream_chat_flutter_core: + # path: ../../stream_chat_flutter_core + stream_chat_flutter: + path: ../ stream_chat_persistence: path: ../../stream_chat_persistence + dev_dependencies: flutter_test: sdk: flutter diff --git a/packages/stream_chat_flutter/lib/src/attachment/attachment.dart b/packages/stream_chat_flutter/lib/src/attachment/attachment.dart index 96ec9ed3..fb771317 100644 --- a/packages/stream_chat_flutter/lib/src/attachment/attachment.dart +++ b/packages/stream_chat_flutter/lib/src/attachment/attachment.dart @@ -1,7 +1,7 @@ +export 'attachment_upload_state_builder.dart'; +export 'attachment_widget.dart' + show AttachmentError, AttachmentSource, AttachmentSourceX; export 'file_attachment.dart'; export 'giphy_attachment.dart'; export 'image_attachment.dart'; export 'video_attachment.dart'; -export 'attachment_upload_state_builder.dart'; -export 'attachment_widget.dart' - show AttachmentError, AttachmentSource, AttachmentSourceX; diff --git a/packages/stream_chat_flutter/lib/src/attachment/attachment_title.dart b/packages/stream_chat_flutter/lib/src/attachment/attachment_title.dart index 176bbbd3..b068a8f9 100644 --- a/packages/stream_chat_flutter/lib/src/attachment/attachment_title.dart +++ b/packages/stream_chat_flutter/lib/src/attachment/attachment_title.dart @@ -1,56 +1,59 @@ import 'package:flutter/material.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart'; -import '../stream_chat_theme.dart'; -import '../utils.dart'; - +/// Title for attachments class AttachmentTitle extends StatelessWidget { + /// Supply attachment and theme for constructing title const AttachmentTitle({ - Key key, - @required this.attachment, - @required this.messageTheme, + Key? key, + required this.attachment, + required this.messageTheme, }) : super(key: key); + /// Theme to apply to text final MessageTheme messageTheme; + + /// Attachment data to display final Attachment attachment; @override - Widget build(BuildContext context) { - return GestureDetector( - onTap: () { - if (attachment.titleLink != null) { - launchURL(context, attachment.titleLink); - } - }, - child: Padding( - padding: const EdgeInsets.all(8.0), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Text( - attachment.title, - overflow: TextOverflow.ellipsis, - style: messageTheme.messageText.copyWith( - color: StreamChatTheme.of(context).colorTheme.accentBlue, - fontWeight: FontWeight.bold, - ), - ), - if (attachment.titleLink != null || attachment.ogScrapeUrl != null) - Text( - Uri.parse(attachment.titleLink ?? attachment.ogScrapeUrl) - .authority - .split('.') - .reversed - .take(2) - .toList() - .reversed - .join('.'), - style: messageTheme.messageText, - ), - ], + Widget build(BuildContext context) => GestureDetector( + onTap: () { + if (attachment.titleLink != null) { + launchURL(context, attachment.titleLink); + } + }, + child: Padding( + padding: const EdgeInsets.all(8), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (attachment.title != null) + Text( + attachment.title!, + overflow: TextOverflow.ellipsis, + style: messageTheme.messageText?.copyWith( + color: StreamChatTheme.of(context).colorTheme.accentPrimary, + fontWeight: FontWeight.bold, + ), + ), + if (attachment.titleLink != null || + attachment.ogScrapeUrl != null) + Text( + Uri.parse(attachment.titleLink ?? attachment.ogScrapeUrl!) + .authority + .split('.') + .reversed + .take(2) + .toList() + .reversed + .join('.'), + style: messageTheme.messageText, + ), + ], + ), ), - ), - ); - } + ); } diff --git a/packages/stream_chat_flutter/lib/src/attachment/attachment_upload_state_builder.dart b/packages/stream_chat_flutter/lib/src/attachment/attachment_upload_state_builder.dart index 2425b966..b1b29c4c 100644 --- a/packages/stream_chat_flutter/lib/src/attachment/attachment_upload_state_builder.dart +++ b/packages/stream_chat_flutter/lib/src/attachment/attachment_upload_state_builder.dart @@ -2,61 +2,70 @@ import 'package:flutter/material.dart'; import 'package:stream_chat_flutter/src/upload_progress_indicator.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; +/// Widget to build in progress typedef InProgressBuilder = Widget Function(BuildContext, int, int); + +/// Widget to build on failure typedef FailedBuilder = Widget Function(BuildContext, String); +/// Widget to display attachment upload state class AttachmentUploadStateBuilder extends StatelessWidget { - final Message message; - final Attachment attachment; - final FailedBuilder failedBuilder; - final WidgetBuilder successBuilder; - final InProgressBuilder inProgressBuilder; - final WidgetBuilder preparingBuilder; - + /// Constructor for creating an [AttachmentUploadStateBuilder] widget const AttachmentUploadStateBuilder({ - Key key, - @required this.message, - @required this.attachment, + Key? key, + required this.message, + required this.attachment, this.failedBuilder, this.successBuilder, this.inProgressBuilder, this.preparingBuilder, - }) : assert(message != null), - assert(attachment != null), - super(key: key); + }) : super(key: key); + + /// Message which attachment is added to + final Message message; + + /// Attachment in concern + final Attachment attachment; + + /// Widget to display when failed + final FailedBuilder? failedBuilder; + + /// Widget to display when succeeded + final WidgetBuilder? successBuilder; + + /// Widget to display when in progress + final InProgressBuilder? inProgressBuilder; + + /// Widget to display when in prep + final WidgetBuilder? preparingBuilder; @override Widget build(BuildContext context) { - if (message.status == null || message.status == MessageSendingStatus.sent) { - return Offstage(); + if (message.status == MessageSendingStatus.sent) { + return const Offstage(); } final messageId = message.id; final attachmentId = attachment.id; - var inProgress = inProgressBuilder; - inProgress ??= (context, int sent, int total) { - return _InProgressState( - sent: sent, - total: total, - attachmentId: attachmentId, - ); - }; + final inProgress = inProgressBuilder ?? + (context, int sent, int total) => _InProgressState( + sent: sent, + total: total, + attachmentId: attachmentId, + ); - var failed = failedBuilder; - failed ??= (context, error) { - return _FailedState( - error: error, - messageId: messageId, - attachmentId: attachmentId, - ); - }; + final failed = failedBuilder ?? + (context, error) => _FailedState( + error: error, + messageId: messageId, + attachmentId: attachmentId, + ); - var success = successBuilder; - success ??= (context) => _SuccessState(); + final success = successBuilder ?? (context) => _SuccessState(); - var preparing = preparingBuilder; - preparing ??= (context) => _PreparingState(attachmentId: attachmentId); + final preparing = preparingBuilder ?? + (context) => _PreparingState(attachmentId: attachmentId); return attachment.uploadState.when( preparing: () => preparing(context), @@ -68,48 +77,47 @@ class AttachmentUploadStateBuilder extends StatelessWidget { } class _IconButton extends StatelessWidget { - final Widget icon; - final double iconSize; - final VoidCallback onPressed; - final Color fillColor; - const _IconButton({ - Key key, + Key? key, this.icon, this.iconSize = 24.0, this.onPressed, this.fillColor, }) : super(key: key); + final Widget? icon; + final double iconSize; + final VoidCallback? onPressed; + final Color? fillColor; + @override - Widget build(BuildContext context) { - return Container( - height: iconSize, - width: iconSize, - child: RawMaterialButton( - elevation: 0, - highlightElevation: 0, - focusElevation: 0, - disabledElevation: 0, - hoverElevation: 0, - onPressed: onPressed, - fillColor: - fillColor ?? StreamChatTheme.of(context).colorTheme.overlayDark, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), - child: icon, - ), - ); - } + Widget build(BuildContext context) => SizedBox( + height: iconSize, + width: iconSize, + child: RawMaterialButton( + elevation: 0, + highlightElevation: 0, + focusElevation: 0, + hoverElevation: 0, + onPressed: onPressed, + fillColor: + fillColor ?? StreamChatTheme.of(context).colorTheme.overlayDark, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + child: icon, + ), + ); } class _PreparingState extends StatelessWidget { - final String attachmentId; - const _PreparingState({ - Key key, - @required this.attachmentId, + Key? key, + required this.attachmentId, }) : super(key: key); + final String attachmentId; + @override Widget build(BuildContext context) { final channel = StreamChannel.of(context).channel; @@ -121,7 +129,7 @@ class _PreparingState extends StatelessWidget { alignment: Alignment.topRight, child: _IconButton( icon: StreamSvgIcon.close( - color: StreamChatTheme.of(context).colorTheme.white, + color: StreamChatTheme.of(context).colorTheme.barsBg, ), onPressed: () => channel.cancelAttachmentUpload(attachmentId), ), @@ -139,17 +147,17 @@ class _PreparingState extends StatelessWidget { } class _InProgressState extends StatelessWidget { + const _InProgressState({ + Key? key, + required this.sent, + required this.total, + required this.attachmentId, + }) : super(key: key); + final int sent; final int total; final String attachmentId; - const _InProgressState({ - Key key, - @required this.sent, - @required this.total, - @required this.attachmentId, - }) : super(key: key); - @override Widget build(BuildContext context) { final channel = StreamChannel.of(context).channel; @@ -161,7 +169,7 @@ class _InProgressState extends StatelessWidget { alignment: Alignment.topRight, child: _IconButton( icon: StreamSvgIcon.close( - color: StreamChatTheme.of(context).colorTheme.white, + color: StreamChatTheme.of(context).colorTheme.barsBg, ), onPressed: () => channel.cancelAttachmentUpload(attachmentId), ), @@ -179,17 +187,17 @@ class _InProgressState extends StatelessWidget { } class _FailedState extends StatelessWidget { - final String error; + const _FailedState({ + Key? key, + this.error, + required this.messageId, + required this.attachmentId, + }) : super(key: key); + + final String? error; final String messageId; final String attachmentId; - const _FailedState({ - Key key, - this.error, - @required this.messageId, - @required this.attachmentId, - }) : super(key: key); - @override Widget build(BuildContext context) { final channel = StreamChannel.of(context).channel; @@ -200,10 +208,10 @@ class _FailedState extends StatelessWidget { children: [ _IconButton( icon: StreamSvgIcon.retry( - color: theme.colorTheme.white, + color: theme.colorTheme.barsBg, ), onPressed: () { - return channel.retryAttachmentUpload(messageId, attachmentId); + channel.retryAttachmentUpload(messageId, attachmentId); }, ), Center( @@ -213,11 +221,14 @@ class _FailedState extends StatelessWidget { color: theme.colorTheme.overlayDark.withOpacity(0.6), ), child: Padding( - padding: const EdgeInsets.symmetric(vertical: 6, horizontal: 12), + padding: const EdgeInsets.symmetric( + vertical: 6, + horizontal: 12, + ), child: Text( 'UPLOAD ERROR', style: theme.textTheme.footnote.copyWith( - color: theme.colorTheme.white, + color: theme.colorTheme.barsBg, ), ), ), @@ -230,16 +241,14 @@ class _FailedState extends StatelessWidget { class _SuccessState extends StatelessWidget { @override - Widget build(BuildContext context) { - return Align( - alignment: Alignment.topRight, - child: CircleAvatar( - backgroundColor: StreamChatTheme.of(context).colorTheme.overlayDark, - maxRadius: 12.0, - child: StreamSvgIcon.check( - color: StreamChatTheme.of(context).colorTheme.white, + Widget build(BuildContext context) => Align( + alignment: Alignment.topRight, + child: CircleAvatar( + backgroundColor: StreamChatTheme.of(context).colorTheme.overlayDark, + maxRadius: 12, + child: StreamSvgIcon.check( + color: StreamChatTheme.of(context).colorTheme.barsBg, + ), ), - ), - ); - } + ); } diff --git a/packages/stream_chat_flutter/lib/src/attachment/attachment_widget.dart b/packages/stream_chat_flutter/lib/src/attachment/attachment_widget.dart index bd58462d..e92087b5 100644 --- a/packages/stream_chat_flutter/lib/src/attachment/attachment_widget.dart +++ b/packages/stream_chat_flutter/lib/src/attachment/attachment_widget.dart @@ -1,27 +1,25 @@ import 'package:flutter/material.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart'; -import '../stream_chat_theme.dart'; - +/// Enum for identifying type of attachment enum AttachmentSource { + /// Attachment is attached local, + + /// Attachment is uploaded network, } +/// Extension for identifying type of attachment extension AttachmentSourceX on AttachmentSource { /// The [when] method is the equivalent to pattern matching. /// Its prototype depends on the AttachmentSource defined. // ignore: missing_return T when({ - @required T Function() local, - @required T Function() network, + required T Function() local, + required T Function() network, }) { - assert(() { - if (local == null || network == null) { - throw 'check for all possible cases'; - } - return true; - }()); switch (this) { case AttachmentSource.local: return local(); @@ -31,48 +29,62 @@ extension AttachmentSourceX on AttachmentSource { } } +/// Abstract class for deriving attachment types abstract class AttachmentWidget extends StatelessWidget { - final Size size; - final Message message; - final Attachment attachment; - final AttachmentSource _source; - - AttachmentSource get source => _source ?? attachment.file != null - ? AttachmentSource.local - : AttachmentSource.network; - + /// Constructor for creating attachment widget const AttachmentWidget({ - Key key, - @required this.message, - @required this.attachment, + Key? key, + required this.message, + required this.attachment, this.size, - AttachmentSource source, + AttachmentSource? source, }) : _source = source, super(key: key); + + /// Size of attachments + final Size? size; + final AttachmentSource? _source; + + /// Message which attachment is attached to + final Message message; + + /// Attachment to display + final Attachment attachment; + + /// Getter for source of attachment + AttachmentSource get source => + _source ?? + (attachment.file != null + ? AttachmentSource.local + : AttachmentSource.network); } +/// Widget for building in case of error class AttachmentError extends StatelessWidget { - final Size size; - + /// Constructor for creating AttachmentError const AttachmentError({ - Key key, + Key? key, this.size, }) : super(key: key); + /// Size of error + final Size? size; + @override - Widget build(BuildContext context) { - return Center( - child: Container( - width: size?.width, - height: size?.height, - color: StreamChatTheme.of(context).colorTheme.accentRed.withOpacity(.1), - child: Center( - child: Icon( - Icons.error_outline, - color: StreamChatTheme.of(context).colorTheme.black, + Widget build(BuildContext context) => Center( + child: Container( + width: size?.width, + height: size?.height, + color: StreamChatTheme.of(context) + .colorTheme + .accentError + .withOpacity(.1), + child: Center( + child: Icon( + Icons.error_outline, + color: StreamChatTheme.of(context).colorTheme.textHighEmphasis, + ), ), ), - ), - ); - } + ); } diff --git a/packages/stream_chat_flutter/lib/src/attachment/file_attachment.dart b/packages/stream_chat_flutter/lib/src/attachment/file_attachment.dart index 1003c767..99c3ec40 100644 --- a/packages/stream_chat_flutter/lib/src/attachment/file_attachment.dart +++ b/packages/stream_chat_flutter/lib/src/attachment/file_attachment.dart @@ -3,74 +3,90 @@ import 'package:flutter/material.dart'; import 'package:shimmer/shimmer.dart'; import 'package:stream_chat_flutter/src/stream_chat_theme.dart'; import 'package:stream_chat_flutter/src/stream_svg_icon.dart'; +import 'package:stream_chat_flutter/src/upload_progress_indicator.dart'; import 'package:stream_chat_flutter/src/utils.dart'; import 'package:stream_chat_flutter/src/video_thumbnail_image.dart'; import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart'; -import '../upload_progress_indicator.dart'; +// ignore: always_use_package_imports import 'attachment_widget.dart'; +/// Widget for displaying file attachments class FileAttachment extends AttachmentWidget { - final Widget title; - final Widget trailing; - final VoidCallback onAttachmentTap; - + /// Constructor for creating a widget when attachment is of type 'file' const FileAttachment({ - Key key, - @required Message message, - @required Attachment attachment, - Size size, + Key? key, + required Message message, + required Attachment attachment, + Size? size, this.title, this.trailing, this.onAttachmentTap, - }) : super(key: key, message: message, attachment: attachment, size: size); + }) : super( + key: key, + message: message, + attachment: attachment, + size: size, + ); + /// Title for attachment + final Widget? title; + + /// Widget for displaying at the end of attachment (such as a download button) + final Widget? trailing; + + /// Callback called when attachment widget is tapped + final VoidCallback? onAttachmentTap; + + /// Check if attachment is a video bool get isVideoAttachment => attachment.title?.mimeType?.type == 'video'; + /// Check if attachment is an image bool get isImageAttachment => attachment.title?.mimeType?.type == 'image'; @override Widget build(BuildContext context) { + final colorTheme = StreamChatTheme.of(context).colorTheme; return Material( child: GestureDetector( onTap: onAttachmentTap, child: Container( width: size?.width ?? 100, - height: 56.0, + height: 56, decoration: BoxDecoration( - color: StreamChatTheme.of(context).colorTheme.white, + color: colorTheme.barsBg, borderRadius: BorderRadius.circular(12), border: Border.all( - color: StreamChatTheme.of(context).colorTheme.greyWhisper, + color: colorTheme.borders, ), ), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ Container( - height: 40.0, + height: 40, width: 33.33, - margin: EdgeInsets.all(8.0), + margin: const EdgeInsets.all(8), child: _getFileTypeImage(context), ), - SizedBox(width: 8.0), + const SizedBox(width: 8), Expanded( child: Column( mainAxisAlignment: MainAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - attachment?.title ?? 'File', + attachment.title ?? 'File', style: StreamChatTheme.of(context).textTheme.bodyBold, maxLines: 1, overflow: TextOverflow.ellipsis, ), - SizedBox(height: 3.0), + const SizedBox(height: 3), _buildSubtitle(context), ], ), ), - SizedBox(width: 8.0), + const SizedBox(width: 8), _buildTrailing(context), ], ), @@ -79,169 +95,166 @@ class FileAttachment extends AttachmentWidget { ); } - ShapeBorder _getDefaultShape(BuildContext context) { - return RoundedRectangleBorder( - side: BorderSide(width: 0.0, color: Colors.transparent), - borderRadius: BorderRadius.circular(8), - ); - } + ShapeBorder _getDefaultShape(BuildContext context) => RoundedRectangleBorder( + side: const BorderSide(width: 0, color: Colors.transparent), + borderRadius: BorderRadius.circular(8), + ); Widget _getFileTypeImage(BuildContext context) { if (isImageAttachment) { return Material( - clipBehavior: Clip.antiAlias, + clipBehavior: Clip.hardEdge, type: MaterialType.transparency, shape: _getDefaultShape(context), child: source.when( - local: () => Image.memory( - attachment.file.bytes, - fit: BoxFit.cover, - errorBuilder: (_, obj, trace) { - return getFileTypeImage(attachment.extraData['other']); - }, - ), - network: () => CachedNetworkImage( - imageUrl: attachment.imageUrl ?? - attachment.assetUrl ?? - attachment.thumbUrl, - fit: BoxFit.cover, - errorWidget: (_, obj, trace) { - return getFileTypeImage(attachment.extraData['other']); - }, - placeholder: (_, __) { - return Shimmer.fromColors( - baseColor: StreamChatTheme.of(context).colorTheme.greyGainsboro, - highlightColor: - StreamChatTheme.of(context).colorTheme.whiteSmoke, - child: Image.asset( + local: () { + if (attachment.file?.bytes == null) { + return getFileTypeImage(attachment.extraData['other'] as String?); + } + return Image.memory( + attachment.file!.bytes!, + fit: BoxFit.cover, + errorBuilder: (_, obj, trace) => + getFileTypeImage(attachment.extraData['other'] as String?), + ); + }, + network: () { + if ((attachment.imageUrl ?? + attachment.assetUrl ?? + attachment.thumbUrl) == + null) { + return getFileTypeImage(attachment.extraData['other'] as String?); + } + return CachedNetworkImage( + imageUrl: attachment.imageUrl ?? + attachment.assetUrl ?? + attachment.thumbUrl!, + fit: BoxFit.cover, + errorWidget: (_, obj, trace) => + getFileTypeImage(attachment.extraData['other'] as String?), + placeholder: (_, __) { + final image = Image.asset( 'images/placeholder.png', fit: BoxFit.cover, package: 'stream_chat_flutter', - ), - ); - }, - ), + ); + + final colorTheme = StreamChatTheme.of(context).colorTheme; + return Shimmer.fromColors( + baseColor: colorTheme.disabled, + highlightColor: colorTheme.inputBg, + child: image, + ); + }, + ); + }, ), ); } if (isVideoAttachment) { return Material( - clipBehavior: Clip.antiAlias, + clipBehavior: Clip.hardEdge, type: MaterialType.transparency, shape: _getDefaultShape(context), child: source.when( local: () => VideoThumbnailImage( - video: attachment.file.path, - placeholderBuilder: (_) { - return Center( - child: Container( - width: 20.0, - height: 20.0, - child: const CircularProgressIndicator(), - ), - ); - }, + fit: BoxFit.cover, + video: attachment.file!.path!, + placeholderBuilder: (_) => const Center( + child: SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(), + ), + ), ), network: () => VideoThumbnailImage( - video: attachment.assetUrl, - placeholderBuilder: (_) { - return Center( - child: Container( - width: 20.0, - height: 20.0, - child: const CircularProgressIndicator(), - ), - ); - }, + fit: BoxFit.cover, + video: attachment.assetUrl!, + placeholderBuilder: (_) => const Center( + child: SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(), + ), + ), ), ), ); } - return getFileTypeImage(attachment.extraData['mime_type']); + return getFileTypeImage(attachment.extraData['mime_type'] as String?); } Widget _buildButton({ - Widget icon, + Widget? icon, double iconSize = 24.0, - VoidCallback onPressed, - Color fillColor, - }) { - return Container( - height: iconSize, - width: iconSize, - child: RawMaterialButton( - elevation: 0, - highlightElevation: 0, - focusElevation: 0, - disabledElevation: 0, - hoverElevation: 0, - onPressed: onPressed, - fillColor: fillColor, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), - child: icon, - ), - ); - } + VoidCallback? onPressed, + Color? fillColor, + }) => + SizedBox( + height: iconSize, + width: iconSize, + child: RawMaterialButton( + elevation: 0, + highlightElevation: 0, + focusElevation: 0, + hoverElevation: 0, + onPressed: onPressed, + fillColor: fillColor, + shape: + RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + child: icon, + ), + ); Widget _buildTrailing(BuildContext context) { final theme = StreamChatTheme.of(context); final channel = StreamChannel.of(context).channel; final attachmentId = attachment.id; var trailingWidget = trailing; - trailingWidget ??= attachment.uploadState?.when( - preparing: () => Padding( - padding: const EdgeInsets.all(8.0), - child: _buildButton( - icon: StreamSvgIcon.close(color: theme.colorTheme.white), - fillColor: theme.colorTheme.overlayDark, - onPressed: () => channel.cancelAttachmentUpload(attachmentId), - ), - ), - inProgress: (_, __) => Padding( - padding: const EdgeInsets.all(8.0), - child: _buildButton( - icon: StreamSvgIcon.close(color: theme.colorTheme.white), - fillColor: theme.colorTheme.overlayDark, - onPressed: () => channel.cancelAttachmentUpload(attachmentId), - ), - ), - success: () => Padding( - padding: const EdgeInsets.all(8.0), - child: CircleAvatar( - backgroundColor: theme.colorTheme.accentBlue, - maxRadius: 12.0, - child: StreamSvgIcon.check(color: theme.colorTheme.white), - ), - ), - failed: (_) => Padding( - padding: const EdgeInsets.all(8.0), - child: _buildButton( - icon: StreamSvgIcon.retry(color: theme.colorTheme.white), - fillColor: theme.colorTheme.overlayDark, - onPressed: () => channel.retryAttachmentUpload( - message?.id, - attachmentId, - ), - ), - ), - ) ?? - IconButton( - icon: StreamSvgIcon.cloudDownload(color: theme.colorTheme.black), - padding: const EdgeInsets.all(8), - visualDensity: VisualDensity.compact, - splashRadius: 16, - onPressed: () { - launchURL(context, attachment.assetUrl); - }, - ); - - if (message != null && - (message.status == null || - message.status == MessageSendingStatus.sent)) { - trailingWidget = IconButton( - icon: StreamSvgIcon.cloudDownload(color: theme.colorTheme.black), + trailingWidget ??= attachment.uploadState.when( + preparing: () => Padding( padding: const EdgeInsets.all(8), + child: _buildButton( + icon: StreamSvgIcon.close(color: theme.colorTheme.barsBg), + fillColor: theme.colorTheme.overlayDark, + onPressed: () => channel.cancelAttachmentUpload(attachmentId), + ), + ), + inProgress: (_, __) => Padding( + padding: const EdgeInsets.all(8), + child: _buildButton( + icon: StreamSvgIcon.close(color: theme.colorTheme.barsBg), + fillColor: theme.colorTheme.overlayDark, + onPressed: () => channel.cancelAttachmentUpload(attachmentId), + ), + ), + success: () => Padding( + padding: const EdgeInsets.all(8), + child: CircleAvatar( + backgroundColor: theme.colorTheme.accentPrimary, + maxRadius: 12, + child: StreamSvgIcon.check(color: theme.colorTheme.barsBg), + ), + ), + failed: (_) => Padding( + padding: const EdgeInsets.all(8), + child: _buildButton( + icon: StreamSvgIcon.retry(color: theme.colorTheme.barsBg), + fillColor: theme.colorTheme.overlayDark, + onPressed: () => channel.retryAttachmentUpload( + message.id, + attachmentId, + ), + ), + ), + ); + + if (message.status == MessageSendingStatus.sent) { + trailingWidget = IconButton( + icon: StreamSvgIcon.cloudDownload( + color: theme.colorTheme.textHighEmphasis), visualDensity: VisualDensity.compact, splashRadius: 16, onPressed: () { @@ -260,35 +273,20 @@ class FileAttachment extends AttachmentWidget { final theme = StreamChatTheme.of(context); final size = attachment.file?.size ?? attachment.extraData['file_size']; final textStyle = theme.textTheme.footnote.copyWith( - color: theme.colorTheme.grey, + color: theme.colorTheme.textLowEmphasis, + ); + return attachment.uploadState.when( + preparing: () => Text(fileSize(size), style: textStyle), + inProgress: (sent, total) => UploadProgressIndicator( + uploaded: sent, + total: total, + showBackground: false, + padding: EdgeInsets.zero, + textStyle: textStyle, + progressIndicatorColor: theme.colorTheme.accentPrimary, + ), + success: () => Text(fileSize(size), style: textStyle), + failed: (_) => Text('UPLOAD ERROR', style: textStyle), ); - return attachment.uploadState?.when( - preparing: () { - if (message == null) { - return Text('${fileSize(size, 2)}', style: textStyle); - } - return UploadProgressIndicator( - uploaded: 0, - total: double.maxFinite.toInt(), - showBackground: false, - padding: EdgeInsets.zero, - textStyle: textStyle, - progressIndicatorColor: theme.colorTheme.accentBlue, - ); - }, - inProgress: (sent, total) { - return UploadProgressIndicator( - uploaded: sent, - total: total, - showBackground: false, - padding: EdgeInsets.zero, - textStyle: textStyle, - progressIndicatorColor: theme.colorTheme.accentBlue, - ); - }, - success: () => Text('${fileSize(size, 2)}', style: textStyle), - failed: (_) => Text('UPLOAD ERROR', style: textStyle), - ) ?? - Text('${fileSize(size)}', style: textStyle); } } diff --git a/packages/stream_chat_flutter/lib/src/attachment/giphy_attachment.dart b/packages/stream_chat_flutter/lib/src/attachment/giphy_attachment.dart index 8b8bb4c1..f14abffc 100644 --- a/packages/stream_chat_flutter/lib/src/attachment/giphy_attachment.dart +++ b/packages/stream_chat_flutter/lib/src/attachment/giphy_attachment.dart @@ -1,38 +1,45 @@ import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; import 'package:shimmer/shimmer.dart'; +import 'package:stream_chat_flutter/src/attachment/attachment_widget.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart'; -import '../full_screen_media.dart'; -import '../stream_chat_theme.dart'; -import '../stream_svg_icon.dart'; -import 'attachment_widget.dart'; - +/// Widget for showing a GIF attachment class GiphyAttachment extends AttachmentWidget { - final MessageTheme messageTheme; - final ShowMessageCallback onShowMessage; - final ValueChanged onReturnAction; - final VoidCallback onAttachmentTap; - + /// Constructor for creating a [GiphyAttachment] widget const GiphyAttachment({ - Key key, - @required Message message, - @required Attachment attachment, - Size size, - this.messageTheme, + Key? key, + required Message message, + required Attachment attachment, + Size? size, this.onShowMessage, this.onReturnAction, this.onAttachmentTap, - }) : super(key: key, message: message, attachment: attachment, size: size); + }) : super( + key: key, + message: message, + attachment: attachment, + size: size, + ); + + /// Callback when show message is tapped + final ShowMessageCallback? onShowMessage; + + /// Callback when attachment is returned to from other screens + final ValueChanged? onReturnAction; + + /// Callback when attachment is tapped + final VoidCallback? onAttachmentTap; @override Widget build(BuildContext context) { final imageUrl = attachment.thumbUrl ?? attachment.imageUrl ?? attachment.assetUrl; - if (imageUrl == null && source == AttachmentSource.network) { - return AttachmentError(); + if (imageUrl == null) { + return const AttachmentError(); } - if (attachment.actions != null) { + if (attachment.actions.isNotEmpty) { return _buildSendingAttachment(context, imageUrl); } return _buildSentAttachment(context, imageUrl); @@ -44,15 +51,14 @@ class GiphyAttachment extends AttachmentWidget { mainAxisSize: MainAxisSize.min, children: [ Card( - color: StreamChatTheme.of(context).colorTheme.white, + color: StreamChatTheme.of(context).colorTheme.barsBg, elevation: 2, - clipBehavior: Clip.antiAlias, - shape: RoundedRectangleBorder( + clipBehavior: Clip.hardEdge, + shape: const RoundedRectangleBorder( borderRadius: BorderRadius.only( - topRight: Radius.circular(16.0), - bottomRight: Radius.circular(0.0), - topLeft: Radius.circular(16.0), - bottomLeft: Radius.circular(16.0), + topRight: Radius.circular(16), + topLeft: Radius.circular(16), + bottomLeft: Radius.circular(16), ), ), child: Column( @@ -60,24 +66,24 @@ class GiphyAttachment extends AttachmentWidget { crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Padding( - padding: const EdgeInsets.all(8.0), + padding: const EdgeInsets.all(8), child: Row( children: [ StreamSvgIcon.giphyIcon(), - SizedBox(width: 8), - Text( + const SizedBox(width: 8), + const Text( 'Giphy', style: TextStyle(fontWeight: FontWeight.bold), ), - SizedBox(width: 8), + const SizedBox(width: 8), if (attachment.title != null) Flexible( child: Text( - attachment.title, + attachment.title!, style: TextStyle( color: StreamChatTheme.of(context) .colorTheme - .black + .textHighEmphasis .withOpacity(0.5), ), overflow: TextOverflow.ellipsis, @@ -88,25 +94,22 @@ class GiphyAttachment extends AttachmentWidget { ), ), Padding( - padding: const EdgeInsets.all(2.0), + padding: const EdgeInsets.all(2), child: GestureDetector( onTap: () => onAttachmentTap ?? _onImageTap(context), child: CachedNetworkImage( height: size?.height, width: size?.width, - placeholder: (_, __) { - return Container( - width: size?.width, - height: size?.height, - child: Center( - child: CircularProgressIndicator(), - ), - ); - }, + placeholder: (_, __) => SizedBox( + width: size?.width, + height: size?.height, + child: const Center( + child: CircularProgressIndicator(), + ), + ), imageUrl: imageUrl, - errorWidget: (context, url, error) { - return AttachmentError(size: size); - }, + errorWidget: (context, url, error) => + AttachmentError(size: size), fit: BoxFit.cover, ), ), @@ -114,17 +117,15 @@ class GiphyAttachment extends AttachmentWidget { Container( color: StreamChatTheme.of(context) .colorTheme - .black + .textHighEmphasis .withOpacity(0.2), width: double.infinity, height: 0.5, ), Row( - mainAxisAlignment: MainAxisAlignment.start, - crossAxisAlignment: CrossAxisAlignment.center, children: [ Expanded( - child: Container( + child: SizedBox( height: 50, child: TextButton( onPressed: () { @@ -140,7 +141,7 @@ class GiphyAttachment extends AttachmentWidget { .copyWith( color: StreamChatTheme.of(context) .colorTheme - .black + .textHighEmphasis .withOpacity(0.5), ), ), @@ -151,12 +152,12 @@ class GiphyAttachment extends AttachmentWidget { width: 0.5, color: StreamChatTheme.of(context) .colorTheme - .black + .textHighEmphasis .withOpacity(0.2), - height: 50.0, + height: 50, ), Expanded( - child: Container( + child: SizedBox( height: 50, child: TextButton( onPressed: () { @@ -172,9 +173,10 @@ class GiphyAttachment extends AttachmentWidget { .copyWith( color: StreamChatTheme.of(context) .colorTheme - .black + .textHighEmphasis .withOpacity(0.5), ), + maxLines: 1, ), ), ), @@ -183,12 +185,12 @@ class GiphyAttachment extends AttachmentWidget { width: 0.5, color: StreamChatTheme.of(context) .colorTheme - .black + .textHighEmphasis .withOpacity(0.2), - height: 50.0, + height: 50, ), Expanded( - child: Container( + child: SizedBox( height: 50, child: TextButton( onPressed: () { @@ -199,10 +201,11 @@ class GiphyAttachment extends AttachmentWidget { child: Text( 'Send', style: TextStyle( - color: StreamChatTheme.of(context) - .colorTheme - .accentBlue, - fontWeight: FontWeight.bold), + color: StreamChatTheme.of(context) + .colorTheme + .accentPrimary, + fontWeight: FontWeight.bold, + ), ), ), ), @@ -212,23 +215,23 @@ class GiphyAttachment extends AttachmentWidget { ], ), ), - SizedBox(height: 4.0), + const SizedBox(height: 4), Align( alignment: Alignment.centerRight, child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 4.0), + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), child: Row( mainAxisSize: MainAxisSize.min, children: [ StreamSvgIcon.eye( color: StreamChatTheme.of(context) .colorTheme - .black + .textHighEmphasis .withOpacity(0.5), - size: 16.0, + size: 16, ), - SizedBox( - width: 8.0, + const SizedBox( + width: 8, ), Text( 'Only visible to you', @@ -238,7 +241,7 @@ class GiphyAttachment extends AttachmentWidget { .copyWith( color: StreamChatTheme.of(context) .colorTheme - .black + .textHighEmphasis .withOpacity(0.5)), ), ], @@ -259,8 +262,7 @@ class GiphyAttachment extends AttachmentWidget { channel: channel, child: FullScreenMedia( mediaAttachments: [attachment], - userName: message.user.name, - sentAt: message.createdAt, + userName: message.user?.name, message: message, onShowMessage: onShowMessage, ), @@ -268,91 +270,90 @@ class GiphyAttachment extends AttachmentWidget { }, ), ); - if (res != null) onReturnAction(res); + if (res != null) onReturnAction?.call(res); } - Widget _buildSentAttachment(BuildContext context, String imageUrl) { - return Container( - child: GestureDetector( - onTap: () async { - final res = - await Navigator.push(context, MaterialPageRoute(builder: (_) { - final channel = StreamChannel.of(context).channel; - return StreamChannel( - channel: channel, - child: FullScreenMedia( - mediaAttachments: [attachment], - userName: message.user.name, - sentAt: message.createdAt, - message: message, - onShowMessage: onShowMessage, - ), - ); - })); - if (res != null) onReturnAction(res); - }, - child: Stack( - children: [ - CachedNetworkImage( - height: size?.height, - width: size?.width, - placeholder: (_, __) { - return Shimmer.fromColors( - baseColor: - StreamChatTheme.of(context).colorTheme.greyGainsboro, - highlightColor: - StreamChatTheme.of(context).colorTheme.whiteSmoke, - child: Image.asset( + Widget _buildSentAttachment(BuildContext context, String imageUrl) => + SizedBox( + child: GestureDetector( + onTap: () async { + final res = + await Navigator.push(context, MaterialPageRoute(builder: (_) { + final channel = StreamChannel.of(context).channel; + return StreamChannel( + channel: channel, + child: FullScreenMedia( + mediaAttachments: [attachment], + userName: message.user?.name, + message: message, + onShowMessage: onShowMessage, + ), + ); + })); + if (res != null) onReturnAction!(res); + }, + child: Stack( + children: [ + CachedNetworkImage( + height: size?.height, + width: size?.width, + placeholder: (_, __) { + final image = Image.asset( 'images/placeholder.png', fit: BoxFit.cover, package: 'stream_chat_flutter', + ); + + final colorTheme = StreamChatTheme.of(context).colorTheme; + return Shimmer.fromColors( + baseColor: colorTheme.disabled, + highlightColor: colorTheme.inputBg, + child: image, + ); + }, + imageUrl: imageUrl, + errorWidget: (context, url, error) => + AttachmentError(size: size), + fit: BoxFit.cover, + ), + Positioned( + bottom: 8, + left: 8, + child: Material( + color: StreamChatTheme.of(context) + .colorTheme + .textHighEmphasis + .withOpacity(.5), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), ), - ); - }, - imageUrl: imageUrl, - errorWidget: (context, url, error) { - return AttachmentError(size: size); - }, - fit: BoxFit.cover, - ), - Positioned( - bottom: 8, - left: 8, - child: Material( - color: StreamChatTheme.of(context) - .colorTheme - .black - .withOpacity(.5), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - ), - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: 8.0, - vertical: 4.0, - ), - child: Row( - children: [ - StreamSvgIcon.lightning( - color: StreamChatTheme.of(context).colorTheme.white, - size: 16, - ), - Text( - 'GIPHY', - style: TextStyle( - color: StreamChatTheme.of(context).colorTheme.white, - fontWeight: FontWeight.bold, - fontSize: 11, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 4, + ), + child: Row( + children: [ + StreamSvgIcon.lightning( + color: StreamChatTheme.of(context).colorTheme.barsBg, + size: 16, ), - ), - ], + Text( + 'GIPHY', + style: TextStyle( + color: + StreamChatTheme.of(context).colorTheme.barsBg, + fontWeight: FontWeight.bold, + fontSize: 11, + ), + ), + ], + ), ), ), ), - ), - ], + ], + ), ), - ), - ); - } + ); } diff --git a/packages/stream_chat_flutter/lib/src/attachment/image_attachment.dart b/packages/stream_chat_flutter/lib/src/attachment/image_attachment.dart index aa8d1246..20174567 100644 --- a/packages/stream_chat_flutter/lib/src/attachment/image_attachment.dart +++ b/packages/stream_chat_flutter/lib/src/attachment/image_attachment.dart @@ -1,165 +1,173 @@ import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; import 'package:shimmer/shimmer.dart'; +import 'package:stream_chat_flutter/src/attachment/attachment_title.dart'; import 'package:stream_chat_flutter/src/attachment/attachment_upload_state_builder.dart'; +import 'package:stream_chat_flutter/src/attachment/attachment_widget.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart'; -import '../full_screen_media.dart'; -import '../stream_chat_theme.dart'; -import 'attachment_title.dart'; -import 'attachment_widget.dart'; - +/// Widget for showing an image attachment class ImageAttachment extends AttachmentWidget { - final MessageTheme messageTheme; - final bool showTitle; - final ShowMessageCallback onShowMessage; - final ValueChanged onReturnAction; - final VoidCallback onAttachmentTap; - + /// Constructor for creating a [ImageAttachment] widget const ImageAttachment({ - Key key, - @required Message message, - @required Attachment attachment, - Size size, - this.messageTheme, + Key? key, + required Message message, + required Attachment attachment, + required this.messageTheme, + Size? size, this.showTitle = false, this.onShowMessage, this.onReturnAction, this.onAttachmentTap, - }) : super(key: key, message: message, attachment: attachment, size: size); + }) : super( + key: key, + message: message, + attachment: attachment, + size: size, + ); + + /// [MessageTheme] for showing image title + final MessageTheme messageTheme; + + /// Flag for showing title + final bool showTitle; + + /// Callback when show message is tapped + final ShowMessageCallback? onShowMessage; + + /// Callback when attachment is returned to from other screens + final ValueChanged? onReturnAction; + + /// Callback when attachment is tapped + final VoidCallback? onAttachmentTap; @override - Widget build(BuildContext context) { - return source.when( - local: () { - if (attachment.localUri == null) { - return AttachmentError(size: size); - } - return _buildImageAttachment( - context, - Image.memory( - attachment.file.bytes, - height: size?.height, - width: size?.width, - fit: BoxFit.cover, - errorBuilder: (context, _, __) { - return Image.asset( + Widget build(BuildContext context) => source.when( + local: () { + if (attachment.localUri == null || attachment.file?.bytes == null) { + return AttachmentError(size: size); + } + return _buildImageAttachment( + context, + Image.memory( + attachment.file!.bytes!, + height: size?.height, + width: size?.width, + fit: BoxFit.cover, + errorBuilder: (context, _, __) => Image.asset( 'images/placeholder.png', package: 'stream_chat_flutter', - ); - }, - ), - ); - }, - network: () { - var imageUrl = - attachment.thumbUrl ?? attachment.imageUrl ?? attachment.assetUrl; + ), + ), + ); + }, + network: () { + var imageUrl = + attachment.thumbUrl ?? attachment.imageUrl ?? attachment.assetUrl; - if (imageUrl == null) { - return AttachmentError(size: size); - } + if (imageUrl == null) { + return AttachmentError(size: size); + } - var imageUri = Uri.parse(imageUrl); - if (imageUri.host == 'stream-io-cdn.com') { - imageUri = imageUri.replace(queryParameters: { - ...imageUri.queryParameters, - 'h': '500', - 'w': '500', - 'crop': 'center', - 'resize': 'crop', - }); - } else if (imageUri.host == 'stream-cloud-uploads.imgix.net') { - imageUri = imageUri.replace(queryParameters: { - ...imageUri.queryParameters, - 'height': '500', - 'width': '500', - 'fit': 'crop', - }); - } - imageUrl = imageUri.toString(); + var imageUri = Uri.parse(imageUrl); + if (imageUri.host == 'stream-io-cdn.com') { + imageUri = imageUri.replace(queryParameters: { + ...imageUri.queryParameters, + 'h': '400', + 'w': '400', + 'crop': 'center', + 'resize': 'crop', + }); + } else if (imageUri.host == 'stream-cloud-uploads.imgix.net') { + imageUri = imageUri.replace(queryParameters: { + ...imageUri.queryParameters, + 'height': '400', + 'width': '400', + 'fit': 'crop', + }); + } + imageUrl = imageUri.toString(); - return _buildImageAttachment( - context, - CachedNetworkImage( - cacheKey: imageUri.path, - height: size?.height, - width: size?.width, - placeholder: (_, __) { - return Shimmer.fromColors( - baseColor: StreamChatTheme.of(context).colorTheme.greyGainsboro, - highlightColor: - StreamChatTheme.of(context).colorTheme.whiteSmoke, - child: Image.asset( + return _buildImageAttachment( + context, + CachedNetworkImage( + cacheKey: imageUrl, + height: size?.height, + width: size?.width, + placeholder: (context, __) { + final image = Image.asset( 'images/placeholder.png', fit: BoxFit.cover, package: 'stream_chat_flutter', - ), - ); - }, - imageUrl: imageUrl, - errorWidget: (context, url, error) { - return AttachmentError(size: size); - }, - fit: BoxFit.cover, - ), - ); - }, - ); - } - - Widget _buildImageAttachment(BuildContext context, Widget imageWidget) { - return ConstrainedBox( - constraints: BoxConstraints.loose(size), - child: Column( - children: [ - Expanded( - child: Stack( - children: [ - GestureDetector( - onTap: onAttachmentTap ?? - () async { - final result = await Navigator.push( - context, - MaterialPageRoute( - builder: (_) { - final channel = StreamChannel.of(context).channel; - return StreamChannel( - channel: channel, - child: FullScreenMedia( - mediaAttachments: [attachment], - userName: message.user.name, - sentAt: message.createdAt, - message: message, - onShowMessage: onShowMessage, - ), - ); - }, - ), - ); - if (result != null) onReturnAction(result); - }, - child: imageWidget, - ), - Padding( - padding: const EdgeInsets.all(8.0), - child: AttachmentUploadStateBuilder( - message: message, - attachment: attachment, - ), - ), - ], + ); + final colorTheme = StreamChatTheme.of(context).colorTheme; + return Shimmer.fromColors( + baseColor: colorTheme.disabled, + highlightColor: colorTheme.inputBg, + child: image, + ); + }, + imageUrl: imageUrl, + errorWidget: (context, url, error) => AttachmentError(size: size), + fit: BoxFit.cover, ), - ), - if (showTitle && attachment.title != null) - Material( - color: messageTheme.messageBackgroundColor, - child: AttachmentTitle( - messageTheme: messageTheme, - attachment: attachment, + ); + }, + ); + + Widget _buildImageAttachment(BuildContext context, Widget imageWidget) => + ConstrainedBox( + constraints: BoxConstraints.loose(size!), + child: Column( + children: [ + Expanded( + child: Stack( + children: [ + GestureDetector( + onTap: onAttachmentTap ?? + () async { + final result = await Navigator.push( + context, + MaterialPageRoute( + builder: (_) { + final channel = + StreamChannel.of(context).channel; + return StreamChannel( + channel: channel, + child: FullScreenMedia( + mediaAttachments: [attachment], + userName: message.user?.name, + message: message, + onShowMessage: onShowMessage, + ), + ); + }, + ), + ); + if (result != null) onReturnAction?.call(result); + }, + child: imageWidget, + ), + Padding( + padding: const EdgeInsets.all(8), + child: AttachmentUploadStateBuilder( + message: message, + attachment: attachment, + ), + ), + ], ), ), - ], - ), - ); - } + if (showTitle && attachment.title != null) + Material( + color: messageTheme.messageBackgroundColor, + child: AttachmentTitle( + messageTheme: messageTheme, + attachment: attachment, + ), + ), + ], + ), + ); } diff --git a/packages/stream_chat_flutter/lib/src/attachment/video_attachment.dart b/packages/stream_chat_flutter/lib/src/attachment/video_attachment.dart index d2ffc335..b743cda1 100644 --- a/packages/stream_chat_flutter/lib/src/attachment/video_attachment.dart +++ b/packages/stream_chat_flutter/lib/src/attachment/video_attachment.dart @@ -1,125 +1,133 @@ import 'package:flutter/material.dart'; +import 'package:stream_chat_flutter/src/attachment/attachment_title.dart'; +import 'package:stream_chat_flutter/src/attachment/attachment_widget.dart'; import 'package:stream_chat_flutter/src/full_screen_media.dart'; import 'package:stream_chat_flutter/src/video_thumbnail_image.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; -import 'attachment_title.dart'; -import 'attachment_upload_state_builder.dart'; -import 'attachment_widget.dart'; - +/// Widget for showing a video attachment class VideoAttachment extends AttachmentWidget { - final MessageTheme messageTheme; - final ShowMessageCallback onShowMessage; - final ValueChanged onReturnAction; - final VoidCallback onAttachmentTap; - + /// Constructor for creating a [VideoAttachment] widget const VideoAttachment({ - Key key, - @required Message message, - @required Attachment attachment, - Size size, - this.messageTheme, + Key? key, + required Message message, + required Attachment attachment, + required this.messageTheme, + Size? size, this.onShowMessage, this.onReturnAction, this.onAttachmentTap, - }) : super(key: key, message: message, attachment: attachment, size: size); + }) : super( + key: key, + message: message, + attachment: attachment, + size: size, + ); + + /// [MessageTheme] for showing title + final MessageTheme messageTheme; + + /// Callback when show message is tapped + final ShowMessageCallback? onShowMessage; + + /// Callback when attachment is returned to from other screens + final ValueChanged? onReturnAction; + + /// Callback when attachment is tapped + final VoidCallback? onAttachmentTap; @override - Widget build(BuildContext context) { - return source.when( - local: () { - if (attachment.file == null) { - return AttachmentError(size: size); - } - return _buildVideoAttachment( - context, - VideoThumbnailImage( - video: attachment.file.path, - height: size?.height, - width: size?.width, - fit: BoxFit.cover, - errorBuilder: (_, __) => AttachmentError(size: size), - ), - ); - }, - network: () { - if (attachment.assetUrl == null) { - return AttachmentError(size: size); - } - return _buildVideoAttachment( - context, - VideoThumbnailImage( - video: attachment.assetUrl, - height: size?.height, - width: size?.width, - fit: BoxFit.cover, - errorBuilder: (_, __) => AttachmentError(size: size), - ), - ); - }, - ); - } + Widget build(BuildContext context) => source.when( + local: () { + if (attachment.file == null) { + return AttachmentError(size: size); + } + return _buildVideoAttachment( + context, + VideoThumbnailImage( + video: attachment.file!.path!, + height: size?.height, + width: size?.width, + fit: BoxFit.cover, + errorBuilder: (_, __) => AttachmentError(size: size), + ), + ); + }, + network: () { + if (attachment.assetUrl == null) { + return AttachmentError(size: size); + } + return _buildVideoAttachment( + context, + VideoThumbnailImage( + video: attachment.assetUrl!, + height: size?.height, + width: size?.width, + fit: BoxFit.cover, + errorBuilder: (_, __) => AttachmentError(size: size), + ), + ); + }, + ); - Widget _buildVideoAttachment(BuildContext context, Widget videoWidget) { - return ConstrainedBox( - constraints: BoxConstraints.loose(size), - child: Column( - children: [ - Expanded( - child: GestureDetector( - onTap: onAttachmentTap ?? - () async { - final channel = StreamChannel.of(context).channel; - final res = await Navigator.push( - context, - MaterialPageRoute( - builder: (_) => StreamChannel( - channel: channel, - child: FullScreenMedia( - mediaAttachments: [attachment], - userName: message.user.name, - sentAt: message.createdAt, - message: message, - onShowMessage: onShowMessage, + Widget _buildVideoAttachment(BuildContext context, Widget videoWidget) => + ConstrainedBox( + constraints: BoxConstraints.loose(size ?? Size.infinite), + child: Column( + children: [ + Expanded( + child: GestureDetector( + onTap: onAttachmentTap ?? + () async { + final channel = StreamChannel.of(context).channel; + final res = await Navigator.push( + context, + MaterialPageRoute( + builder: (_) => StreamChannel( + channel: channel, + child: FullScreenMedia( + mediaAttachments: [attachment], + userName: message.user?.name, + message: message, + onShowMessage: onShowMessage, + ), ), ), - ), - ); - if (res != null) onReturnAction(res); - }, - child: Stack( - children: [ - videoWidget, - Center( - child: Material( - shape: CircleBorder(), - child: Padding( - padding: const EdgeInsets.all(16.0), - child: Icon(Icons.play_arrow), + ); + if (res != null) onReturnAction?.call(res); + }, + child: Stack( + children: [ + videoWidget, + const Center( + child: Material( + shape: CircleBorder(), + child: Padding( + padding: EdgeInsets.all(16), + child: Icon(Icons.play_arrow), + ), ), ), - ), - Padding( - padding: const EdgeInsets.all(8.0), - child: AttachmentUploadStateBuilder( - message: message, - attachment: attachment, + Padding( + padding: const EdgeInsets.all(8), + child: AttachmentUploadStateBuilder( + message: message, + attachment: attachment, + ), ), - ), - ], + ], + ), ), ), - ), - if (attachment.title != null) - Material( - color: messageTheme.messageBackgroundColor, - child: AttachmentTitle( - messageTheme: messageTheme, - attachment: attachment, + if (attachment.title != null) + Material( + color: messageTheme.messageBackgroundColor, + child: AttachmentTitle( + messageTheme: messageTheme, + attachment: attachment, + ), ), - ), - ], - ), - ); - } + ], + ), + ); } diff --git a/packages/stream_chat_flutter/lib/src/attachment_actions_modal.dart b/packages/stream_chat_flutter/lib/src/attachment_actions_modal.dart index d9b9c016..ea189b91 100644 --- a/packages/stream_chat_flutter/lib/src/attachment_actions_modal.dart +++ b/packages/stream_chat_flutter/lib/src/attachment_actions_modal.dart @@ -5,66 +5,64 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:image_gallery_saver/image_gallery_saver.dart'; import 'package:path_provider/path_provider.dart'; - -import '../stream_chat_flutter.dart'; -import 'extension.dart'; +import 'package:stream_chat_flutter/src/extension.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; /// Callback to download an attachment asset typedef AttachmentDownloader = Future Function( Attachment attachment, { - ProgressCallback progressCallback, + ProgressCallback? progressCallback, }); /// Widget that shows the options in the gallery view class AttachmentActionsModal extends StatelessWidget { + /// Returns a new [AttachmentActionsModal] + const AttachmentActionsModal({ + Key? key, + required this.currentIndex, + required this.message, + this.onShowMessage, + this.imageDownloader, + this.fileDownloader, + }) : super(key: key); + /// The message containing the attachments final Message message; /// Current page index - final currentIndex; + final int currentIndex; /// Callback to show the message - final VoidCallback onShowMessage; + final VoidCallback? onShowMessage; /// Callback to download images - final AttachmentDownloader imageDownloader; + final AttachmentDownloader? imageDownloader; /// Callback to provide download files - final AttachmentDownloader fileDownloader; - - /// Returns a new [AttachmentActionsModal] - const AttachmentActionsModal({ - this.message, - this.currentIndex, - this.onShowMessage, - this.imageDownloader, - this.fileDownloader, - }); + final AttachmentDownloader? fileDownloader; @override - Widget build(BuildContext context) { - return GestureDetector( - behavior: HitTestBehavior.translucent, - onTap: () => Navigator.maybePop(context), - child: _buildPage(context), - ); - } + Widget build(BuildContext context) => GestureDetector( + behavior: HitTestBehavior.translucent, + onTap: () => Navigator.maybePop(context), + child: _buildPage(context), + ); Widget _buildPage(context) { final theme = StreamChatTheme.of(context); return Column( crossAxisAlignment: CrossAxisAlignment.end, children: [ - SizedBox(height: kToolbarHeight), + const SizedBox(height: kToolbarHeight), Padding( - padding: const EdgeInsets.only(right: 8.0), + padding: const EdgeInsets.only(right: 8), child: Container( width: MediaQuery.of(context).size.width * 0.5, clipBehavior: Clip.hardEdge, decoration: BoxDecoration( - borderRadius: BorderRadius.circular(16.0), + borderRadius: BorderRadius.circular(16), ), - child: Container( + child: SizedBox( child: Column( crossAxisAlignment: CrossAxisAlignment.end, mainAxisSize: MainAxisSize.min, @@ -73,8 +71,8 @@ class AttachmentActionsModal extends StatelessWidget { context, 'Reply', StreamSvgIcon.iconCurveLineLeftUp( - size: 24.0, - color: theme.colorTheme.grey, + size: 24, + color: theme.colorTheme.textLowEmphasis, ), () { Navigator.pop(context, ReturnActionType.reply); @@ -84,26 +82,32 @@ class AttachmentActionsModal extends StatelessWidget { context, 'Show in Chat', StreamSvgIcon.eye( - size: 24.0, - color: theme.colorTheme.black, + size: 24, + color: theme.colorTheme.textHighEmphasis, ), onShowMessage, ), _buildButton( context, + // ignore: lines_longer_than_80_chars 'Save ${message.attachments[currentIndex].type == 'video' ? 'Video' : 'Image'}', StreamSvgIcon.iconSave( - size: 24.0, - color: theme.colorTheme.grey, + size: 24, + color: theme.colorTheme.textLowEmphasis, ), () { final attachment = message.attachments[currentIndex]; final isImage = attachment.type == 'image'; - final saveFile = fileDownloader ?? _downloadAttachment; - final saveImage = imageDownloader ?? _downloadAttachment; + final Future Function(Attachment, + {void Function(int, int) progressCallback}) + saveFile = fileDownloader ?? _downloadAttachment; + final Future Function(Attachment, + {void Function(int, int) progressCallback}) + saveImage = imageDownloader ?? _downloadAttachment; final downloader = isImage ? saveImage : saveFile; - final progressNotifier = ValueNotifier<_DownloadProgress>( + final progressNotifier = + ValueNotifier<_DownloadProgress?>( _DownloadProgress.initial(), ); @@ -115,7 +119,7 @@ class AttachmentActionsModal extends StatelessWidget { received, ); }, - ).catchError((_) { + ).catchError((e, stk) { progressNotifier.value = null; }); @@ -134,41 +138,44 @@ class AttachmentActionsModal extends StatelessWidget { ); }, ), - if (StreamChat.of(context).user.id == message.user.id) + if (StreamChat.of(context).user?.id == message.user?.id) _buildButton( context, 'Delete', StreamSvgIcon.delete( - size: 24.0, - color: theme.colorTheme.accentRed, + size: 24, + color: theme.colorTheme.accentError, ), () { final channel = StreamChannel.of(context).channel; if (message.attachments.length > 1 || - message.text.isNotEmpty) { + message.text?.isNotEmpty == true) { final remainingAttachments = [...message.attachments] ..removeAt(currentIndex); channel.updateMessage(message.copyWith( attachments: remainingAttachments, )); - Navigator.pop(context); - Navigator.pop(context); + Navigator.of(context) + ..pop() + ..maybePop(); } else { - channel.deleteMessage(message).then((value) { - Navigator.pop(context); - Navigator.pop(context); - }); + channel.deleteMessage(message); + Navigator.of(context) + ..pop() + ..maybePop(); } }, - color: theme.colorTheme.accentRed, + color: theme.colorTheme.accentError, ), ] - .map((e) => - Align(alignment: Alignment.centerRight, child: e)) + .map((e) => Align( + alignment: Alignment.centerRight, + child: e, + )) .insertBetween( Container( height: 1, - color: theme.colorTheme.greyWhisper, + color: theme.colorTheme.borders, ), ), ), @@ -183,140 +190,138 @@ class AttachmentActionsModal extends StatelessWidget { context, String title, StreamSvgIcon icon, - VoidCallback onTap, { - Color color, - }) { - return Material( - color: StreamChatTheme.of(context).colorTheme.white, - child: InkWell( - onTap: onTap, - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 8.0, horizontal: 16.0), - child: Row( - children: [ - icon, - SizedBox(width: 16), - Text( - title, - style: StreamChatTheme.of(context) - .textTheme - .body - .copyWith(color: color), - ), - ], + VoidCallback? onTap, { + Color? color, + Key? key, + }) => + Material( + key: key, + color: StreamChatTheme.of(context).colorTheme.barsBg, + child: InkWell( + onTap: onTap, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16), + child: Row( + children: [ + icon, + const SizedBox(width: 16), + Text( + title, + style: StreamChatTheme.of(context) + .textTheme + .body + .copyWith(color: color), + ), + ], + ), ), ), - ), - ); - } + ); Widget _buildDownloadProgressDialog( BuildContext context, - ValueNotifier<_DownloadProgress> progressNotifier, + ValueNotifier<_DownloadProgress?> progressNotifier, ) { final theme = StreamChatTheme.of(context); - return WillPopScope( - onWillPop: () => Future.value(false), - child: ValueListenableBuilder( - valueListenable: progressNotifier, - builder: (_, _DownloadProgress progress, __) { - // Pop the dialog in case the progress is null or it's completed. - if (progress == null || progress?.toProgressIndicatorValue == 1.0) { - Future.delayed( - const Duration(milliseconds: 500), - Navigator.of(context).pop, - ); - } - return Material( - type: MaterialType.transparency, - child: Center( - child: Container( - height: 182, - width: 182, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(16), - color: theme.colorTheme.white, - ), - child: Center( - child: progress == null - ? Container( - height: 100, - width: 100, - child: StreamSvgIcon.error( - color: theme.colorTheme.greyGainsboro, - ), - ) - : progress.toProgressIndicatorValue == 1.0 - ? Container( - height: 160, - width: 160, - child: StreamSvgIcon.check( - color: theme.colorTheme.greyGainsboro, - ), - ) - : Container( - height: 100, - width: 100, - child: Stack( - fit: StackFit.expand, - children: [ - CircularProgressIndicator( - value: progress.toProgressIndicatorValue, - strokeWidth: 8.0, - valueColor: AlwaysStoppedAnimation( - theme.colorTheme.accentBlue, - ), - ), - Center( - child: Text( - '${progress.toPercentage}%', - style: theme.textTheme.headline.copyWith( - color: theme.colorTheme.grey, - ), - ), - ), - ], - ), + return ValueListenableBuilder( + valueListenable: progressNotifier, + builder: (_, _DownloadProgress? progress, __) { + // Pop the dialog in case the progress is null or it's completed. + if (progress == null || progress.toProgressIndicatorValue == 1.0) { + Future.delayed( + const Duration(milliseconds: 500), + () => Navigator.of(context).maybePop(), + ); + } + return Material( + type: MaterialType.transparency, + child: Center( + child: Container( + height: 182, + width: 182, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(16), + color: theme.colorTheme.barsBg, + ), + child: Center( + child: progress == null + ? SizedBox( + height: 100, + width: 100, + child: StreamSvgIcon.error( + color: theme.colorTheme.disabled, + ), + ) + : progress.toProgressIndicatorValue == 1.0 + ? SizedBox( + key: const Key('completedIcon'), + height: 160, + width: 160, + child: StreamSvgIcon.check( + color: theme.colorTheme.disabled, ), - ), + ) + : SizedBox( + height: 100, + width: 100, + child: Stack( + fit: StackFit.expand, + children: [ + CircularProgressIndicator( + value: progress.toProgressIndicatorValue, + strokeWidth: 8, + valueColor: AlwaysStoppedAnimation( + theme.colorTheme.accentPrimary, + ), + ), + Center( + child: Text( + '${progress.toPercentage}%', + style: theme.textTheme.headline.copyWith( + color: theme.colorTheme.textLowEmphasis, + ), + ), + ), + ], + ), + ), ), ), - ); - }, - ), + ), + ); + }, ); } - Future _downloadAttachment( + Future _downloadAttachment( Attachment attachment, { - ProgressCallback progressCallback, + ProgressCallback? progressCallback, }) async { - String filePath; + String? filePath; final appDocDir = await getTemporaryDirectory(); await Dio().download( - attachment.assetUrl ?? attachment.imageUrl ?? attachment.thumbUrl, + attachment.assetUrl ?? attachment.imageUrl ?? attachment.thumbUrl!, (Headers responseHeaders) { - final contentType = responseHeaders[Headers.contentTypeHeader]; - final mimeType = contentType.first?.split('/')?.last; + final contentType = responseHeaders[Headers.contentTypeHeader]!; + final mimeType = contentType.first.split('/').last; filePath ??= '${appDocDir.path}/${attachment.id}.$mimeType'; - return filePath; + return filePath!; }, onReceiveProgress: progressCallback, ); - final result = await ImageGallerySaver.saveFile(filePath); + final result = await ImageGallerySaver.saveFile(filePath!); return (result as Map)['filePath']; } } class _DownloadProgress { - final int total; - final int received; - const _DownloadProgress(this.total, this.received); - factory _DownloadProgress.initial() { - return _DownloadProgress(double.maxFinite.toInt(), 0); - } + factory _DownloadProgress.initial() => + _DownloadProgress(double.maxFinite.toInt(), 0); + + final int total; + final int received; double get toProgressIndicatorValue => received / total; diff --git a/packages/stream_chat_flutter/lib/src/back_button.dart b/packages/stream_chat_flutter/lib/src/back_button.dart index f49fa98c..6bd3e0cc 100644 --- a/packages/stream_chat_flutter/lib/src/back_button.dart +++ b/packages/stream_chat_flutter/lib/src/back_button.dart @@ -1,59 +1,60 @@ import 'package:flutter/material.dart'; import 'package:stream_chat_flutter/src/stream_svg_icon.dart'; import 'package:stream_chat_flutter/src/unread_indicator.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; -import '../stream_chat_flutter.dart'; - +/// Back button implementation class StreamBackButton extends StatelessWidget { + /// Constructor for creating back button const StreamBackButton({ - Key key, + Key? key, this.onPressed, this.showUnreads = false, this.cid, }) : super(key: key); - final VoidCallback onPressed; + /// Callback for when button is pressed + final VoidCallback? onPressed; + + /// Show unread count final bool showUnreads; /// Channel cid used to retrieve unread count - final String cid; + final String? cid; @override - Widget build(BuildContext context) { - return Stack( - alignment: Alignment.center, - children: [ - RawMaterialButton( - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(4), - ), - elevation: 0, - highlightElevation: 0, - focusElevation: 0, - disabledElevation: 0, - hoverElevation: 0, - onPressed: () { - if (onPressed != null) { - onPressed(); - } else { - Navigator.maybePop(context); - } - }, - padding: const EdgeInsets.all(14.0), - child: StreamSvgIcon.left( - size: 24, - color: StreamChatTheme.of(context).colorTheme.black, - ), - ), - if (showUnreads) - Positioned( - top: 7, - right: 7, - child: UnreadIndicator( - cid: cid, + Widget build(BuildContext context) => Stack( + alignment: Alignment.center, + children: [ + RawMaterialButton( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(4), + ), + elevation: 0, + highlightElevation: 0, + focusElevation: 0, + hoverElevation: 0, + onPressed: () { + if (onPressed != null) { + onPressed!(); + } else { + Navigator.maybePop(context); + } + }, + padding: const EdgeInsets.all(14), + child: StreamSvgIcon.left( + size: 24, + color: StreamChatTheme.of(context).colorTheme.textHighEmphasis, ), ), - ], - ); - } + if (showUnreads) + Positioned( + top: 7, + right: 7, + child: UnreadIndicator( + cid: cid, + ), + ), + ], + ); } diff --git a/packages/stream_chat_flutter/lib/src/channel_avatar.dart b/packages/stream_chat_flutter/lib/src/channel_avatar.dart new file mode 100644 index 00000000..4ab6ba31 --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/channel_avatar.dart @@ -0,0 +1,202 @@ +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:flutter/material.dart'; +import 'package:stream_chat_flutter/src/group_avatar.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; +import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart'; + +/// ![screenshot](https://raw.githubusercontent.com/GetStream/stream-chat-flutter/master/screenshots/channel_image.png) +/// ![screenshot](https://raw.githubusercontent.com/GetStream/stream-chat-flutter/master/screenshots/channel_image_paint.png) +/// +/// It shows the current [Channel] image. +/// +/// ```dart +/// class MyApp extends StatelessWidget { +/// final StreamChatClient client; +/// final Channel channel; +/// +/// MyApp(this.client, this.channel); +/// +/// @override +/// Widget build(BuildContext context) { +/// return MaterialApp( +/// debugShowCheckedModeBanner: false, +/// home: StreamChat( +/// client: client, +/// child: StreamChannel( +/// channel: channel, +/// child: Center( +/// child: ChannelImage( +/// channel: channel, +/// ), +/// ), +/// ), +/// ), +/// ); +/// } +/// } +/// ``` +/// +/// The widget uses a [StreamBuilder] to render the channel information +/// image as soon as it updates. +/// +/// By default the widget radius size is 40x40 pixels. +/// Set the property [constraints] to set a custom dimension. +/// +/// The widget renders the ui based on the first ancestor of type +/// [StreamChatTheme]. +/// Modify it to change the widget appearance. +class ChannelAvatar extends StatelessWidget { + /// Instantiate a new ChannelImage + const ChannelAvatar({ + Key? key, + this.channel, + this.constraints, + this.onTap, + this.borderRadius, + this.selected = false, + this.selectionColor, + this.selectionThickness = 4, + }) : super(key: key); + + /// [BorderRadius] to display the widget + final BorderRadius? borderRadius; + + /// The channel to show the image of + final Channel? channel; + + /// The diameter of the image + final BoxConstraints? constraints; + + /// The function called when the image is tapped + final VoidCallback? onTap; + + /// If image is selected + final bool selected; + + /// Selection color for image + final Color? selectionColor; + + /// Thickness of selection image + final double selectionThickness; + + @override + Widget build(BuildContext context) { + final streamChat = StreamChat.of(context); + final channel = this.channel ?? StreamChannel.of(context).channel; + + assert(channel.state != null, 'Channel ${channel.id} is not initialized'); + + final chatThemeData = StreamChatTheme.of(context); + final colorTheme = chatThemeData.colorTheme; + final previewTheme = chatThemeData.channelPreviewTheme.avatarTheme; + + return BetterStreamBuilder>( + stream: channel.extraDataStream, + initialData: channel.extraData, + builder: (context, extraData) { + final channelImage = extraData['image']; + + if (channelImage != null) { + Widget child = ClipRRect( + borderRadius: borderRadius ?? previewTheme?.borderRadius, + child: Container( + constraints: constraints ?? previewTheme?.constraints, + decoration: BoxDecoration(color: colorTheme.accentPrimary), + child: InkWell( + onTap: onTap, + child: CachedNetworkImage( + imageUrl: channelImage, + errorWidget: (_, __, ___) => Center( + child: Text( + extraData['name']?[0] ?? '', + style: TextStyle( + color: colorTheme.barsBg, + fontWeight: FontWeight.bold, + ), + ), + ), + fit: BoxFit.cover, + ), + ), + ), + ); + + if (selected) { + child = ClipRRect( + key: const Key('selectedImage'), + borderRadius: BorderRadius.circular(selectionThickness) + + (borderRadius ?? + previewTheme?.borderRadius ?? + BorderRadius.zero), + child: Container( + constraints: constraints ?? previewTheme?.constraints, + color: selectionColor ?? colorTheme.accentPrimary, + child: Padding( + padding: EdgeInsets.all(selectionThickness), + child: child, + ), + ), + ); + } + return child; + } + + final currentUser = streamChat.user!; + final otherMembers = channel.state!.members + .where((it) => it.userId != currentUser.id) + .toList(growable: false); + + // our own space, no other members + if (otherMembers.isEmpty) { + return BetterStreamBuilder( + stream: streamChat.client.state.userStream.map((it) => it!), + initialData: currentUser, + builder: (context, user) => UserAvatar( + borderRadius: borderRadius ?? previewTheme?.borderRadius, + user: user, + constraints: constraints ?? previewTheme?.constraints, + onTap: onTap != null ? (_) => onTap!() : null, + selected: selected, + selectionColor: selectionColor ?? colorTheme.accentPrimary, + selectionThickness: selectionThickness, + ), + ); + } + + // 1-1 Conversation + if (otherMembers.length == 1) { + final member = otherMembers.first; + return BetterStreamBuilder( + stream: channel.state!.membersStream.map( + (members) => members.firstWhere( + (it) => it.userId == member.userId, + orElse: () => member, + ), + ), + initialData: member, + builder: (context, member) => UserAvatar( + borderRadius: borderRadius ?? previewTheme?.borderRadius, + user: member.user!, + constraints: constraints ?? previewTheme?.constraints, + onTap: onTap != null ? (_) => onTap!() : null, + selected: selected, + selectionColor: selectionColor ?? colorTheme.accentPrimary, + selectionThickness: selectionThickness, + ), + ); + } + + // Group conversation + return GroupAvatar( + members: otherMembers, + borderRadius: borderRadius ?? previewTheme?.borderRadius, + constraints: constraints ?? previewTheme?.constraints, + onTap: onTap, + selected: selected, + selectionColor: selectionColor ?? colorTheme.accentPrimary, + selectionThickness: selectionThickness, + ); + }, + ); + } +} diff --git a/packages/stream_chat_flutter/lib/src/channel_bottom_sheet.dart b/packages/stream_chat_flutter/lib/src/channel_bottom_sheet.dart index 2f3a26b2..368bfbd8 100644 --- a/packages/stream_chat_flutter/lib/src/channel_bottom_sheet.dart +++ b/packages/stream_chat_flutter/lib/src/channel_bottom_sheet.dart @@ -1,13 +1,14 @@ import 'package:flutter/material.dart'; +import 'package:stream_chat_flutter/src/channel_info.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; -import '../stream_chat_flutter.dart'; -import 'channel_info.dart'; -import 'option_list_tile.dart'; - +/// Bottom Sheet with options class ChannelBottomSheet extends StatefulWidget { - final VoidCallback onViewInfoTap; + /// Constructor for creating bottom sheet + const ChannelBottomSheet({Key? key, this.onViewInfoTap}) : super(key: key); - ChannelBottomSheet({this.onViewInfoTap}); + /// Callback when 'View Info' is tapped + final VoidCallback? onViewInfoTap; @override _ChannelBottomSheetState createState() => _ChannelBottomSheetState(); @@ -16,56 +17,58 @@ class ChannelBottomSheet extends StatefulWidget { class _ChannelBottomSheetState extends State { bool _showActions = true; + late StreamChannelState _streamChannelState; + late StreamChatThemeData _streamChatThemeData; + late StreamChatState _streamChatState; + @override Widget build(BuildContext context) { - var channel = StreamChannel.of(context).channel; + final channel = _streamChannelState.channel; - var members = channel.state.members; + final members = channel.state?.members ?? []; - var userAsMember = - members.firstWhere((e) => e.user.id == StreamChat.of(context).user.id); - var isOwner = userAsMember.role == 'owner'; + final userAsMember = + members.firstWhere((e) => e.user?.id == _streamChatState.user?.id); + final isOwner = userAsMember.role == 'owner'; return Material( - color: StreamChatTheme.of(context).colorTheme.white, + color: _streamChatThemeData.colorTheme.barsBg, clipBehavior: Clip.antiAlias, - shape: RoundedRectangleBorder( + shape: const RoundedRectangleBorder( borderRadius: BorderRadius.only( - topLeft: Radius.circular(16.0), - topRight: Radius.circular(16.0), + topLeft: Radius.circular(16), + topRight: Radius.circular(16), ), ), child: !_showActions - ? SizedBox() + ? const SizedBox() : ListView( shrinkWrap: true, children: [ - SizedBox( - height: 24.0, + const SizedBox( + height: 24, ), Center( child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 16.0), + padding: const EdgeInsets.symmetric(horizontal: 16), child: ChannelName( - textStyle: - StreamChatTheme.of(context).textTheme.headlineBold, + textStyle: _streamChatThemeData.textTheme.headlineBold, ), ), ), - SizedBox( - height: 5.0, + const SizedBox( + height: 5, ), Center( child: ChannelInfo( showTypingIndicator: false, - channel: StreamChannel.of(context).channel, - textStyle: StreamChatTheme.of(context) - .channelPreviewTheme - .subtitle, + channel: _streamChannelState.channel, + textStyle: + _streamChatThemeData.channelPreviewTheme.subtitle, ), ), - SizedBox( - height: 17.0, + const SizedBox( + height: 17, ), if (channel.isDistinct && channel.memberCount == 2) Column( @@ -73,27 +76,27 @@ class _ChannelBottomSheetState extends State { UserAvatar( user: members .firstWhere( - (e) => e.user.id != userAsMember.user.id) - .user, - constraints: BoxConstraints( - maxHeight: 64.0, - maxWidth: 64.0, + (e) => e.user?.id != userAsMember.user?.id) + .user!, + constraints: const BoxConstraints( + maxHeight: 64, + maxWidth: 64, ), - borderRadius: BorderRadius.circular(32.0), + borderRadius: BorderRadius.circular(32), onlineIndicatorConstraints: - BoxConstraints.tight(Size(12.0, 12.0)), + BoxConstraints.tight(const Size(12, 12)), ), - SizedBox( - height: 6.0, + const SizedBox( + height: 6, ), Text( members - .firstWhere( - (e) => e.user.id != userAsMember.user.id) - .user - .name, - style: - StreamChatTheme.of(context).textTheme.footnoteBold, + .firstWhere( + (e) => e.user?.id != userAsMember.user?.id) + .user + ?.name ?? + '', + style: _streamChatThemeData.textTheme.footnoteBold, maxLines: 1, overflow: TextOverflow.ellipsis, ), @@ -101,52 +104,49 @@ class _ChannelBottomSheetState extends State { ), if (!(channel.isDistinct && channel.memberCount == 2)) Container( - height: 94.0, + height: 94, alignment: Alignment.center, child: ListView.builder( scrollDirection: Axis.horizontal, itemCount: members.length, shrinkWrap: true, - itemBuilder: (context, index) { - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 8.0), - child: Column( - children: [ - UserAvatar( - user: members[index].user, - constraints: BoxConstraints.tightFor( - height: 64.0, - width: 64.0, - ), - borderRadius: BorderRadius.circular(32.0), - onlineIndicatorConstraints: - BoxConstraints.tight(Size(12.0, 12.0)), + itemBuilder: (context, index) => Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: Column( + children: [ + UserAvatar( + user: members[index].user!, + constraints: const BoxConstraints.tightFor( + height: 64, + width: 64, ), - SizedBox( - height: 6.0, - ), - Text( - members[index].user.name, - style: StreamChatTheme.of(context) - .textTheme - .footnoteBold, - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ], - ), - ); - }, + borderRadius: BorderRadius.circular(32), + onlineIndicatorConstraints: + BoxConstraints.tight(const Size(12, 12)), + ), + const SizedBox( + height: 6, + ), + Text( + members[index].user?.name ?? '', + style: + _streamChatThemeData.textTheme.footnoteBold, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), ), ), - SizedBox( - height: 24.0, + const SizedBox( + height: 24, ), OptionListTile( leading: Padding( - padding: const EdgeInsets.symmetric(horizontal: 16.0), + padding: const EdgeInsets.symmetric(horizontal: 16), child: StreamSvgIcon.user( - color: StreamChatTheme.of(context).colorTheme.grey, + color: _streamChatThemeData.colorTheme.textLowEmphasis, ), ), title: 'View Info', @@ -155,9 +155,9 @@ class _ChannelBottomSheetState extends State { if (!channel.isDistinct) OptionListTile( leading: Padding( - padding: const EdgeInsets.symmetric(horizontal: 16.0), + padding: const EdgeInsets.symmetric(horizontal: 16), child: StreamSvgIcon.userRemove( - color: StreamChatTheme.of(context).colorTheme.grey, + color: _streamChatThemeData.colorTheme.textLowEmphasis, ), ), title: 'Leave Group', @@ -174,14 +174,13 @@ class _ChannelBottomSheetState extends State { if (isOwner) OptionListTile( leading: Padding( - padding: const EdgeInsets.symmetric(horizontal: 16.0), + padding: const EdgeInsets.symmetric(horizontal: 16), child: StreamSvgIcon.delete( - color: StreamChatTheme.of(context).colorTheme.accentRed, + color: _streamChatThemeData.colorTheme.accentError, ), ), title: 'Delete Conversation', - titleColor: - StreamChatTheme.of(context).colorTheme.accentRed, + titleColor: _streamChatThemeData.colorTheme.accentError, onTap: () async { setState(() { _showActions = false; @@ -194,9 +193,9 @@ class _ChannelBottomSheetState extends State { ), OptionListTile( leading: Padding( - padding: const EdgeInsets.symmetric(horizontal: 16.0), + padding: const EdgeInsets.symmetric(horizontal: 16), child: StreamSvgIcon.closeSmall( - color: StreamChatTheme.of(context).colorTheme.grey, + color: _streamChatThemeData.colorTheme.textLowEmphasis, ), ), title: 'Cancel', @@ -209,6 +208,14 @@ class _ChannelBottomSheetState extends State { ); } + @override + void didChangeDependencies() { + _streamChannelState = StreamChannel.of(context); + _streamChatThemeData = StreamChatTheme.of(context); + _streamChatState = StreamChat.of(context); + super.didChangeDependencies(); + } + Future _showDeleteDialog() async { final res = await showConfirmationDialog( context, @@ -217,10 +224,10 @@ class _ChannelBottomSheetState extends State { question: 'Are you sure you want to delete this conversation?', cancelText: 'CANCEL', icon: StreamSvgIcon.delete( - color: StreamChatTheme.of(context).colorTheme.accentRed, + color: _streamChatThemeData.colorTheme.accentError, ), ); - var channel = StreamChannel.of(context).channel; + final channel = _streamChannelState.channel; if (res == true) { await channel.delete(); Navigator.pop(context); @@ -235,12 +242,15 @@ class _ChannelBottomSheetState extends State { question: 'Are you sure you want to leave this conversation?', cancelText: 'CANCEL', icon: StreamSvgIcon.userRemove( - color: StreamChatTheme.of(context).colorTheme.accentRed, + color: _streamChatThemeData.colorTheme.accentError, ), ); if (res == true) { - final channel = StreamChannel.of(context).channel; - await channel.removeMembers([StreamChat.of(context).user.id]); + final channel = _streamChannelState.channel; + final user = _streamChatState.user; + if (user != null) { + await channel.removeMembers([user.id]); + } Navigator.pop(context); } } diff --git a/packages/stream_chat_flutter/lib/src/channel_file_display_screen.dart b/packages/stream_chat_flutter/lib/src/channel_file_display_screen.dart deleted file mode 100644 index 083b238b..00000000 --- a/packages/stream_chat_flutter/lib/src/channel_file_display_screen.dart +++ /dev/null @@ -1,182 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:stream_chat_flutter/stream_chat_flutter.dart'; - -import 'attachment/attachment.dart'; - -class ChannelFileDisplayScreen extends StatefulWidget { - /// The sorting used for the channels matching the filters. - /// Sorting is based on field and direction, multiple sorting options can be provided. - /// You can sort based on last_updated, last_message_at, updated_at, created_at or member_count. - /// Direction can be ascending or descending. - final List sortOptions; - - /// Pagination parameters - /// limit: the number of users to return (max is 30) - /// offset: the offset (max is 1000) - /// message_limit: how many messages should be included to each channel - final PaginationParams paginationParams; - - /// The builder used when the file list is empty. - final WidgetBuilder emptyBuilder; - - const ChannelFileDisplayScreen({ - this.sortOptions, - this.paginationParams, - this.emptyBuilder, - }); - - @override - _ChannelFileDisplayScreenState createState() => - _ChannelFileDisplayScreenState(); -} - -class _ChannelFileDisplayScreenState extends State { - @override - void initState() { - super.initState(); - final messageSearchBloc = MessageSearchBloc.of(context); - messageSearchBloc.search( - filter: { - 'cid': { - r'$in': [StreamChannel.of(context).channel.cid] - } - }, - messageFilter: { - 'attachments.type': { - r'$in': ['file'], - }, - }, - sort: widget.sortOptions, - pagination: widget.paginationParams, - ); - } - - @override - Widget build(BuildContext context) { - return Scaffold( - backgroundColor: StreamChatTheme.of(context).colorTheme.white, - appBar: AppBar( - brightness: Theme.of(context).brightness, - elevation: 1, - centerTitle: true, - title: Text( - 'Files', - style: TextStyle( - color: StreamChatTheme.of(context).colorTheme.black, - fontSize: 16.0), - ), - leading: Center( - child: InkWell( - onTap: () { - Navigator.of(context).pop(); - }, - child: Container( - width: 24.0, - height: 24.0, - child: StreamSvgIcon.left( - color: StreamChatTheme.of(context).colorTheme.black, - size: 24.0, - ), - ), - ), - ), - backgroundColor: StreamChatTheme.of(context).colorTheme.white, - ), - body: _buildMediaGrid(), - ); - } - - Widget _buildMediaGrid() { - final messageSearchBloc = MessageSearchBloc.of(context); - - return StreamBuilder>( - builder: (context, snapshot) { - if (snapshot.data == null) { - return Center( - child: const CircularProgressIndicator(), - ); - } - - if (snapshot.data.isEmpty) { - if (widget.emptyBuilder != null) { - return widget.emptyBuilder(context); - } - return Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - StreamSvgIcon.files( - size: 136.0, - color: StreamChatTheme.of(context).colorTheme.greyGainsboro, - ), - SizedBox(height: 16.0), - Text( - 'No Files', - style: TextStyle( - fontSize: 14.0, - color: StreamChatTheme.of(context).colorTheme.black, - ), - ), - SizedBox(height: 8.0), - Text( - 'Files sent in this chat will appear here', - textAlign: TextAlign.center, - style: TextStyle( - fontSize: 14.0, - color: StreamChatTheme.of(context) - .colorTheme - .black - .withOpacity(0.5), - ), - ), - ], - ), - ); - } - - final media = {}; - - for (var item in snapshot.data) { - item.message.attachments.where((e) => e.type == 'file').forEach((e) { - media[e] = item.message; - }); - } - - return LazyLoadScrollView( - onEndOfPage: () => messageSearchBloc.search( - filter: { - 'cid': { - r'$in': [StreamChannel.of(context).channel.cid] - } - }, - messageFilter: { - 'attachments.type': { - r'$in': ['file'] - }, - }, - sort: widget.sortOptions, - pagination: widget.paginationParams.copyWith( - offset: messageSearchBloc.messageResponses?.length ?? 0, - ), - ), - child: ListView.builder( - itemBuilder: (context, position) { - return Padding( - padding: const EdgeInsets.all(1.0), - child: Padding( - padding: const EdgeInsets.all(8.0), - child: FileAttachment( - message: media.values.toList()[position], - attachment: media.keys.toList()[position], - ), - ), - ); - }, - itemCount: media.length, - ), - ); - }, - stream: messageSearchBloc.messagesStream, - ); - } -} diff --git a/packages/stream_chat_flutter/lib/src/channel_header.dart b/packages/stream_chat_flutter/lib/src/channel_header.dart index c2927a6e..8f586516 100644 --- a/packages/stream_chat_flutter/lib/src/channel_header.dart +++ b/packages/stream_chat_flutter/lib/src/channel_header.dart @@ -4,13 +4,9 @@ import 'package:stream_chat_flutter/src/channel_info.dart'; import 'package:stream_chat_flutter/src/channel_name.dart'; import 'package:stream_chat_flutter/src/info_tile.dart'; import 'package:stream_chat_flutter/src/stream_chat_theme.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart'; -import './channel_name.dart'; -import '../stream_chat_flutter.dart'; -import 'channel_image.dart'; -import 'connection_status_builder.dart'; - /// ![screenshot](https://raw.githubusercontent.com/GetStream/stream-chat-flutter/master/screenshots/channel_header.png) /// ![screenshot](https://raw.githubusercontent.com/GetStream/stream-chat-flutter/master/screenshots/channel_header_paint.png) /// @@ -43,50 +39,23 @@ import 'connection_status_builder.dart'; /// Usually you would use this widget as an [AppBar] inside a [Scaffold]. /// However you can also use it as a normal widget. /// -/// Make sure to have a [StreamChannel] ancestor in order to provide the information about the channel. -/// Every part of the widget uses a [StreamBuilder] to render the channel information as soon as it updates. +/// Make sure to have a [StreamChannel] ancestor in order to provide the +/// information about the channel. +/// Every part of the widget uses a [StreamBuilder] to render the channel +/// information as soon as it updates. /// /// By default the widget shows a backButton that calls [Navigator.pop]. -/// You can disable this button using the [showBackButton] property of just override the behaviour +/// You can disable this button using the [showBackButton] property of just +/// override the behaviour /// with [onBackPressed]. /// -/// The widget components render the ui based on the first ancestor of type [StreamChatTheme] and on its [ChannelTheme.channelHeaderTheme] property. +/// The widget components render the ui based on the first ancestor of type +/// [StreamChatTheme] and on its [ChannelTheme.channelHeaderTheme] property. /// Modify it to change the widget appearance. class ChannelHeader extends StatelessWidget implements PreferredSizeWidget { - /// True if this header shows the leading back button - final bool showBackButton; - - /// Callback to call when pressing the back button. - /// By default it calls [Navigator.pop] - final VoidCallback onBackPressed; - - /// Callback to call when the header is tapped. - final VoidCallback onTitleTap; - - /// Callback to call when the image is tapped. - final VoidCallback onImageTap; - - /// If true the typing indicator will be rendered if a user is typing - final bool showTypingIndicator; - - final bool showConnectionStateTile; - - /// Title widget - final Widget title; - - /// Subtitle widget - final Widget subtitle; - - /// Leading widget - final Widget leading; - - /// AppBar actions - /// By default it shows the [ChannelImage] - final List actions; - /// Creates a channel header - ChannelHeader({ - Key key, + const ChannelHeader({ + Key? key, this.showBackButton = true, this.onBackPressed, this.onTitleTap, @@ -97,12 +66,45 @@ class ChannelHeader extends StatelessWidget implements PreferredSizeWidget { this.subtitle, this.leading, this.actions, - }) : preferredSize = Size.fromHeight(kToolbarHeight), + }) : preferredSize = const Size.fromHeight(kToolbarHeight), super(key: key); + /// True if this header shows the leading back button + final bool showBackButton; + + /// Callback to call when pressing the back button. + /// By default it calls [Navigator.pop] + final VoidCallback? onBackPressed; + + /// Callback to call when the header is tapped. + final VoidCallback? onTitleTap; + + /// Callback to call when the image is tapped. + final VoidCallback? onImageTap; + + /// If true the typing indicator will be rendered if a user is typing + final bool showTypingIndicator; + + /// Show connection tile on header + final bool showConnectionStateTile; + + /// Title widget + final Widget? title; + + /// Subtitle widget + final Widget? subtitle; + + /// Leading widget + final Widget? leading; + + /// AppBar actions + /// By default it shows the [ChannelAvatar] + final List? actions; + @override Widget build(BuildContext context) { final channel = StreamChannel.of(context).channel; + final chatThemeData = StreamChatTheme.of(context); final leadingWidget = leading ?? (showBackButton @@ -110,7 +112,7 @@ class ChannelHeader extends StatelessWidget implements PreferredSizeWidget { onPressed: onBackPressed, showUnreads: true, ) - : SizedBox()); + : const SizedBox()); return ConnectionStatusBuilder( statusBuilder: (context, status) { @@ -131,32 +133,25 @@ class ChannelHeader extends StatelessWidget implements PreferredSizeWidget { } return InfoTile( - showMessage: showConnectionStateTile ? showStatus : false, + showMessage: showConnectionStateTile && showStatus, message: statusString, child: AppBar( + textTheme: Theme.of(context).textTheme, brightness: Theme.of(context).brightness, elevation: 1, leading: leadingWidget, - backgroundColor: StreamChatTheme.of(context) - .channelTheme - .channelHeaderTheme - .color, + backgroundColor: + chatThemeData.channelTheme.channelHeaderTheme.color, actions: actions ?? [ Padding( - padding: const EdgeInsets.only(right: 10.0), + padding: const EdgeInsets.only(right: 10), child: Center( - child: ChannelImage( - borderRadius: StreamChatTheme.of(context) - .channelTheme - .channelHeaderTheme - .avatarTheme - .borderRadius, - constraints: StreamChatTheme.of(context) - .channelTheme - .channelHeaderTheme - .avatarTheme - .constraints, + child: ChannelAvatar( + borderRadius: chatThemeData.channelTheme + .channelHeaderTheme.avatarTheme?.borderRadius, + constraints: chatThemeData.channelTheme + .channelHeaderTheme.avatarTheme?.constraints, onTap: onImageTap, ), ), @@ -165,29 +160,24 @@ class ChannelHeader extends StatelessWidget implements PreferredSizeWidget { centerTitle: true, title: InkWell( onTap: onTitleTap, - child: Container( + child: SizedBox( height: preferredSize.height, width: preferredSize.width, child: Column( - crossAxisAlignment: CrossAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center, children: [ title ?? ChannelName( - textStyle: StreamChatTheme.of(context) - .channelTheme - .channelHeaderTheme - .title, + textStyle: chatThemeData + .channelTheme.channelHeaderTheme.title, ), - SizedBox(height: 2), + const SizedBox(height: 2), subtitle ?? ChannelInfo( showTypingIndicator: showTypingIndicator, channel: channel, - textStyle: StreamChatTheme.of(context) - .channelTheme - .channelHeaderTheme - .subtitle, + textStyle: chatThemeData + .channelTheme.channelHeaderTheme.subtitle, ), ], ), diff --git a/packages/stream_chat_flutter/lib/src/channel_image.dart b/packages/stream_chat_flutter/lib/src/channel_image.dart deleted file mode 100644 index e7e39d38..00000000 --- a/packages/stream_chat_flutter/lib/src/channel_image.dart +++ /dev/null @@ -1,222 +0,0 @@ -import 'package:cached_network_image/cached_network_image.dart'; -import 'package:flutter/material.dart'; -import 'package:stream_chat_flutter/src/group_image.dart'; -import 'package:stream_chat_flutter/stream_chat_flutter.dart'; -import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart'; - -/// ![screenshot](https://raw.githubusercontent.com/GetStream/stream-chat-flutter/master/screenshots/channel_image.png) -/// ![screenshot](https://raw.githubusercontent.com/GetStream/stream-chat-flutter/master/screenshots/channel_image_paint.png) -/// -/// It shows the current [Channel] image. -/// -/// ```dart -/// class MyApp extends StatelessWidget { -/// final StreamChatClient client; -/// final Channel channel; -/// -/// MyApp(this.client, this.channel); -/// -/// @override -/// Widget build(BuildContext context) { -/// return MaterialApp( -/// debugShowCheckedModeBanner: false, -/// home: StreamChat( -/// client: client, -/// child: StreamChannel( -/// channel: channel, -/// child: Center( -/// child: ChannelImage( -/// channel: channel, -/// ), -/// ), -/// ), -/// ), -/// ); -/// } -/// } -/// ``` -/// -/// The widget uses a [StreamBuilder] to render the channel information image as soon as it updates. -/// -/// By default the widget radius size is 40x40 pixels. -/// Set the property [constraints] to set a custom dimension. -/// -/// The widget renders the ui based on the first ancestor of type [StreamChatTheme]. -/// Modify it to change the widget appearance. -class ChannelImage extends StatelessWidget { - /// Instantiate a new ChannelImage - const ChannelImage({ - Key key, - this.channel, - this.constraints, - this.onTap, - this.showOnlineStatus = true, - this.borderRadius, - this.selected = false, - this.selectionColor, - this.selectionThickness = 4, - }) : super(key: key); - - final BorderRadius borderRadius; - - /// The channel to show the image of - final Channel channel; - - /// The diameter of the image - final BoxConstraints constraints; - - /// The function called when the image is tapped - final VoidCallback onTap; - - final bool showOnlineStatus; - - final bool selected; - - final Color selectionColor; - - final double selectionThickness; - - @override - Widget build(BuildContext context) { - final streamChat = StreamChat.of(context); - final channel = this.channel ?? StreamChannel.of(context).channel; - return StreamBuilder>( - stream: channel.extraDataStream, - initialData: channel.extraData, - builder: (context, snapshot) { - String image; - if (snapshot.data?.containsKey('image') == true) { - image = snapshot.data['image']; - } else if (channel.state.members?.length == 2) { - final otherMember = channel.state.members - .firstWhere((member) => member.user.id != streamChat.user.id); - return StreamBuilder( - stream: streamChat.client.state.usersStream - .map((users) => users[otherMember.userId]), - initialData: otherMember.user, - builder: (context, snapshot) { - return UserAvatar( - borderRadius: borderRadius ?? - StreamChatTheme.of(context) - .channelPreviewTheme - .avatarTheme - .borderRadius, - user: snapshot.data ?? otherMember.user, - constraints: constraints ?? - StreamChatTheme.of(context) - .channelPreviewTheme - .avatarTheme - .constraints, - onTap: onTap != null ? (_) => onTap() : null, - selected: selected, - selectionColor: selectionColor ?? - StreamChatTheme.of(context).colorTheme.accentBlue, - selectionThickness: selectionThickness, - ); - }); - } else { - final images = channel.state.members - .where((member) => - member.user.id != streamChat.user.id && - member.user.extraData['image'] != null) - .take(4) - .map((e) => e.user.extraData['image'] as String) - .toList(); - return GroupImage( - images: images, - borderRadius: borderRadius ?? - StreamChatTheme.of(context) - .channelPreviewTheme - .avatarTheme - .borderRadius, - constraints: constraints ?? - StreamChatTheme.of(context) - .channelPreviewTheme - .avatarTheme - .constraints, - onTap: onTap, - selected: selected, - selectionColor: selectionColor ?? - StreamChatTheme.of(context).colorTheme.accentBlue, - selectionThickness: selectionThickness, - ); - } - - Widget child = ClipRRect( - borderRadius: borderRadius ?? - StreamChatTheme.of(context) - .channelPreviewTheme - .avatarTheme - .borderRadius, - child: Container( - constraints: constraints ?? - StreamChatTheme.of(context) - .channelPreviewTheme - .avatarTheme - .constraints, - decoration: BoxDecoration( - color: StreamChatTheme.of(context).colorTheme.accentBlue, - ), - child: Stack( - alignment: Alignment.center, - fit: StackFit.expand, - children: [ - image != null - ? CachedNetworkImage( - imageUrl: image, - errorWidget: (_, __, ___) { - return Center( - child: Text( - snapshot.data?.containsKey('name') ?? false - ? snapshot.data['name'][0] - : '', - style: TextStyle( - color: StreamChatTheme.of(context) - .colorTheme - .white, - fontWeight: FontWeight.bold, - ), - ), - ); - }, - fit: BoxFit.cover, - ) - : StreamChatTheme.of(context) - .defaultChannelImage(context, channel), - Material( - color: Colors.transparent, - child: InkWell( - onTap: onTap, - ), - ), - ], - ), - ), - ); - if (selected) { - child = ClipRRect( - borderRadius: (borderRadius ?? - StreamChatTheme.of(context) - .ownMessageTheme - .avatarTheme - .borderRadius) + - BorderRadius.circular(selectionThickness), - child: Container( - constraints: constraints ?? - StreamChatTheme.of(context) - .ownMessageTheme - .avatarTheme - .constraints, - color: selectionColor ?? - StreamChatTheme.of(context).colorTheme.accentBlue, - child: Padding( - padding: EdgeInsets.all(selectionThickness), - child: child, - ), - ), - ); - } - return child; - }); - } -} diff --git a/packages/stream_chat_flutter/lib/src/channel_info.dart b/packages/stream_chat_flutter/lib/src/channel_info.dart index 1d6f6fe2..3be26ec8 100644 --- a/packages/stream_chat_flutter/lib/src/channel_info.dart +++ b/packages/stream_chat_flutter/lib/src/channel_info.dart @@ -1,56 +1,63 @@ +import 'package:collection/collection.dart' show IterableExtension; import 'package:flutter/material.dart'; import 'package:jiffy/jiffy.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; -import 'connection_status_builder.dart'; - +/// Widget which shows channel info class ChannelInfo extends StatelessWidget { + /// Constructor which creates a [ChannelInfo] widget + const ChannelInfo({ + Key? key, + required this.channel, + this.textStyle, + this.showTypingIndicator = true, + this.parentId, + }) : super(key: key); + + /// The channel about which the info is to be displayed final Channel channel; /// The style of the text displayed - final TextStyle textStyle; + final TextStyle? textStyle; /// If true the typing indicator will be rendered if a user is typing final bool showTypingIndicator; - const ChannelInfo({ - Key key, - @required this.channel, - this.textStyle, - this.showTypingIndicator = true, - }) : super(key: key); + /// Id of the parent message in case of a thread + final String? parentId; @override Widget build(BuildContext context) { final client = StreamChat.of(context).client; - return StreamBuilder>( - stream: channel.state.membersStream, - initialData: channel.state.members, - builder: (context, snapshot) { - return ConnectionStatusBuilder( - statusBuilder: (context, status) { - switch (status) { - case ConnectionStatus.connected: - return _buildConnectedTitleState(context, snapshot.data); - case ConnectionStatus.connecting: - return _buildConnectingTitleState(context); - case ConnectionStatus.disconnected: - return _buildDisconnectedTitleState(context, client); - default: - return Offstage(); - } - }, - ); - }, + return BetterStreamBuilder>( + stream: channel.state!.membersStream, + initialData: channel.state!.members, + builder: (context, data) => ConnectionStatusBuilder( + statusBuilder: (context, status) { + switch (status) { + case ConnectionStatus.connected: + return _buildConnectedTitleState(context, data); + case ConnectionStatus.connecting: + return _buildConnectingTitleState(context); + case ConnectionStatus.disconnected: + return _buildDisconnectedTitleState(context, client); + default: + return const Offstage(); + } + }, + ), ); } - Widget _buildConnectedTitleState(BuildContext context, List members) { - var alternativeWidget; + Widget _buildConnectedTitleState( + BuildContext context, + List? members, + ) { + Widget? alternativeWidget; - if (channel.memberCount != null && channel.memberCount > 2) { + if (channel.memberCount != null && channel.memberCount! > 2) { var text = '${channel.memberCount} Members'; - final watcherCount = channel.state.watcherCount ?? 0; + final watcherCount = channel.state?.watcherCount ?? 0; if (watcherCount > 0) text += ' $watcherCount Online'; alternativeWidget = Text( text, @@ -60,20 +67,20 @@ class ChannelInfo extends StatelessWidget { .subtitle, ); } else { - final otherMember = members.firstWhere( - (element) => element.userId != StreamChat.of(context).user.id, - orElse: () => null, + final userId = StreamChat.of(context).user?.id; + final otherMember = members?.firstWhereOrNull( + (element) => element.userId != userId, ); if (otherMember != null) { - if (otherMember.user.online) { + if (otherMember.user?.online == true) { alternativeWidget = Text( 'Online', style: textStyle, ); } else { alternativeWidget = Text( - 'Last seen ${Jiffy(otherMember.user.lastActive).fromNow()}', + 'Last seen ${Jiffy(otherMember.user?.lastActive).fromNow()}', style: textStyle, ); } @@ -81,66 +88,65 @@ class ChannelInfo extends StatelessWidget { } if (!showTypingIndicator) { - return alternativeWidget ?? Offstage(); + return alternativeWidget ?? const Offstage(); } return TypingIndicator( + parentId: parentId, alignment: Alignment.center, alternativeWidget: alternativeWidget, style: textStyle, ); } - Widget _buildConnectingTitleState(BuildContext context) { - return Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Container( - height: 16, - width: 16, - child: Center( - child: CircularProgressIndicator(), + Widget _buildConnectingTitleState(BuildContext context) => Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const SizedBox( + height: 16, + width: 16, + child: Center( + child: CircularProgressIndicator(), + ), ), - ), - SizedBox(width: 10), - Text( - 'Searching for Network', - style: textStyle, - ), - ], - ); - } + const SizedBox(width: 10), + Text( + 'Searching for Network', + style: textStyle, + ), + ], + ); Widget _buildDisconnectedTitleState( - BuildContext context, StreamChatClient client) { - return Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text( - 'Offline...', - style: textStyle, - ), - TextButton( - style: TextButton.styleFrom( - padding: const EdgeInsets.all(0), - tapTargetSize: MaterialTapTargetSize.shrinkWrap, - visualDensity: VisualDensity( - horizontal: VisualDensity.minimumDensity, - vertical: VisualDensity.minimumDensity, + BuildContext context, + StreamChatClient client, + ) => + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + 'Offline...', + style: textStyle, + ), + TextButton( + style: TextButton.styleFrom( + padding: const EdgeInsets.all(0), + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + visualDensity: const VisualDensity( + horizontal: VisualDensity.minimumDensity, + vertical: VisualDensity.minimumDensity, + ), + ), + onPressed: () => client + ..closeConnection() + ..openConnection(), + child: Text( + 'Try Again', + style: textStyle?.copyWith( + color: StreamChatTheme.of(context).colorTheme.accentPrimary, + ), ), ), - onPressed: () async { - await client.disconnect(); - return client.connect(); - }, - child: Text( - 'Try Again', - style: textStyle.copyWith( - color: StreamChatTheme.of(context).colorTheme.accentBlue, - ), - ), - ), - ], - ); - } + ], + ); } diff --git a/packages/stream_chat_flutter/lib/src/channel_list_header.dart b/packages/stream_chat_flutter/lib/src/channel_list_header.dart index 88b329e6..0db31f80 100644 --- a/packages/stream_chat_flutter/lib/src/channel_list_header.dart +++ b/packages/stream_chat_flutter/lib/src/channel_list_header.dart @@ -6,11 +6,8 @@ import 'package:stream_chat_flutter/src/stream_neumorphic_button.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart'; -import 'connection_status_builder.dart'; -import 'info_tile.dart'; -import 'stream_chat.dart'; - -typedef _TitleBuilder = Widget Function( +/// Widget builder for title +typedef TitleBuilder = Widget Function( BuildContext context, ConnectionStatus status, StreamChatClient client, @@ -42,15 +39,18 @@ typedef _TitleBuilder = Widget Function( /// Usually you would use this widget as an [AppBar] inside a [Scaffold]. /// However you can also use it as a normal widget. /// -/// The widget by default uses the inherited [StreamChatClient] to fetch information about the status. -/// However you can also pass your own [StreamChatClient] if you don't have it in the widget tree. +/// The widget by default uses the inherited [StreamChatClient] +/// to fetch information about the status. +/// However you can also pass your own [StreamChatClient] +/// if you don't have it in the widget tree. /// -/// The widget components render the ui based on the first ancestor of type [StreamChatTheme] and on its [ChannelListHeaderTheme] property. +/// The widget components render the ui based on the first ancestor of type +/// [StreamChatTheme] and on its [ChannelListHeaderTheme] property. /// Modify it to change the widget appearance. class ChannelListHeader extends StatelessWidget implements PreferredSizeWidget { /// Instantiates a ChannelListHeader const ChannelListHeader({ - Key key, + Key? key, this.client, this.titleBuilder, this.onUserAvatarTap, @@ -63,32 +63,34 @@ class ChannelListHeader extends StatelessWidget implements PreferredSizeWidget { }) : super(key: key); /// Pass this if you don't have a [StreamChatClient] in your widget tree. - final StreamChatClient client; + final StreamChatClient? client; /// Use this to build your own title as per different [ConnectionStatus] - final _TitleBuilder titleBuilder; + final TitleBuilder? titleBuilder; /// Callback to call when pressing the user avatar button. /// By default it calls Scaffold.of(context).openDrawer() - final Function(User) onUserAvatarTap; + final Function(User)? onUserAvatarTap; /// Callback to call when pressing the new chat button. - final VoidCallback onNewChatButtonTap; + final VoidCallback? onNewChatButtonTap; + /// Show connection state tile final bool showConnectionStateTile; - final VoidCallback preNavigationCallback; + /// Callback before navigation is performed + final VoidCallback? preNavigationCallback; /// Subtitle widget - final Widget subtitle; + final Widget? subtitle; /// Leading widget /// By default it shows the logged in user avatar - final Widget leading; + final Widget? leading; /// AppBar actions /// By default it shows the new chat button - final List actions; + final List? actions; @override Widget build(BuildContext context) { @@ -112,36 +114,36 @@ class ChannelListHeader extends StatelessWidget implements PreferredSizeWidget { break; } + final chatThemeData = StreamChatTheme.of(context); return InfoTile( + // ignore: avoid_bool_literals_in_conditional_expressions showMessage: showConnectionStateTile ? showStatus : false, message: statusString, child: AppBar( + textTheme: Theme.of(context).textTheme, brightness: Theme.of(context).brightness, elevation: 1, - backgroundColor: - StreamChatTheme.of(context).channelListHeaderTheme.color, + backgroundColor: chatThemeData.channelListHeaderTheme.color, centerTitle: true, leading: leading ?? Center( - child: UserAvatar( - user: user, - showOnlineStatus: false, - onTap: onUserAvatarTap ?? - (_) { - if (preNavigationCallback != null) { - preNavigationCallback(); - } - Scaffold.of(context).openDrawer(); - }, - borderRadius: StreamChatTheme.of(context) - .channelListHeaderTheme - .avatarTheme - .borderRadius, - constraints: StreamChatTheme.of(context) - .channelListHeaderTheme - .avatarTheme - .constraints, - ), + child: user != null + ? UserAvatar( + user: user, + showOnlineStatus: false, + onTap: onUserAvatarTap ?? + (_) { + if (preNavigationCallback != null) { + preNavigationCallback!(); + } + Scaffold.of(context).openDrawer(); + }, + borderRadius: chatThemeData + .channelListHeaderTheme.avatarTheme?.borderRadius, + constraints: chatThemeData + .channelListHeaderTheme.avatarTheme?.constraints, + ) + : const Offstage(), ), actions: actions ?? [ @@ -149,12 +151,10 @@ class ChannelListHeader extends StatelessWidget implements PreferredSizeWidget { child: IconButton( icon: ConnectionStatusBuilder( statusBuilder: (context, status) { - var color; + Color? color; switch (status) { case ConnectionStatus.connected: - color = StreamChatTheme.of(context) - .colorTheme - .accentBlue; + color = chatThemeData.colorTheme.accentPrimary; break; case ConnectionStatus.connecting: color = Colors.grey; @@ -166,8 +166,8 @@ class ChannelListHeader extends StatelessWidget implements PreferredSizeWidget { return SvgPicture.asset( 'svgs/icon_pen_write.svg', package: 'stream_chat_flutter', - width: 24.0, - height: 24.0, + width: 24, + height: 24, color: color, ); }, @@ -181,7 +181,7 @@ class ChannelListHeader extends StatelessWidget implements PreferredSizeWidget { Builder( builder: (context) { if (titleBuilder != null) { - return titleBuilder(context, status, _client); + return titleBuilder!(context, status, _client); } switch (status) { case ConnectionStatus.connected: @@ -191,11 +191,11 @@ class ChannelListHeader extends StatelessWidget implements PreferredSizeWidget { case ConnectionStatus.disconnected: return _buildDisconnectedTitleState(context, _client); default: - return Offstage(); + return const Offstage(); } }, ), - subtitle ?? Offstage(), + subtitle ?? const Offstage(), ], ), ), @@ -204,65 +204,66 @@ class ChannelListHeader extends StatelessWidget implements PreferredSizeWidget { ); } - Widget _buildConnectedTitleState(BuildContext context) => Text( - 'Stream Chat', - style: StreamChatTheme.of(context).textTheme.headlineBold.copyWith( - color: StreamChatTheme.of(context).colorTheme.black, - ), - ); - - Widget _buildConnectingTitleState(BuildContext context) { - return Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Container( - height: 16, - width: 16, - child: Center( - child: CircularProgressIndicator(), - ), - ), - SizedBox(width: 10), - Text( - 'Searching for Network', - style: - StreamChatTheme.of(context).channelListHeaderTheme.title.copyWith( - fontSize: 16, - fontWeight: FontWeight.bold, - ), - ), - ], + Widget _buildConnectedTitleState(BuildContext context) { + final chatThemeData = StreamChatTheme.of(context); + return Text( + 'Stream Chat', + style: chatThemeData.textTheme.headlineBold.copyWith( + color: chatThemeData.colorTheme.textHighEmphasis, + ), ); } + Widget _buildConnectingTitleState(BuildContext context) => Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const SizedBox( + height: 16, + width: 16, + child: Center( + child: CircularProgressIndicator(), + ), + ), + const SizedBox(width: 10), + Text( + 'Searching for Network', + style: StreamChatTheme.of(context) + .channelListHeaderTheme + .title + ?.copyWith( + fontSize: 16, + fontWeight: FontWeight.bold, + ), + ), + ], + ); + Widget _buildDisconnectedTitleState( - BuildContext context, StreamChatClient client) { + BuildContext context, + StreamChatClient client, + ) { + final chatThemeData = StreamChatTheme.of(context); return Row( mainAxisAlignment: MainAxisAlignment.center, children: [ Text( 'Offline...', - style: - StreamChatTheme.of(context).channelListHeaderTheme.title.copyWith( - fontSize: 16, - fontWeight: FontWeight.bold, - ), + style: chatThemeData.channelListHeaderTheme.title?.copyWith( + fontSize: 16, + fontWeight: FontWeight.bold, + ), ), TextButton( - onPressed: () async { - await client.disconnect(); - return client.connect(); - }, + onPressed: () => client + ..closeConnection() + ..openConnection(), child: Text( 'Try Again', - style: StreamChatTheme.of(context) - .channelListHeaderTheme - .title - .copyWith( - fontSize: 16, - fontWeight: FontWeight.bold, - color: StreamChatTheme.of(context).colorTheme.accentBlue, - ), + style: chatThemeData.channelListHeaderTheme.title?.copyWith( + fontSize: 16, + fontWeight: FontWeight.bold, + color: chatThemeData.colorTheme.accentPrimary, + ), ), ), ], @@ -270,5 +271,5 @@ class ChannelListHeader extends StatelessWidget implements PreferredSizeWidget { } @override - Size get preferredSize => Size.fromHeight(kToolbarHeight); + Size get preferredSize => const Size.fromHeight(kToolbarHeight); } diff --git a/packages/stream_chat_flutter/lib/src/channel_list_view.dart b/packages/stream_chat_flutter/lib/src/channel_list_view.dart index 9751ad6d..83f196df 100644 --- a/packages/stream_chat_flutter/lib/src/channel_list_view.dart +++ b/packages/stream_chat_flutter/lib/src/channel_list_view.dart @@ -1,21 +1,23 @@ +import 'package:collection/collection.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_slidable/flutter_slidable.dart'; import 'package:shimmer/shimmer.dart'; +import 'package:stream_chat_flutter/src/channel_bottom_sheet.dart'; import 'package:stream_chat_flutter/src/stream_svg_icon.dart'; -import 'package:stream_chat_flutter/src/utils.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart'; -import '../stream_chat_flutter.dart'; -import 'channel_bottom_sheet.dart'; -import 'channel_preview.dart'; +/// Callback called when tapping on a channel +typedef ChannelTapCallback = void Function(Channel, Widget?); /// Callback called when tapping on a channel -typedef ChannelTapCallback = void Function(Channel, Widget); +typedef ChannelInfoCallback = void Function(Channel); /// Builder used to create a custom [ChannelPreview] from a [Channel] typedef ChannelPreviewBuilder = Widget Function(BuildContext, Channel); +/// Callback for when 'View Info' is tapped typedef ViewInfoCallback = void Function(Channel); /// ![screenshot](https://raw.githubusercontent.com/GetStream/stream-chat-flutter/master/screenshots/channel_list_view.png) @@ -46,19 +48,27 @@ typedef ViewInfoCallback = void Function(Channel); /// ``` /// /// -/// Make sure to have a [StreamChat] ancestor in order to provide the information about the channels. +/// Make sure to have a [StreamChat] ancestor in order to provide the +/// information about the channels. /// The widget uses a [ListView.custom] to render the list of channels. /// -/// The widget components render the ui based on the first ancestor of type [StreamChatTheme]. +/// The widget components render the ui based on the first ancestor of +/// type [StreamChatTheme]. /// Modify it to change the widget appearance. class ChannelListView extends StatefulWidget { /// Instantiate a new ChannelListView - ChannelListView({ - Key key, + const ChannelListView({ + Key? key, this.filter, - this.options, this.sort, - this.pagination, + this.state = true, + this.watch = true, + this.presence = false, + this.memberLimit, + this.messageLimit, + this.pagination = const PaginationParams( + limit: 25, + ), this.onChannelTap, this.onChannelLongPress, this.channelWidget, @@ -76,6 +86,10 @@ class ChannelListView extends StatefulWidget { this.emptyBuilder, this.loadingBuilder, this.listBuilder, + this.onMoreDetailsPressed, + this.onDeletePressed, + this.swipeActions, + this.channelListController, }) : super(key: key); /// If true a default swipe to action behaviour will be added to this widget @@ -84,19 +98,30 @@ class ChannelListView extends StatefulWidget { /// The query filters to use. /// You can query on any of the custom fields you've defined on the [Channel]. /// You can also filter other built-in channel fields. - final Map filter; - - /// Query channels options. - /// - /// state: if true returns the Channel state - /// watch: if true listen to changes to this Channel in real time. - final Map options; + final Filter? filter; /// The sorting used for the channels matching the filters. - /// Sorting is based on field and direction, multiple sorting options can be provided. - /// You can sort based on last_updated, last_message_at, updated_at, created_at or member_count. + /// Sorting is based on field and direction, multiple sorting options + /// can be provided. + /// You can sort based on last_updated, last_message_at, updated_at, + /// created_at or member_count. /// Direction can be ascending or descending. - final List> sort; + final List>? sort; + + /// If true returns the Channel state + final bool state; + + /// If true listen to changes to this Channel in real time. + final bool watch; + + /// If true you’ll receive user presence updates via the websocket events + final bool presence; + + /// Number of members to fetch in each channel + final int? memberLimit; + + /// Number of messages to fetch in each channel + final int? messageLimit; /// Pagination parameters /// limit: the number of channels to return (max is 30) @@ -107,50 +132,67 @@ class ChannelListView extends StatefulWidget { /// Function called when tapping on a channel /// By default it calls [Navigator.push] building a [MaterialPageRoute] /// with the widget [channelWidget] as child. - final ChannelTapCallback onChannelTap; + final ChannelTapCallback? onChannelTap; /// Function called when long pressing on a channel - final Function(Channel) onChannelLongPress; + final Function(Channel)? onChannelLongPress; /// Widget used when opening a channel - final Widget channelWidget; + final Widget? channelWidget; /// Builder used to create a custom channel preview - final ChannelPreviewBuilder channelPreviewBuilder; + final ChannelPreviewBuilder? channelPreviewBuilder; /// Builder used to create a custom item separator - final Function(BuildContext, int) separatorBuilder; + final Function(BuildContext, int)? separatorBuilder; /// The function called when the image is tapped - final Function(Channel) onImageTap; + final Function(Channel)? onImageTap; /// Set it to false to disable the pull-to-refresh widget final bool pullToRefresh; /// Callback used in the default empty list widget - final VoidCallback onStartChatPressed; + final VoidCallback? onStartChatPressed; /// The number of children in the cross axis. final int crossAxisCount; /// The amount of space by which to inset the children. - final EdgeInsetsGeometry padding; + final EdgeInsetsGeometry? padding; + /// List of selected channels which are displayed differently final List selectedChannels; - final ViewInfoCallback onViewInfoTap; + /// Callback for when 'View Info' is tapped + final ViewInfoCallback? onViewInfoTap; /// The builder that will be used in case of error - final ErrorBuilder errorBuilder; + final ErrorBuilder? errorBuilder; /// The builder that will be used in case of loading - final WidgetBuilder loadingBuilder; + final WidgetBuilder? loadingBuilder; /// The builder which is used when list of channels loads - final Function(BuildContext, List) listBuilder; + final Function(BuildContext, List)? listBuilder; /// The builder used when the channel list is empty. - final WidgetBuilder emptyBuilder; + final WidgetBuilder? emptyBuilder; + + /// Callback used when the more details slidable option is pressed + final ChannelInfoCallback? onMoreDetailsPressed; + + /// Callback used when the delete slidable option is pressed + final ChannelInfoCallback? onDeletePressed; + + /// List of actions for slidable + final List? swipeActions; + + /// A [ChannelListController] allows reloading and pagination. + /// Use [ChannelListController.loadData] and + /// [ChannelListController.paginateData] respectively for reloading and + /// pagination. + final ChannelListController? channelListController; @override _ChannelListViewState createState() => _ChannelListViewState(); @@ -159,15 +201,22 @@ class ChannelListView extends StatefulWidget { class _ChannelListViewState extends State { final _slideController = SlidableController(); - final _channelListController = ChannelListController(); + late final _defaultController = ChannelListController(); + + ChannelListController get _channelListController => + widget.channelListController ?? _defaultController; @override Widget build(BuildContext context) { Widget child = ChannelListCore( - pagination: widget.pagination, - options: widget.options, - sort: widget.sort, filter: widget.filter, + sort: widget.sort, + state: widget.state, + watch: widget.watch, + presence: widget.presence, + memberLimit: widget.memberLimit, + messageLimit: widget.messageLimit, + pagination: widget.pagination, channelListController: _channelListController, listBuilder: widget.listBuilder ?? _buildListView, emptyBuilder: widget.emptyBuilder ?? _buildEmptyWidget, @@ -177,198 +226,174 @@ class _ChannelListViewState extends State { if (widget.pullToRefresh) { child = RefreshIndicator( - onRefresh: () => _channelListController.loadData(), + onRefresh: () => _channelListController.loadData!(), child: child, ); } return LazyLoadScrollView( - onEndOfPage: () => _channelListController.paginateData(), + onEndOfPage: () => _channelListController.paginateData!(), child: child, ); } Widget _buildListView(BuildContext context, List channels) { - Widget child; - - if (channels.isNotEmpty) { - if (widget.crossAxisCount > 1) { - child = GridView.builder( - padding: widget.padding, - gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: widget.crossAxisCount), - itemCount: channels.length, - physics: AlwaysScrollableScrollPhysics(), - itemBuilder: (context, index) { - return _gridItemBuilder(context, index, channels); - }, - ); - } else { - child = ListView.separated( - padding: widget.padding, - physics: AlwaysScrollableScrollPhysics(), - itemCount: - channels.isNotEmpty ? channels.length + 1 : channels.length, - separatorBuilder: (_, index) { - if (widget.separatorBuilder != null) { - return widget.separatorBuilder(context, index); - } - return _separatorBuilder(context, index); - }, - itemBuilder: (context, index) { - return _listItemBuilder(context, index, channels); - }, - ); - } + if (widget.crossAxisCount > 1) { + return GridView.builder( + padding: widget.padding, + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: widget.crossAxisCount, + ), + itemCount: channels.length, + physics: const AlwaysScrollableScrollPhysics(), + itemBuilder: (context, index) => + _gridItemBuilder(context, index, channels), + ); } - - return AnimatedSwitcher( - duration: const Duration(milliseconds: 500), - child: child, + return ListView.separated( + padding: widget.padding, + physics: const AlwaysScrollableScrollPhysics(), + // all channels + progress loader + itemCount: channels.length + 1, + separatorBuilder: (_, index) { + if (widget.separatorBuilder != null) { + return widget.separatorBuilder!(context, index); + } + return _separatorBuilder(context, index); + }, + itemBuilder: (context, index) => + _listItemBuilder(context, index, channels), ); } - Widget _buildEmptyWidget(BuildContext context) { - return LayoutBuilder( - builder: (context, viewportConstraints) { - return SingleChildScrollView( - physics: AlwaysScrollableScrollPhysics(), - child: Stack( - children: [ - ConstrainedBox( - constraints: BoxConstraints( - minHeight: viewportConstraints.maxHeight, + Widget _buildEmptyWidget(BuildContext context) => LayoutBuilder( + builder: (context, viewportConstraints) { + final chatThemeData = StreamChatTheme.of(context); + return SingleChildScrollView( + physics: const AlwaysScrollableScrollPhysics(), + child: Stack( + children: [ + ConstrainedBox( + constraints: BoxConstraints( + minHeight: viewportConstraints.maxHeight, + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Padding( + padding: const EdgeInsets.all(8), + child: StreamSvgIcon.message( + size: 136, + color: chatThemeData.colorTheme.disabled, + ), + ), + Padding( + padding: const EdgeInsets.all(8), + child: Text( + 'Let’s start chatting!', + style: chatThemeData.textTheme.headline, + ), + ), + Padding( + padding: const EdgeInsets.symmetric( + vertical: 8, + horizontal: 52, + ), + child: Text( + 'How about sending your first message to a friend?', + textAlign: TextAlign.center, + style: chatThemeData.textTheme.body.copyWith( + color: chatThemeData.colorTheme.textLowEmphasis, + ), + ), + ), + ], + ), ), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Padding( - padding: const EdgeInsets.all(8.0), - child: StreamSvgIcon.message( - size: 136, - color: StreamChatTheme.of(context) - .colorTheme - .greyGainsboro, - ), - ), - Padding( - padding: const EdgeInsets.all(8.0), - child: Text( - 'Let’s start chatting!', - style: StreamChatTheme.of(context).textTheme.headline, - ), - ), - Padding( - padding: const EdgeInsets.symmetric( - vertical: 8.0, - horizontal: 52, - ), - child: Text( - 'How about sending your first message to a friend?', - textAlign: TextAlign.center, - style: StreamChatTheme.of(context) - .textTheme - .body - .copyWith( - color: - StreamChatTheme.of(context).colorTheme.grey, - ), - ), - ), - ], - ), - ), - if (widget.onStartChatPressed != null) - Positioned( - right: 0, - left: 0, - bottom: 32, - child: Center( - child: TextButton( - onPressed: widget.onStartChatPressed, - child: Text( - 'Start a chat', - style: StreamChatTheme.of(context) - .textTheme - .bodyBold - .copyWith( - color: StreamChatTheme.of(context) - .colorTheme - .accentBlue, - ), + if (widget.onStartChatPressed != null) + Positioned( + right: 0, + left: 0, + bottom: 32, + child: Center( + child: TextButton( + onPressed: widget.onStartChatPressed, + child: Text( + 'Start a chat', + style: chatThemeData.textTheme.bodyBold.copyWith( + color: chatThemeData.colorTheme.accentPrimary, + ), + ), ), ), ), - ), - ], - ), - ); - }, - ); - } - - Widget _buildLoadingWidget(BuildContext context) { - return ListView( - padding: widget.padding, - physics: AlwaysScrollableScrollPhysics(), - children: List.generate( - 25, - (i) { - if (widget.crossAxisCount == 1) { - if (i % 2 != 0) { - if (widget.separatorBuilder != null) { - return widget.separatorBuilder(context, i); - } - return _separatorBuilder(context, i); - } - } - return _buildLoadingItem(context); + ], + ), + ); }, - ), - ); - } + ); + + Widget _buildLoadingWidget(BuildContext context) => ListView( + padding: widget.padding, + physics: const AlwaysScrollableScrollPhysics(), + children: List.generate( + 25, + (i) { + if (widget.crossAxisCount == 1) { + if (i % 2 != 0) { + if (widget.separatorBuilder != null) { + return widget.separatorBuilder!(context, i); + } + return _separatorBuilder(context, i); + } + } + return _buildLoadingItem(context); + }, + ), + ); Shimmer _buildLoadingItem(BuildContext context) { + final chatThemeData = StreamChatTheme.of(context); if (widget.crossAxisCount > 1) { return Shimmer.fromColors( - baseColor: StreamChatTheme.of(context).colorTheme.greyGainsboro, - highlightColor: StreamChatTheme.of(context).colorTheme.whiteSmoke, + baseColor: chatThemeData.colorTheme.disabled, + highlightColor: chatThemeData.colorTheme.inputBg, child: Column( children: [ - SizedBox(height: 4.0), + const SizedBox(height: 4), Row( mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [ for (int i = 0; i < widget.crossAxisCount; i++) Container( - decoration: BoxDecoration( + decoration: const BoxDecoration( color: Colors.white, shape: BoxShape.circle, ), - constraints: BoxConstraints.tightFor( + constraints: const BoxConstraints.tightFor( height: 70, width: 70, ), ), ], ), - SizedBox( - height: 16.0, + const SizedBox( + height: 16, ), ], ), ); } else { return Shimmer.fromColors( - baseColor: StreamChatTheme.of(context).colorTheme.greyGainsboro, - highlightColor: StreamChatTheme.of(context).colorTheme.whiteSmoke, + baseColor: chatThemeData.colorTheme.disabled, + highlightColor: chatThemeData.colorTheme.inputBg, child: ListTile( leading: Container( decoration: BoxDecoration( - color: StreamChatTheme.of(context).colorTheme.white, + color: chatThemeData.colorTheme.barsBg, shape: BoxShape.circle, ), - constraints: BoxConstraints.tightFor( + constraints: const BoxConstraints.tightFor( height: 40, width: 40, ), @@ -381,10 +406,10 @@ class _ChannelListViewState extends State { alignment: Alignment.centerLeft, child: Container( decoration: BoxDecoration( - color: StreamChatTheme.of(context).colorTheme.white, + color: chatThemeData.colorTheme.barsBg, borderRadius: BorderRadius.circular(11), ), - constraints: BoxConstraints.tightFor( + constraints: const BoxConstraints.tightFor( height: 16, width: 82, ), @@ -398,10 +423,10 @@ class _ChannelListViewState extends State { alignment: Alignment.centerLeft, child: Container( decoration: BoxDecoration( - color: StreamChatTheme.of(context).colorTheme.white, + color: chatThemeData.colorTheme.barsBg, borderRadius: BorderRadius.circular(11), ), - constraints: BoxConstraints.expand( + constraints: const BoxConstraints.expand( height: 16, ), ), @@ -410,10 +435,10 @@ class _ChannelListViewState extends State { Container( margin: const EdgeInsets.only(left: 16), decoration: BoxDecoration( - color: StreamChatTheme.of(context).colorTheme.white, + color: chatThemeData.colorTheme.barsBg, borderRadius: BorderRadius.circular(11), ), - constraints: BoxConstraints.tightFor( + constraints: const BoxConstraints.tightFor( height: 16, width: 42, ), @@ -425,175 +450,192 @@ class _ChannelListViewState extends State { } } - Widget _buildErrorWidget(BuildContext context, Object error) { - return Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text.rich( - TextSpan( - children: [ - WidgetSpan( - child: Padding( - padding: const EdgeInsets.only( - right: 2.0, + Widget _buildErrorWidget(BuildContext context, Object error) => Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text.rich( + const TextSpan( + children: [ + WidgetSpan( + child: Padding( + padding: EdgeInsets.only( + right: 2, + ), + child: Icon(Icons.error_outline), ), - child: Icon(Icons.error_outline), ), - ), - TextSpan(text: 'Error loading channels'), - ], + TextSpan(text: 'Error loading channels'), + ], + ), + style: Theme.of(context).textTheme.headline6, ), - style: Theme.of(context).textTheme.headline6, + TextButton( + onPressed: () => _channelListController.loadData!(), + child: const Text('Retry'), + ), + ], + ), + ); + + Widget _listItemBuilder(BuildContext context, int i, List channels) { + final channelsBloc = ChannelsBloc.of(context); + + if (i == channels.length) { + return _buildQueryProgressIndicator(context, channelsBloc); + } + + final onTap = _getChannelTap(context); + final chatThemeData = StreamChatTheme.of(context); + final backgroundColor = chatThemeData.colorTheme.inputBg; + final channel = channels[i]; + + return StreamChannel( + key: ValueKey('CHANNEL-${channel.cid}'), + channel: channel, + child: Slidable( + controller: _slideController, + enabled: widget.swipeToAction, + actionPane: const SlidableBehindActionPane(), + actionExtentRatio: 0.12, + secondaryActions: widget.swipeActions + ?.map((e) => IconSlideAction( + color: e.color, + iconWidget: e.iconWidget, + onTap: () { + e.onTap?.call(channel); + }, + )) + .toList() ?? + [ + IconSlideAction( + color: backgroundColor, + icon: Icons.more_horiz, + onTap: widget.onMoreDetailsPressed != null + ? () { + widget.onMoreDetailsPressed!(channel); + } + : () { + showModalBottomSheet( + clipBehavior: Clip.hardEdge, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.only( + topLeft: Radius.circular(32), + topRight: Radius.circular(32), + ), + ), + context: context, + builder: (context) => StreamChannel( + channel: channel, + child: ChannelBottomSheet( + onViewInfoTap: () { + widget.onViewInfoTap?.call(channel); + }, + ), + ), + ); + }, + ), + if ([ + 'admin', + 'owner', + ].contains(channel.state!.members + .firstWhereOrNull( + (m) => m.userId == channel.client.state.user?.id) + ?.role)) + IconSlideAction( + color: backgroundColor, + iconWidget: StreamSvgIcon.delete( + color: chatThemeData.colorTheme.accentError, + ), + onTap: widget.onDeletePressed != null + ? () { + widget.onDeletePressed!(channel); + } + : () async { + final res = await showConfirmationDialog( + context, + title: 'Delete Conversation', + okText: 'DELETE', + question: + // ignore: lines_longer_than_80_chars + 'Are you sure you want to delete this conversation?', + cancelText: 'CANCEL', + icon: StreamSvgIcon.delete( + color: chatThemeData.colorTheme.accentError, + ), + ); + if (res == true) { + await channel.delete(); + } + }, + ), + ], + child: DecoratedBox( + decoration: BoxDecoration( + color: chatThemeData.colorTheme.appBg, ), - TextButton( - onPressed: () => _channelListController.loadData(), - child: Text('Retry'), - ), - ], + child: widget.channelPreviewBuilder?.call(context, channel) ?? + ChannelPreview( + onLongPress: widget.onChannelLongPress, + channel: channel, + onImageTap: () => widget.onImageTap?.call(channel), + onTap: (channel) => onTap(channel, widget.channelWidget), + ), + ), ), ); } - Widget _listItemBuilder(BuildContext context, int i, List channels) { - final channelsProvider = ChannelsBloc.of(context); - if (i < channels.length) { - final channel = channels[i]; - ChannelTapCallback onTap; - if (widget.onChannelTap != null) { - onTap = widget.onChannelTap; - } else { - onTap = (client, _) { - Navigator.push( - context, - MaterialPageRoute( - builder: (context) { - return StreamChannel( - channel: client, - child: widget.channelWidget, - ); - }, - ), - ); - }; - } - - final backgroundColor = StreamChatTheme.of(context).colorTheme.whiteSmoke; - return StreamChannel( - key: ValueKey('CHANNEL-${channel.id}'), - channel: channel, - child: Builder( - builder: (context) { - return Slidable( - controller: _slideController, - enabled: widget.swipeToAction, - actionPane: SlidableBehindActionPane(), - actionExtentRatio: 0.12, - closeOnScroll: true, - secondaryActions: [ - IconSlideAction( - color: backgroundColor, - icon: Icons.more_horiz, - onTap: () { - showModalBottomSheet( - clipBehavior: Clip.hardEdge, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.only( - topLeft: Radius.circular(32), - topRight: Radius.circular(32), - ), - ), - context: context, - builder: (context) { - return StreamChannel( - channel: channel, - child: ChannelBottomSheet( - onViewInfoTap: () { - widget.onViewInfoTap(channel); - }, - ), - ); - }, - ); - }, - ), - if ([ - 'admin', - 'owner', - ].contains(channel.state.members - .firstWhere((m) => m.userId == channel.client.state.user.id, - orElse: () => null) - ?.role)) - IconSlideAction( - color: backgroundColor, - iconWidget: StreamSvgIcon.delete( - color: StreamChatTheme.of(context).colorTheme.accentRed, - ), - onTap: () async { - final res = await showConfirmationDialog( - context, - title: 'Delete Conversation', - okText: 'DELETE', - question: - 'Are you sure you want to delete this conversation?', - cancelText: 'CANCEL', - icon: StreamSvgIcon.delete( - color: - StreamChatTheme.of(context).colorTheme.accentRed, - ), - ); - if (res == true) { - await channel.delete(); - } - }, - ), - ], - child: Container( - color: StreamChatTheme.of(context).colorTheme.whiteSnow, - child: widget.channelPreviewBuilder?.call(context, channel) ?? - ChannelPreview( - onLongPress: widget.onChannelLongPress, - channel: channel, - onImageTap: () => widget.onImageTap?.call(channel), - onTap: (channel) => onTap(channel, widget.channelWidget), - ), - ), - ); - }, - ), - ); + ChannelTapCallback _getChannelTap(BuildContext context) { + ChannelTapCallback onTap; + if (widget.onChannelTap != null) { + onTap = widget.onChannelTap!; } else { - return _buildQueryProgressIndicator(context, channelsProvider); + onTap = (client, _) { + if (widget.channelWidget == null) { + return; + } + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => StreamChannel( + channel: client, + child: widget.channelWidget!, + ), + ), + ); + }; } + return onTap; } Widget _gridItemBuilder(BuildContext context, int i, List channels) { - var channel = channels[i]; + final channel = channels[i]; - var selected = widget.selectedChannels.contains(channel); + final selected = widget.selectedChannels.contains(channel); return Container( key: ValueKey('CHANNEL-${channel.id}'), child: Column( mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.center, children: [ - ChannelImage( + ChannelAvatar( channel: channel, borderRadius: BorderRadius.circular(32), selected: selected, - constraints: BoxConstraints.tightFor( + constraints: const BoxConstraints.tightFor( width: 64, height: 64, ), - onTap: () => widget.onChannelTap(channel, null), + onTap: () => _getChannelTap(context), ), - SizedBox(height: 7), + const SizedBox(height: 7), Padding( padding: const EdgeInsets.symmetric(horizontal: 8), child: StreamChannel( channel: channel, - child: ChannelName( + child: const ChannelName( textStyle: TextStyle( fontSize: 12, fontWeight: FontWeight.w600, @@ -609,42 +651,61 @@ class _ChannelListViewState extends State { Widget _buildQueryProgressIndicator( context, ChannelsBlocState channelsProvider, - ) { - return StreamBuilder( + ) => + BetterStreamBuilder( stream: channelsProvider.queryChannelsLoading, initialData: false, - builder: (context, snapshot) { - if (snapshot.hasError) { - return Container( - color: StreamChatTheme.of(context) - .colorTheme - .accentRed - .withOpacity(.2), - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 16.0), - child: Center( - child: Text('Error loading channels'), + errorBuilder: (context, err) { + final theme = StreamChatTheme.of(context); + return Container( + color: theme.colorTheme.textLowEmphasis.withOpacity(0.9), + child: Padding( + padding: const EdgeInsets.all(16), + child: Text( + 'Error loading channels', + style: theme.textTheme.body.copyWith( + color: Colors.white, ), ), - ); - } - return snapshot.data - ? Center( - child: Padding( - padding: const EdgeInsets.all(16.0), - child: const CircularProgressIndicator(), - ), - ) - : Offstage(); - }); - } + ), + ); + }, + builder: (context, showLoading) { + if (!showLoading) return const Offstage(); + return const Center( + child: Padding( + padding: EdgeInsets.all(16), + child: CircularProgressIndicator(), + ), + ); + }, + ); Widget _separatorBuilder(context, i) { - var effect = StreamChatTheme.of(context).colorTheme.borderBottom; + final effect = StreamChatTheme.of(context).colorTheme.borderBottom; return Container( height: 1, - color: effect.color.withOpacity(effect.alpha ?? 1.0), + color: effect.color!.withOpacity(effect.alpha ?? 1.0), ); } } + +/// Class for slidable action +class SwipeAction { + /// Constructor for creating [SwipeAction] + SwipeAction({ + this.color, + required this.iconWidget, + this.onTap, + }); + + /// Background color of action + Color? color; + + /// Widget to display as icon + Widget iconWidget; + + /// Callback when icon is tapped + ChannelInfoCallback? onTap; +} diff --git a/packages/stream_chat_flutter/lib/src/channel_media_display_screen.dart b/packages/stream_chat_flutter/lib/src/channel_media_display_screen.dart deleted file mode 100644 index 7b34e449..00000000 --- a/packages/stream_chat_flutter/lib/src/channel_media_display_screen.dart +++ /dev/null @@ -1,251 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:stream_chat_flutter/stream_chat_flutter.dart'; -import 'package:video_player/video_player.dart'; - -import 'attachment/attachment.dart'; - -class ChannelMediaDisplayScreen extends StatefulWidget { - /// The sorting used for the channels matching the filters. - /// Sorting is based on field and direction, multiple sorting options can be provided. - /// You can sort based on last_updated, last_message_at, updated_at, created_at or member_count. - /// Direction can be ascending or descending. - final List sortOptions; - - /// Pagination parameters - /// limit: the number of users to return (max is 30) - /// offset: the offset (max is 1000) - /// message_limit: how many messages should be included to each channel - final PaginationParams paginationParams; - - /// The builder used when the file list is empty. - final WidgetBuilder emptyBuilder; - - final ShowMessageCallback onShowMessage; - - const ChannelMediaDisplayScreen({ - this.sortOptions, - this.paginationParams, - this.emptyBuilder, - this.onShowMessage, - }); - - @override - _ChannelMediaDisplayScreenState createState() => - _ChannelMediaDisplayScreenState(); -} - -class _ChannelMediaDisplayScreenState extends State { - Map controllerCache = {}; - - @override - void initState() { - super.initState(); - final messageSearchBloc = MessageSearchBloc.of(context); - messageSearchBloc.search( - filter: { - 'cid': { - r'$in': [StreamChannel.of(context).channel.cid], - } - }, - messageFilter: { - 'attachments.type': { - r'$in': ['image', 'video'] - }, - }, - sort: widget.sortOptions, - pagination: widget.paginationParams, - ); - } - - @override - Widget build(BuildContext context) { - return Scaffold( - backgroundColor: StreamChatTheme.of(context).colorTheme.white, - appBar: AppBar( - brightness: Theme.of(context).brightness, - elevation: 1, - centerTitle: true, - title: Text( - 'Photos & Videos', - style: TextStyle( - color: StreamChatTheme.of(context).colorTheme.black, - fontSize: 16.0, - ), - ), - leading: Center( - child: InkWell( - onTap: () { - Navigator.of(context).pop(); - }, - child: Container( - width: 24.0, - height: 24.0, - child: StreamSvgIcon.left( - color: StreamChatTheme.of(context).colorTheme.black, - size: 24.0, - ), - ), - ), - ), - backgroundColor: StreamChatTheme.of(context).colorTheme.white, - ), - body: _buildMediaGrid(), - ); - } - - Widget _buildMediaGrid() { - final messageSearchBloc = MessageSearchBloc.of(context); - - return StreamBuilder>( - builder: (context, snapshot) { - if (snapshot.data == null) { - return Center( - child: const CircularProgressIndicator(), - ); - } - - if (snapshot.data.isEmpty) { - if (widget.emptyBuilder != null) { - return widget.emptyBuilder(context); - } - return Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - StreamSvgIcon.pictures( - size: 136.0, - color: StreamChatTheme.of(context).colorTheme.greyGainsboro, - ), - SizedBox(height: 16.0), - Text( - 'No Media', - style: TextStyle( - fontSize: 14.0, - color: StreamChatTheme.of(context).colorTheme.black, - ), - ), - SizedBox(height: 8.0), - Text( - 'Photos or video sent in this chat will \nappear here', - textAlign: TextAlign.center, - style: TextStyle( - fontSize: 14.0, - color: StreamChatTheme.of(context) - .colorTheme - .black - .withOpacity(0.5), - ), - ), - ], - ), - ); - } - - final media = <_AssetPackage>[]; - - for (var item in snapshot.data) { - item.message.attachments - .where((e) => - (e.type == 'image' || e.type == 'video') && - e.ogScrapeUrl == null) - .forEach((e) { - VideoPlayerController controller; - if (e.type == 'video') { - var cachedController = controllerCache[e.assetUrl]; - - if (cachedController == null) { - controller = VideoPlayerController.network(e.assetUrl); - controller.initialize(); - controllerCache[e.assetUrl] = controller; - } else { - controller = cachedController; - } - } - media.add(_AssetPackage(e, item.message, controller)); - }); - } - - return LazyLoadScrollView( - onEndOfPage: () => messageSearchBloc.search( - filter: { - 'cid': { - r'$in': [StreamChannel.of(context).channel.cid] - } - }, - messageFilter: { - 'attachments.type': { - r'$in': ['image', 'video'] - }, - }, - sort: widget.sortOptions, - pagination: widget.paginationParams.copyWith( - offset: messageSearchBloc.messageResponses?.length ?? 0, - ), - ), - child: GridView.builder( - gridDelegate: - SliverGridDelegateWithFixedCrossAxisCount(crossAxisCount: 3), - itemBuilder: (context, position) { - var channel = StreamChannel.of(context).channel; - return Padding( - padding: const EdgeInsets.all(1.0), - child: InkWell( - onTap: () { - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => StreamChannel( - channel: channel, - child: FullScreenMedia( - mediaAttachments: - media.map((e) => e.attachment).toList(), - startIndex: position, - message: media[position].message, - sentAt: media[position].message.createdAt, - userName: media[position].message.user.name, - onShowMessage: widget.onShowMessage, - ), - ), - ), - ); - }, - child: media[position].attachment.type == 'image' - ? IgnorePointer( - child: ImageAttachment( - attachment: media[position].attachment, - message: media[position].message, - showTitle: false, - size: Size( - MediaQuery.of(context).size.width * 0.8, - MediaQuery.of(context).size.height * 0.3, - ), - ), - ) - : VideoPlayer(media[position].videoPlayer), - ), - ); - }, - itemCount: media.length, - ), - ); - }, - stream: messageSearchBloc.messagesStream, - ); - } - - @override - void dispose() { - super.dispose(); - for (var c in controllerCache.values) { - c.dispose(); - } - } -} - -class _AssetPackage { - Attachment attachment; - Message message; - VideoPlayerController videoPlayer; - - _AssetPackage(this.attachment, this.message, this.videoPlayer); -} diff --git a/packages/stream_chat_flutter/lib/src/channel_name.dart b/packages/stream_chat_flutter/lib/src/channel_name.dart index 53817563..4819ecc7 100644 --- a/packages/stream_chat_flutter/lib/src/channel_name.dart +++ b/packages/stream_chat_flutter/lib/src/channel_name.dart @@ -1,79 +1,85 @@ import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart'; -import '../stream_chat_flutter.dart'; - /// It shows the current [Channel] name using a [Text] widget. /// -/// The widget uses a [StreamBuilder] to render the channel information image as soon as it updates. +/// The widget uses a [StreamBuilder] to render the channel information +/// image as soon as it updates. class ChannelName extends StatelessWidget { /// Instantiate a new ChannelName const ChannelName({ - Key key, + Key? key, this.textStyle, + this.textOverflow = TextOverflow.ellipsis, }) : super(key: key); /// The style of the text displayed - final TextStyle textStyle; + final TextStyle? textStyle; + + /// How visual overflow should be handled. + final TextOverflow textOverflow; @override Widget build(BuildContext context) { final client = StreamChat.of(context); final channel = StreamChannel.of(context).channel; - return StreamBuilder>( + return BetterStreamBuilder>( stream: channel.extraDataStream, initialData: channel.extraData, - builder: (context, snapshot) { - return _buildName(snapshot.data, channel.state.members, client); - }, + builder: (context, data) => _buildName( + data, + channel.state?.members, + client, + ), ); } Widget _buildName( Map extraData, - List members, + List? members, StreamChatState client, - ) { - return LayoutBuilder( - builder: (context, constraints) { - String title; - if (extraData['name'] == null) { - final otherMembers = - members.where((member) => member.userId != client.user.id); - if (otherMembers.length == 1) { - title = otherMembers.first.user.name; - } else if (otherMembers.isNotEmpty) { - final maxWidth = constraints.maxWidth; - final maxChars = maxWidth / textStyle.fontSize; - var currentChars = 0; - final currentMembers = []; - otherMembers.forEach((element) { - final newLength = currentChars + element.user.name.length; - if (newLength < maxChars) { - currentChars = newLength; - currentMembers.add(element); + ) => + LayoutBuilder( + builder: (context, constraints) { + var title = 'No title'; + if (extraData['name'] == null) { + final otherMembers = + members?.where((member) => member.userId != client.user!.id); + if (otherMembers?.length == 1) { + if (otherMembers!.first.user != null) { + title = otherMembers.first.user!.name; } - }); + } else if (otherMembers?.isNotEmpty == true) { + final maxWidth = constraints.maxWidth; + final maxChars = maxWidth / (textStyle?.fontSize ?? 1); + var currentChars = 0; + final currentMembers = []; + otherMembers!.forEach((element) { + final newLength = + currentChars + (element.user?.name.length ?? 0); + if (newLength < maxChars) { + currentChars = newLength; + currentMembers.add(element); + } + }); - final exceedingMembers = - otherMembers.length - currentMembers.length; - title = - '${currentMembers.map((e) => e.user.name).join(', ')} ${exceedingMembers > 0 ? '+ $exceedingMembers' : ''}'; + final exceedingMembers = + otherMembers.length - currentMembers.length; + title = '${currentMembers.map((e) => e.user?.name).join(', ')} ' + '${exceedingMembers > 0 ? '+ $exceedingMembers' : ''}'; + } } else { - title = 'No title'; + title = extraData['name']; } - } else { - title = extraData['name']; - } - return Text( - title, - style: textStyle, - overflow: TextOverflow.ellipsis, - ); - }, - ); - } + return Text( + title, + style: textStyle, + overflow: textOverflow, + ); + }, + ); } diff --git a/packages/stream_chat_flutter/lib/src/channel_preview.dart b/packages/stream_chat_flutter/lib/src/channel_preview.dart index 3243988b..bf615248 100644 --- a/packages/stream_chat_flutter/lib/src/channel_preview.dart +++ b/packages/stream_chat_flutter/lib/src/channel_preview.dart @@ -1,55 +1,31 @@ +import 'package:collection/collection.dart' + show IterableExtension, ListEquality; import 'package:flutter/material.dart'; import 'package:flutter/widgets.dart'; import 'package:jiffy/jiffy.dart'; import 'package:stream_chat_flutter/src/stream_svg_icon.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart'; -import '../stream_chat_flutter.dart'; -import 'channel_name.dart'; -import 'channel_unread_indicator.dart'; - /// ![screenshot](https://raw.githubusercontent.com/GetStream/stream-chat-flutter/master/screenshots/channel_preview.png) /// ![screenshot](https://raw.githubusercontent.com/GetStream/stream-chat-flutter/master/screenshots/channel_preview_paint.png) /// /// It shows the current [Channel] preview. /// -/// The widget uses a [StreamBuilder] to render the channel information image as soon as it updates. +/// The widget uses a [StreamBuilder] to render the channel information +/// image as soon as it updates. /// -/// Usually you don't use this widget as it's the default channel preview used by [ChannelListView]. +/// Usually you don't use this widget as it's the default channel preview +/// used by [ChannelListView]. /// -/// The widget renders the ui based on the first ancestor of type [StreamChatTheme]. +/// The widget renders the ui based on the first ancestor of type +/// [StreamChatTheme]. /// Modify it to change the widget appearance. class ChannelPreview extends StatelessWidget { - /// Function called when tapping this widget - final void Function(Channel) onTap; - - /// Function called when long pressing this widget - final void Function(Channel) onLongPress; - - /// Channel displayed - final Channel channel; - - /// The function called when the image is tapped - final VoidCallback onImageTap; - - /// Widget rendering the title - final Widget title; - - /// Widget rendering the subtitle - final Widget subtitle; - - /// Widget rendering the leading element, by default it shows the [ChannelImage] - final Widget leading; - - /// Widget rendering the trailing element, by default it shows the last message date - final Widget trailing; - - /// Widget rendering the sending indicator, by default it uses the [SendingIndicator] widget - final Widget sendingIndicator; - - ChannelPreview({ - @required this.channel, - Key key, + /// Constructor for creating [ChannelPreview] + const ChannelPreview({ + required this.channel, + Key? key, this.onTap, this.onLongPress, this.onImageTap, @@ -60,135 +36,159 @@ class ChannelPreview extends StatelessWidget { this.trailing, }) : super(key: key); + /// Function called when tapping this widget + final void Function(Channel)? onTap; + + /// Function called when long pressing this widget + final void Function(Channel)? onLongPress; + + /// Channel displayed + final Channel channel; + + /// The function called when the image is tapped + final VoidCallback? onImageTap; + + /// Widget rendering the title + final Widget? title; + + /// Widget rendering the subtitle + final Widget? subtitle; + + /// Widget rendering the leading element, by default + /// it shows the [ChannelAvatar] + final Widget? leading; + + /// Widget rendering the trailing element, + /// by default it shows the last message date + final Widget? trailing; + + /// Widget rendering the sending indicator, + /// by default it uses the [SendingIndicator] widget + final Widget? sendingIndicator; + @override Widget build(BuildContext context) { final channelPreviewTheme = StreamChatTheme.of(context).channelPreviewTheme; - return StreamBuilder( + final streamChatState = StreamChat.of(context); + return BetterStreamBuilder( stream: channel.isMutedStream, initialData: channel.isMuted, - builder: (context, snapshot) { - return Opacity( - opacity: snapshot.data ? 0.5 : 1, - child: ListTile( - visualDensity: VisualDensity.compact, - contentPadding: const EdgeInsets.symmetric( - horizontal: 8, - ), - onTap: () { - if (onTap != null) { - onTap(channel); - } - }, - onLongPress: () { - if (onLongPress != null) { - onLongPress(channel); - } - }, - leading: leading ?? - ChannelImage( - onTap: onImageTap, - ), - title: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Flexible( - child: title ?? - ChannelName( - textStyle: channelPreviewTheme.title, - ), - ), - StreamBuilder>( - stream: channel.state.membersStream, - initialData: channel.state.members, - builder: (context, snapshot) { - if (!snapshot.hasData || - snapshot.data.isEmpty || - !snapshot.data.any((Member e) => - e.user.id == channel.client.state.user.id)) { - return SizedBox(); + builder: (context, data) => AnimatedOpacity( + opacity: data ? 0.5 : 1, + duration: const Duration(milliseconds: 300), + child: ListTile( + visualDensity: VisualDensity.compact, + contentPadding: const EdgeInsets.symmetric( + horizontal: 8, + ), + onTap: () => onTap?.call(channel), + onLongPress: () => onLongPress?.call(channel), + leading: leading ?? ChannelAvatar(onTap: onImageTap), + title: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Flexible( + child: title ?? + ChannelName( + textStyle: channelPreviewTheme.title, + ), + ), + BetterStreamBuilder?>( + stream: channel.state?.membersStream, + initialData: channel.state?.members, + comparator: const ListEquality().equals, + builder: (context, members) { + if (members?.isEmpty == true || + members?.any((Member e) => + e.user!.id == + channel.client.state.user?.id) != + true) { + return const SizedBox(); } - return ChannelUnreadIndicator( - channel: channel, + return UnreadIndicator( + cid: channel.cid, ); - }), - ], - ), - subtitle: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Flexible(child: subtitle ?? _buildSubtitle(context)), - sendingIndicator ?? - Builder( - builder: (context) { - final lastMessage = channel.state.messages.lastWhere( - (m) => !m.isDeleted && m.shadowed != true, - orElse: () => null, - ); - if (lastMessage?.user?.id == - StreamChat.of(context).user.id) { - return Padding( - padding: const EdgeInsets.only(right: 4.0), - child: SendingIndicator( - message: lastMessage, - size: channelPreviewTheme.indicatorIconSize, - isMessageRead: channel.state.read - ?.where((element) => - element.user.id != - channel.client.state.user.id) - ?.where((element) => element.lastRead - .isAfter(lastMessage.createdAt)) - ?.isNotEmpty == - true, - ), + }, + ), + ], + ), + subtitle: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Flexible(child: subtitle ?? _buildSubtitle(context)), + sendingIndicator ?? + Builder( + builder: (context) { + final lastMessage = + channel.state?.messages.lastWhereOrNull( + (m) => !m.isDeleted && m.shadowed != true, ); - } - return SizedBox(); - }, - ), - trailing ?? _buildDate(context), - ], + if (lastMessage?.user?.id == + streamChatState.user?.id) { + return Padding( + padding: const EdgeInsets.only(right: 4), + child: SendingIndicator( + message: lastMessage!, + size: channelPreviewTheme.indicatorIconSize, + isMessageRead: channel.state!.read + ?.where((element) => + element.user.id != + channel.client.state.user!.id) + .where((element) => element.lastRead + .isAfter(lastMessage.createdAt)) + .isNotEmpty == + true, + ), + ); + } + return const SizedBox(); + }, + ), + trailing ?? _buildDate(context), + ], + ), ), - ), + )); + } + + Widget _buildDate(BuildContext context) => BetterStreamBuilder( + stream: channel.lastMessageAtStream, + initialData: channel.lastMessageAt, + builder: (context, data) { + if (data == null) { + return const Offstage(); + } + final lastMessageAt = data.toLocal(); + + String stringDate; + final now = DateTime.now(); + + final startOfDay = DateTime(now.year, now.month, now.day); + + if (lastMessageAt.millisecondsSinceEpoch >= + startOfDay.millisecondsSinceEpoch) { + stringDate = Jiffy(lastMessageAt.toLocal()).jm; + } else if (lastMessageAt.millisecondsSinceEpoch >= + startOfDay + .subtract(const Duration(days: 1)) + .millisecondsSinceEpoch) { + stringDate = 'Yesterday'; + } else if (startOfDay.difference(lastMessageAt).inDays < 7) { + stringDate = Jiffy(lastMessageAt.toLocal()).EEEE; + } else { + stringDate = Jiffy(lastMessageAt.toLocal()).yMd; + } + + return Text( + stringDate, + style: + StreamChatTheme.of(context).channelPreviewTheme.lastMessageAt, ); - }); - } - - Widget _buildDate(BuildContext context) { - return StreamBuilder( - stream: channel.lastMessageAtStream, - initialData: channel.lastMessageAt, - builder: (context, snapshot) { - if (!snapshot.hasData) { - return SizedBox(); - } - final lastMessageAt = snapshot.data.toLocal(); - - String stringDate; - final now = DateTime.now(); - - var startOfDay = DateTime(now.year, now.month, now.day); - - if (lastMessageAt.millisecondsSinceEpoch >= - startOfDay.millisecondsSinceEpoch) { - stringDate = Jiffy(lastMessageAt.toLocal()).jm; - } else if (lastMessageAt.millisecondsSinceEpoch >= - startOfDay.subtract(Duration(days: 1)).millisecondsSinceEpoch) { - stringDate = 'Yesterday'; - } else if (startOfDay.difference(lastMessageAt).inDays < 7) { - stringDate = Jiffy(lastMessageAt.toLocal()).EEEE; - } else { - stringDate = Jiffy(lastMessageAt.toLocal()).yMd; - } - - return Text( - stringDate, - style: StreamChatTheme.of(context).channelPreviewTheme.lastMessageAt, - ); - }, - ); - } + }, + ); Widget _buildSubtitle(BuildContext context) { + final chatThemeData = StreamChatTheme.of(context); if (channel.isMuted) { return Row( crossAxisAlignment: CrossAxisAlignment.end, @@ -198,7 +198,7 @@ class ChannelPreview extends StatelessWidget { ), Text( ' Channel is muted', - style: StreamChatTheme.of(context).channelPreviewTheme.subtitle, + style: chatThemeData.channelPreviewTheme.subtitle, ), ], ); @@ -206,101 +206,95 @@ class ChannelPreview extends StatelessWidget { return TypingIndicator( channel: channel, alternativeWidget: _buildLastMessage(context), - style: StreamChatTheme.of(context).channelPreviewTheme.subtitle, + style: chatThemeData.channelPreviewTheme.subtitle, ); } - Widget _buildLastMessage(BuildContext context) { - return StreamBuilder>( - stream: channel.state.messagesStream, - initialData: channel.state.messages, - builder: (context, snapshot) { - final lastMessage = snapshot.data?.lastWhere( - (m) => m.shadowed != true && !m.isDeleted, - orElse: () => null); - if (lastMessage == null) { - return SizedBox(); - } + Widget _buildLastMessage(BuildContext context) => Align( + alignment: Alignment.centerLeft, + child: BetterStreamBuilder?>( + stream: channel.state!.messagesStream, + initialData: channel.state!.messages, + builder: (context, data) { + final lastMessage = data + ?.lastWhereOrNull((m) => m.shadowed != true && !m.isDeleted); + if (lastMessage == null) { + return const SizedBox(); + } - var text = lastMessage.text; - if (lastMessage.attachments != null) { - final parts = [ - ...lastMessage.attachments.map((e) { - if (e.type == 'image') { - return '📷'; - } else if (e.type == 'video') { - return '🎬'; - } else if (e.type == 'giphy') { - return '[GIF]'; - } - return e == lastMessage.attachments.last - ? (e.title ?? 'File') - : '${e.title ?? 'File'} , '; - }).where((e) => e != null), - lastMessage.text ?? '', - ]; + var text = lastMessage.text; + final parts = [ + ...lastMessage.attachments.map((e) { + if (e.type == 'image') { + return '📷'; + } else if (e.type == 'video') { + return '🎬'; + } else if (e.type == 'giphy') { + return '[GIF]'; + } + return e == lastMessage.attachments.last + ? (e.title ?? 'File') + : '${e.title ?? 'File'} , '; + }), + lastMessage.text ?? '', + ]; - text = parts.join(' '); - } + text = parts.join(' '); - return Text.rich( - _getDisplayText( - text, - lastMessage.mentionedUsers, - lastMessage.attachments, - StreamChatTheme.of(context).channelPreviewTheme.subtitle.copyWith( - color: StreamChatTheme.of(context) - .channelPreviewTheme - .subtitle - .color, - fontStyle: (lastMessage.isSystem || lastMessage.isDeleted) - ? FontStyle.italic - : FontStyle.normal), - StreamChatTheme.of(context).channelPreviewTheme.subtitle.copyWith( - color: StreamChatTheme.of(context) - .channelPreviewTheme - .subtitle - .color, - fontStyle: (lastMessage.isSystem || lastMessage.isDeleted) - ? FontStyle.italic - : FontStyle.normal, - fontWeight: FontWeight.bold), - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ); - }, - ); - } + final chatThemeData = StreamChatTheme.of(context); + return Text.rich( + _getDisplayText( + text, + lastMessage.mentionedUsers, + lastMessage.attachments, + chatThemeData.channelPreviewTheme.subtitle?.copyWith( + color: chatThemeData.channelPreviewTheme.subtitle?.color, + fontStyle: (lastMessage.isSystem || lastMessage.isDeleted) + ? FontStyle.italic + : FontStyle.normal), + chatThemeData.channelPreviewTheme.subtitle?.copyWith( + color: chatThemeData.channelPreviewTheme.subtitle?.color, + fontStyle: (lastMessage.isSystem || lastMessage.isDeleted) + ? FontStyle.italic + : FontStyle.normal, + fontWeight: FontWeight.bold, + ), + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.start, + ); + }, + ), + ); TextSpan _getDisplayText( - String text, - List mentions, - List attachments, - TextStyle normalTextStyle, - TextStyle mentionsTextStyle) { - var textList = text.split(' '); - var resList = []; - for (var e in textList) { - if (mentions != null && - mentions.isNotEmpty && + String text, + List mentions, + List attachments, + TextStyle? normalTextStyle, + TextStyle? mentionsTextStyle, + ) { + final textList = text.split(' '); + final resList = []; + for (final e in textList) { + if (mentions.isNotEmpty && mentions.any((element) => '@${element.name}' == e)) { resList.add(TextSpan( text: '$e ', style: mentionsTextStyle, )); - } else if (attachments != null && - attachments.isNotEmpty && + } else if (attachments.isNotEmpty && attachments .where((e) => e.title != null) .any((element) => element.title == e)) { resList.add(TextSpan( text: '$e ', - style: normalTextStyle.copyWith(fontStyle: FontStyle.italic), + style: normalTextStyle?.copyWith(fontStyle: FontStyle.italic), )); } else { resList.add(TextSpan( - text: e == textList.last ? '$e' : '$e ', + text: e == textList.last ? e : '$e ', style: normalTextStyle, )); } diff --git a/packages/stream_chat_flutter/lib/src/channel_unread_indicator.dart b/packages/stream_chat_flutter/lib/src/channel_unread_indicator.dart deleted file mode 100644 index cb9dc362..00000000 --- a/packages/stream_chat_flutter/lib/src/channel_unread_indicator.dart +++ /dev/null @@ -1,49 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart'; -import 'package:stream_chat_flutter/src/stream_chat_theme.dart'; - -class ChannelUnreadIndicator extends StatelessWidget { - const ChannelUnreadIndicator({ - Key key, - @required this.channel, - }) : super(key: key); - - final Channel channel; - - @override - Widget build(BuildContext context) { - return StreamBuilder( - stream: channel.state.unreadCountStream, - initialData: channel.state.unreadCount, - builder: (context, snapshot) { - if (!snapshot.hasData || snapshot.data == 0) { - return SizedBox(); - } - - return Material( - borderRadius: BorderRadius.circular(8), - color: StreamChatTheme.of(context) - .channelPreviewTheme - .unreadCounterColor, - child: Padding( - padding: const EdgeInsets.only( - left: 5.0, - right: 5.0, - top: 2, - bottom: 1, - ), - child: Center( - child: Text( - '${snapshot.data > 99 ? '99+' : snapshot.data}', - style: TextStyle( - fontSize: 11, - color: Colors.white, - ), - ), - ), - ), - ); - }, - ); - } -} diff --git a/packages/stream_chat_flutter/lib/src/connection_status_builder.dart b/packages/stream_chat_flutter/lib/src/connection_status_builder.dart index 01632b1b..fbe7ffae 100644 --- a/packages/stream_chat_flutter/lib/src/connection_status_builder.dart +++ b/packages/stream_chat_flutter/lib/src/connection_status_builder.dart @@ -1,36 +1,30 @@ import 'package:flutter/material.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart'; -import 'stream_chat.dart'; - /// Widget that builds itself based on the latest snapshot of interaction with /// a [Stream] of type [ConnectionStatus]. /// -/// The widget will use the closest [StreamChatClient.wsConnectionStatusStream] in case no -/// stream is provided. +/// The widget will use the closest [StreamChatClient.wsConnectionStatusStream] +/// in case no stream is provided. class ConnectionStatusBuilder extends StatelessWidget { /// Creates a new ConnectionStatusBuilder const ConnectionStatusBuilder({ - Key key, - @required this.statusBuilder, - this.initialStatus = ConnectionStatus.disconnected, + Key? key, + required this.statusBuilder, this.connectionStatusStream, this.errorBuilder, this.loadingBuilder, - }) : assert(statusBuilder != null), - super(key: key); - - /// The connection status that will be used to create the initial snapshot. - final ConnectionStatus initialStatus; + }) : super(key: key); /// The asynchronous computation to which this builder is currently connected. - final Stream connectionStatusStream; + final Stream? connectionStatusStream; /// The builder that will be used in case of error - final Widget Function(BuildContext context, Object error) errorBuilder; + final Widget Function(BuildContext context, Object? error)? errorBuilder; /// The builder that will be used in case of loading - final WidgetBuilder loadingBuilder; + final WidgetBuilder? loadingBuilder; /// The builder that will be used in case of data final Widget Function(BuildContext context, ConnectionStatus status) @@ -40,22 +34,18 @@ class ConnectionStatusBuilder extends StatelessWidget { Widget build(BuildContext context) { final stream = connectionStatusStream ?? StreamChat.of(context).client.wsConnectionStatusStream; - return StreamBuilder( - initialData: initialStatus, + final client = StreamChat.of(context).client; + return BetterStreamBuilder( + initialData: client.wsConnectionStatus, stream: stream, - builder: (context, snapshot) { - if (snapshot.hasError) { - if (errorBuilder != null) { - return errorBuilder(context, snapshot.error); - } - return Offstage(); + loadingBuilder: loadingBuilder, + errorBuilder: (context, error) { + if (errorBuilder != null) { + return errorBuilder!(context, error); } - if (!snapshot.hasData) { - if (loadingBuilder != null) return loadingBuilder(context); - return Offstage(); - } - return statusBuilder(context, snapshot.data); + return const Offstage(); }, + builder: statusBuilder, ); } } diff --git a/packages/stream_chat_flutter/lib/src/date_divider.dart b/packages/stream_chat_flutter/lib/src/date_divider.dart index 20b8151d..8fff2ade 100644 --- a/packages/stream_chat_flutter/lib/src/date_divider.dart +++ b/packages/stream_chat_flutter/lib/src/date_divider.dart @@ -4,15 +4,19 @@ import 'package:stream_chat_flutter/src/stream_chat_theme.dart'; /// It shows a date divider depending on the date difference class DateDivider extends StatelessWidget { - final DateTime dateTime; - final bool uppercase; - + /// Constructor for creating a [DateDivider] const DateDivider({ - Key key, - @required this.dateTime, + Key? key, + required this.dateTime, this.uppercase = false, }) : super(key: key); + /// [DateTime] to display + final DateTime dateTime; + + /// If text is uppercase + final bool uppercase; + @override Widget build(BuildContext context) { final createdAt = Jiffy(dateTime); @@ -22,10 +26,10 @@ class DateDivider extends StatelessWidget { if (Jiffy(createdAt).isSame(now, Units.DAY)) { dayInfo = 'Today'; } else if (Jiffy(createdAt) - .isSame(now.subtract(Duration(days: 1)), Units.DAY)) { + .isSame(now.subtract(const Duration(days: 1)), Units.DAY)) { dayInfo = 'Yesterday'; } else if (Jiffy(createdAt).isAfter( - now.subtract(Duration(days: 7)), + now.subtract(const Duration(days: 7)), Units.DAY, )) { dayInfo = createdAt.EEEE; @@ -40,18 +44,19 @@ class DateDivider extends StatelessWidget { if (uppercase) dayInfo = dayInfo.toUpperCase(); + final chatThemeData = StreamChatTheme.of(context); return Center( child: Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 1), decoration: BoxDecoration( - color: StreamChatTheme.of(context).colorTheme.overlayDark, + color: chatThemeData.colorTheme.overlayDark, borderRadius: BorderRadius.circular(8), ), child: Text( dayInfo, - style: StreamChatTheme.of(context).textTheme.footnote.copyWith( - color: StreamChatTheme.of(context).colorTheme.white, - ), + style: chatThemeData.textTheme.footnote.copyWith( + color: chatThemeData.colorTheme.barsBg, + ), ), ), ); diff --git a/packages/stream_chat_flutter/lib/src/deleted_message.dart b/packages/stream_chat_flutter/lib/src/deleted_message.dart index bd014770..011b0c72 100644 --- a/packages/stream_chat_flutter/lib/src/deleted_message.dart +++ b/packages/stream_chat_flutter/lib/src/deleted_message.dart @@ -1,12 +1,12 @@ -import 'dart:math'; - import 'package:flutter/material.dart'; import 'package:stream_chat_flutter/src/stream_chat_theme.dart'; +/// Widget to display deleted message class DeletedMessage extends StatelessWidget { + /// Constructor to create [DeletedMessage] const DeletedMessage({ - Key key, - @required this.messageTheme, + Key? key, + required this.messageTheme, this.borderRadiusGeometry, this.shape, this.borderSide, @@ -17,55 +17,42 @@ class DeletedMessage extends StatelessWidget { final MessageTheme messageTheme; /// The border radius of the message text - final BorderRadiusGeometry borderRadiusGeometry; + final BorderRadiusGeometry? borderRadiusGeometry; /// The shape of the message text - final ShapeBorder shape; + final ShapeBorder? shape; /// The borderside of the message text - final BorderSide borderSide; + final BorderSide? borderSide; /// If true the widget will be mirrored final bool reverse; @override Widget build(BuildContext context) { - return Transform( - transform: Matrix4.rotationY(reverse ? pi : 0), - alignment: Alignment.center, - child: Material( - color: messageTheme.messageBackgroundColor, - shape: shape ?? - RoundedRectangleBorder( - borderRadius: borderRadiusGeometry ?? BorderRadius.zero, - side: borderSide ?? - BorderSide( - color: Theme.of(context).brightness == Brightness.dark - ? StreamChatTheme.of(context) - .colorTheme - .white - .withAlpha(24) - : StreamChatTheme.of(context) - .colorTheme - .black - .withAlpha(24), - ), - ), - child: Padding( - padding: const EdgeInsets.symmetric( - vertical: 8.0, - horizontal: 16, + final chatThemeData = StreamChatTheme.of(context); + return Material( + color: messageTheme.messageBackgroundColor, + shape: shape ?? + RoundedRectangleBorder( + borderRadius: borderRadiusGeometry ?? BorderRadius.zero, + side: borderSide ?? + BorderSide( + color: Theme.of(context).brightness == Brightness.dark + ? chatThemeData.colorTheme.barsBg.withAlpha(24) + : chatThemeData.colorTheme.textHighEmphasis.withAlpha(24), + ), ), - child: Transform( - transform: Matrix4.rotationY(reverse ? pi : 0), - alignment: Alignment.center, - child: Text( - 'Message deleted', - style: messageTheme.messageText.copyWith( - fontStyle: FontStyle.italic, - color: messageTheme.createdAt.color, - ), - ), + child: Padding( + padding: const EdgeInsets.symmetric( + vertical: 8, + horizontal: 16, + ), + child: Text( + 'Message deleted', + style: messageTheme.messageText?.copyWith( + fontStyle: FontStyle.italic, + color: messageTheme.createdAt?.color, ), ), ), diff --git a/packages/stream_chat_flutter/lib/src/emoji/emoji.dart b/packages/stream_chat_flutter/lib/src/emoji/emoji.dart new file mode 100644 index 00000000..38af85af --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/emoji/emoji.dart @@ -0,0 +1,114467 @@ +// Copyright 2020 Naji. All rights reserved. +// +// Redistribution and use in source and binary forms, with or without +// modification, are permitted provided that the following conditions are +// met: +// +// * Redistributions of source code must retain the above copyright +// notice, this list of conditions and the following disclaimer. +// * Redistributions in binary form must reproduce the above +// copyright notice, this list of conditions and the following disclaimer +// in the documentation and/or other materials provided with the +// distribution. +// * Neither the name of Naji nor the names of its +// contributors may be used to endorse or promote products derived from +// this software without specific prior written permission. +// +// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +// "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +// LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +// A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +// OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +// SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +// LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +// DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +// THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +// (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +// OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +import 'package:collection/collection.dart' show IterableExtension; + +/// All Groups +enum EmojiGroup { + smileysEmotion, + activities, + peopleBody, + objects, + travelPlaces, + component, + animalsNature, + foodDrink, + symbols, + flags +} + +/// All Subgroups +enum EmojiSubgroup { + faceSmiling, + faceAffection, + faceSleepy, + faceTongue, + faceNeutralSkeptical, + faceGlasses, + faceHat, + faceConcerned, + faceNegative, + faceUnwell, + faceHand, + faceCostume, + event, + catFace, + hands, + handFingersClosed, + handFingersPartial, + handSingleFinger, + handFingersOpen, + bodyParts, + handProp, + clothing, + emotion, + personSymbol, + person, + personRole, + personFantasy, + personGesture, + personActivity, + family, + artsCrafts, + office, + hotel, + skyWeather, + hairStyle, + animalMammal, + animalAmphibian, + monkeyFace, + animalBird, + animalBug, + animalReptile, + animalMarine, + foodMarine, + plantOther, + foodVegetable, + placeBuilding, + plantFlower, + placeMap, + foodFruit, + foodAsian, + foodPrepared, + foodSweet, + drink, + dishware, + sport, + tool, + game, + transportGround, + personSport, + transportAir, + personResting, + awardMedal, + placeOther, + lightVideo, + music, + musicalInstrument, + transportWater, + otherObject, + placeGeographic, + placeReligious, + time, + phone, + computer, + science, + household, + money, + medical, + transportSign, + lock, + mail, + bookPaper, + sound, + writing, + religion, + zodiac, + alphanum, + warning, + avSymbol, + otherSymbol, + punctuation, + geometric, + keycap, + arrow, + math, + currency, + gender, + flag, + countryFlag, + subdivisionFlag, + skinTone, + regional +} + +/// List of All Emojis. +final List _emojis = [ + Emoji( + name: 'grinning face', + char: '\u{1F600}', + shortName: 'grinning', + emojiGroup: EmojiGroup.smileysEmotion, + emojiSubgroup: EmojiSubgroup.faceSmiling, + keywords: [ + 'face', + 'grin', + 'uc6', + 'smiley', + 'happy', + 'silly', + 'laugh', + 'thank you', + 'awesome', + 'smile', + 'friend', + 'pleased', + 'teeth', + 'pacman', + 'fun', + 'smileys', + 'mood', + 'emotion', + 'emotions', + 'emotional', + 'hooray', + 'cheek', + 'cheeky', + 'excited', + 'feliz', + 'heureux', + 'cheerful', + 'delighted', + 'ecstatic', + 'elated', + 'glad', + 'joy', + 'merry', + 'funny', + 'laughing', + 'lol', + 'rofl', + 'lmao', + 'lmfao', + 'hilarious', + 'ha', + 'haha', + 'chuckle', + 'comedy', + 'giggle', + 'hehe', + 'joyful', + 'laugh out loud', + 'rire', + 'tee hee', + 'jaja', + 'thanks', + 'thankful', + 'praise', + 'gracias', + 'merci', + 'thankyou', + 'acceptable', + 'okay', + 'got it', + 'cool', + 'ok', + 'will do', + 'like', + 'bien', + 'yep', + 'yup', + 'smiles', + 'friends', + 'friendship', + 'best friends', + 'bestfriends', + 'ami', + 'amiga', + 'amigo', + 'please', + 'chill', + 'confident', + 'content', + 'dentist', + 'pac man' + ]), + Emoji( + name: 'grinning face with big eyes', + char: '\u{1F603}', + shortName: 'smiley', + emojiGroup: EmojiGroup.smileysEmotion, + emojiSubgroup: EmojiSubgroup.faceSmiling, + keywords: [ + 'face', + 'mouth', + 'open', + 'smile', + 'uc6', + 'smiley', + 'happy', + 'silly', + 'laugh', + 'good', + 'smile', + 'teeth', + 'fun', + 'smileys', + 'mood', + 'emotion', + 'emotions', + 'emotional', + 'hooray', + 'cheek', + 'cheeky', + 'excited', + 'feliz', + 'heureux', + 'cheerful', + 'delighted', + 'ecstatic', + 'elated', + 'glad', + 'joy', + 'merry', + 'funny', + 'laughing', + 'lol', + 'rofl', + 'lmao', + 'lmfao', + 'hilarious', + 'ha', + 'haha', + 'chuckle', + 'comedy', + 'giggle', + 'hehe', + 'joyful', + 'laugh out loud', + 'rire', + 'tee hee', + 'jaja', + 'good job', + 'nice', + 'well done', + 'bravo', + 'congratulations', + 'congrats', + 'smiles', + 'dentist', + ':-D', + '=D' + ]), + Emoji( + name: 'grinning face with smiling eyes', + char: '\u{1F604}', + shortName: 'smile', + emojiGroup: EmojiGroup.smileysEmotion, + emojiSubgroup: EmojiSubgroup.faceSmiling, + keywords: [ + 'eye', + 'face', + 'mouth', + 'open', + 'smile', + 'uc6', + 'smiley', + 'happy', + 'laugh', + 'smile', + 'teeth', + 'fun', + 'smileys', + 'mood', + 'emotion', + 'emotions', + 'emotional', + 'hooray', + 'cheek', + 'cheeky', + 'excited', + 'feliz', + 'heureux', + 'cheerful', + 'delighted', + 'ecstatic', + 'elated', + 'glad', + 'joy', + 'merry', + 'laughing', + 'lol', + 'rofl', + 'lmao', + 'lmfao', + 'hilarious', + 'ha', + 'haha', + 'chuckle', + 'comedy', + 'giggle', + 'hehe', + 'joyful', + 'laugh out loud', + 'rire', + 'tee hee', + 'jaja', + 'smiles', + 'dentist', + ':D' + ]), + Emoji( + name: 'beaming face with smiling eyes', + char: '\u{1F601}', + shortName: 'grin', + emojiGroup: EmojiGroup.smileysEmotion, + emojiSubgroup: EmojiSubgroup.faceSmiling, + keywords: [ + 'eye', + 'face', + 'grin', + 'smile', + 'uc6', + 'smiley', + 'happy', + 'silly', + 'laugh', + 'thank you', + 'good', + 'beautiful', + 'selfie', + 'smile', + 'friend', + 'teeth', + 'dumb', + 'grimace', + 'fun', + 'proud', + 'smileys', + 'mood', + 'emotion', + 'emotions', + 'emotional', + 'hooray', + 'cheek', + 'cheeky', + 'excited', + 'feliz', + 'heureux', + 'cheerful', + 'delighted', + 'ecstatic', + 'elated', + 'glad', + 'joy', + 'merry', + 'funny', + 'laughing', + 'lol', + 'rofl', + 'lmao', + 'lmfao', + 'hilarious', + 'ha', + 'haha', + 'chuckle', + 'comedy', + 'giggle', + 'hehe', + 'joyful', + 'laugh out loud', + 'rire', + 'tee hee', + 'jaja', + 'thanks', + 'thankful', + 'praise', + 'gracias', + 'merci', + 'thankyou', + 'acceptable', + 'good job', + 'nice', + 'well done', + 'bravo', + 'congratulations', + 'congrats', + 'cute', + 'pretty', + 'adorable', + 'adore', + 'beauty', + 'cutie', + 'babe', + 'lovely', + 'smiles', + 'friends', + 'friendship', + 'best friends', + 'bestfriends', + 'ami', + 'amiga', + 'amigo', + 'dentist', + 'idiot', + 'ignorant', + 'stupid' + ]), + Emoji( + name: 'grinning squinting face', + char: '\u{1F606}', + shortName: 'laughing', + emojiGroup: EmojiGroup.smileysEmotion, + emojiSubgroup: EmojiSubgroup.faceSmiling, + keywords: [ + 'face', + 'laugh', + 'mouth', + 'open', + 'satisfied', + 'smile', + 'uc6', + 'smiley', + 'happy', + 'silly', + 'laugh', + 'smile', + 'teeth', + 'dumb', + 'fun', + 'smileys', + 'mood', + 'emotion', + 'emotions', + 'emotional', + 'hooray', + 'cheek', + 'cheeky', + 'excited', + 'feliz', + 'heureux', + 'cheerful', + 'delighted', + 'ecstatic', + 'elated', + 'glad', + 'joy', + 'merry', + 'funny', + 'laughing', + 'lol', + 'rofl', + 'lmao', + 'lmfao', + 'hilarious', + 'ha', + 'haha', + 'chuckle', + 'comedy', + 'giggle', + 'hehe', + 'joyful', + 'laugh out loud', + 'rire', + 'tee hee', + 'jaja', + 'smiles', + 'dentist', + 'idiot', + 'ignorant', + 'stupid', + '>:)', + '>;)', + '>:-)', + '>=)' + ]), + Emoji( + name: 'grinning face with sweat', + char: '\u{1F605}', + shortName: 'sweat_smile', + emojiGroup: EmojiGroup.smileysEmotion, + emojiSubgroup: EmojiSubgroup.faceSmiling, + keywords: [ + 'cold', + 'face', + 'open', + 'smile', + 'sweat', + 'uc6', + 'smiley', + 'happy', + 'laugh', + 'sweat', + 'smile', + 'tease', + 'drip', + 'guilty', + 'porn', + 'smileys', + 'mood', + 'emotion', + 'emotions', + 'emotional', + 'hooray', + 'cheek', + 'cheeky', + 'excited', + 'feliz', + 'heureux', + 'cheerful', + 'delighted', + 'ecstatic', + 'elated', + 'glad', + 'joy', + 'merry', + 'laughing', + 'lol', + 'rofl', + 'lmao', + 'lmfao', + 'hilarious', + 'ha', + 'haha', + 'chuckle', + 'comedy', + 'giggle', + 'hehe', + 'joyful', + 'laugh out loud', + 'rire', + 'tee hee', + 'jaja', + 'smiles', + 'joke', + 'kidding', + ':)', + ':-)', + '=)', + ':D', + ':-D', + '=D' + ]), + Emoji( + name: 'face with tears of joy', + char: '\u{1F602}', + shortName: 'joy', + emojiGroup: EmojiGroup.smileysEmotion, + emojiSubgroup: EmojiSubgroup.faceSmiling, + keywords: [ + 'face', + 'joy', + 'laugh', + 'tear', + 'uc6', + 'smiley', + 'happy', + 'silly', + 'cry', + 'laugh', + 'sarcastic', + 'smile', + 'tease', + 'crazy', + 'smileys', + 'mood', + 'emotion', + 'emotions', + 'emotional', + 'hooray', + 'cheek', + 'cheeky', + 'excited', + 'feliz', + 'heureux', + 'cheerful', + 'delighted', + 'ecstatic', + 'elated', + 'glad', + 'joy', + 'merry', + 'funny', + 'crying', + 'weeping', + 'weep', + 'sob', + 'sobbing', + 'tear', + 'tears', + 'bawling', + 'laughing', + 'lol', + 'rofl', + 'lmao', + 'lmfao', + 'hilarious', + 'ha', + 'haha', + 'chuckle', + 'comedy', + 'giggle', + 'hehe', + 'joyful', + 'laugh out loud', + 'rire', + 'tee hee', + 'jaja', + 'sarcasm', + 'smiles', + 'joke', + 'kidding', + 'weird', + 'awkward', + 'insane', + 'wild', + ":')", + ":'-)" + ]), + Emoji( + name: 'rolling on the floor laughing', + char: '\u{1F923}', + shortName: 'rofl', + emojiGroup: EmojiGroup.smileysEmotion, + emojiSubgroup: EmojiSubgroup.faceSmiling, + keywords: [ + 'face', + 'floor', + 'laugh', + 'rolling', + 'uc9', + 'smiley', + 'happy', + 'silly', + 'laugh', + 'tease', + 'crazy', + 'smileys', + 'mood', + 'emotion', + 'emotions', + 'emotional', + 'hooray', + 'cheek', + 'cheeky', + 'excited', + 'feliz', + 'heureux', + 'cheerful', + 'delighted', + 'ecstatic', + 'elated', + 'glad', + 'joy', + 'merry', + 'funny', + 'laughing', + 'lol', + 'rofl', + 'lmao', + 'lmfao', + 'hilarious', + 'ha', + 'haha', + 'chuckle', + 'comedy', + 'giggle', + 'hehe', + 'joyful', + 'laugh out loud', + 'rire', + 'tee hee', + 'jaja', + 'joke', + 'kidding', + 'weird', + 'awkward', + 'insane', + 'wild' + ]), + Emoji( + name: 'smiling face', + char: '\u{263A}\u{FE0F}', + shortName: 'relaxed', + emojiGroup: EmojiGroup.smileysEmotion, + emojiSubgroup: EmojiSubgroup.faceAffection, + keywords: [ + 'face', + 'outlined', + 'relaxed', + 'smile', + 'uc1', + 'smiley', + 'happy', + 'beautiful', + 'smile', + 'blush', + 'pleased', + 'smileys', + 'mood', + 'emotion', + 'emotions', + 'emotional', + 'hooray', + 'cheek', + 'cheeky', + 'excited', + 'feliz', + 'heureux', + 'cheerful', + 'delighted', + 'ecstatic', + 'elated', + 'glad', + 'joy', + 'merry', + 'cute', + 'pretty', + 'adorable', + 'adore', + 'beauty', + 'cutie', + 'babe', + 'lovely', + 'smiles', + 'blushing', + 'bella', + 'embarrassed', + 'creep', + 'please', + 'chill', + 'confident', + 'content' + ]), + Emoji( + name: 'smiling face with smiling eyes', + char: '\u{1F60A}', + shortName: 'blush', + emojiGroup: EmojiGroup.smileysEmotion, + emojiSubgroup: EmojiSubgroup.faceSmiling, + keywords: [ + 'blush', + 'eye', + 'face', + 'smile', + 'uc6', + 'smiley', + 'happy', + 'good', + 'beautiful', + 'smile', + 'blush', + 'pleased', + 'smileys', + 'mood', + 'emotion', + 'emotions', + 'emotional', + 'hooray', + 'cheek', + 'cheeky', + 'excited', + 'feliz', + 'heureux', + 'cheerful', + 'delighted', + 'ecstatic', + 'elated', + 'glad', + 'joy', + 'merry', + 'good job', + 'nice', + 'well done', + 'bravo', + 'congratulations', + 'congrats', + 'cute', + 'pretty', + 'adorable', + 'adore', + 'beauty', + 'cutie', + 'babe', + 'lovely', + 'smiles', + 'blushing', + 'bella', + 'embarrassed', + 'creep', + 'please', + 'chill', + 'confident', + 'content' + ]), + Emoji( + name: 'smiling face with halo', + char: '\u{1F607}', + shortName: 'innocent', + emojiGroup: EmojiGroup.smileysEmotion, + emojiSubgroup: EmojiSubgroup.faceSmiling, + keywords: [ + 'angel', + 'face', + 'fairy tale', + 'fantasy', + 'halo', + 'innocent', + 'smile', + 'uc6', + 'smiley', + 'silly', + 'pray', + 'smile', + 'blush', + 'fantasy', + 'soul', + 'smileys', + 'mood', + 'emotion', + 'emotions', + 'emotional', + 'funny', + 'prayer', + 'praying', + 'prayers', + 'grateful', + 'sorry', + 'heaven', + 'bless', + 'faith', + 'holy', + 'spirit', + 'hopeful', + 'blessed', + 'preach', + 'offering', + 'smiles', + 'blushing', + 'bella', + 'embarrassed', + 'creep', + 'O:-)', + '0:-3', + '0:3', + '0:-)', + '0:)', + '0;^)', + 'O:)', + 'O;-)', + 'O=)', + '0;-)', + 'O:-3', + 'O:3' + ]), + Emoji( + name: 'slightly smiling face', + char: '\u{1F642}', + shortName: 'slight_smile', + emojiGroup: EmojiGroup.smileysEmotion, + emojiSubgroup: EmojiSubgroup.faceSmiling, + keywords: [ + 'face', + 'smile', + 'uc7', + 'smiley', + 'happy', + 'awesome', + 'smile', + 'blush', + 'pleased', + 'fun', + 'smileys', + 'mood', + 'emotion', + 'emotions', + 'emotional', + 'hooray', + 'cheek', + 'cheeky', + 'excited', + 'feliz', + 'heureux', + 'cheerful', + 'delighted', + 'ecstatic', + 'elated', + 'glad', + 'joy', + 'merry', + 'okay', + 'got it', + 'cool', + 'ok', + 'will do', + 'like', + 'bien', + 'yep', + 'yup', + 'smiles', + 'blushing', + 'bella', + 'embarrassed', + 'creep', + 'please', + 'chill', + 'confident', + 'content', + ':)', + ':-)', + '=]', + '=)', + ':]' + ]), + Emoji( + name: 'upside-down face', + char: '\u{1F643}', + shortName: 'upside_down', + emojiGroup: EmojiGroup.smileysEmotion, + emojiSubgroup: EmojiSubgroup.faceSmiling, + keywords: [ + 'face', + 'upside-down', + 'uc8', + 'smiley', + 'happy', + 'silly', + 'sarcastic', + 'smile', + 'pleased', + 'dumb', + 'what', + 'clever', + 'smileys', + 'mood', + 'emotion', + 'emotions', + 'emotional', + 'hooray', + 'cheek', + 'cheeky', + 'excited', + 'feliz', + 'heureux', + 'cheerful', + 'delighted', + 'ecstatic', + 'elated', + 'glad', + 'joy', + 'merry', + 'funny', + 'sarcasm', + 'smiles', + 'please', + 'chill', + 'confident', + 'content', + 'idiot', + 'ignorant', + 'stupid', + 'witty' + ]), + Emoji( + name: 'winking face', + char: '\u{1F609}', + shortName: 'wink', + emojiGroup: EmojiGroup.smileysEmotion, + emojiSubgroup: EmojiSubgroup.faceSmiling, + keywords: [ + 'face', + 'wink', + 'uc6', + 'smiley', + 'happy', + 'silly', + 'sarcastic', + 'selfie', + 'smile', + 'tease', + 'clever', + 'smileys', + 'mood', + 'emotion', + 'emotions', + 'emotional', + 'hooray', + 'cheek', + 'cheeky', + 'excited', + 'feliz', + 'heureux', + 'cheerful', + 'delighted', + 'ecstatic', + 'elated', + 'glad', + 'joy', + 'merry', + 'funny', + 'sarcasm', + 'smiles', + 'joke', + 'kidding', + 'witty', + ';)', + ';-)', + '*-)', + '*)', + ';-]', + ';]', + ';D', + ';^)' + ]), + Emoji( + name: 'relieved face', + char: '\u{1F60C}', + shortName: 'relieved', + emojiGroup: EmojiGroup.smileysEmotion, + emojiSubgroup: EmojiSubgroup.faceSleepy, + keywords: [ + 'face', + 'relieved', + 'uc6', + 'smiley', + 'happy', + 'smile', + 'pleased', + 'calm', + 'smileys', + 'mood', + 'emotion', + 'emotions', + 'emotional', + 'hooray', + 'cheek', + 'cheeky', + 'excited', + 'feliz', + 'heureux', + 'cheerful', + 'delighted', + 'ecstatic', + 'elated', + 'glad', + 'joy', + 'merry', + 'smiles', + 'please', + 'chill', + 'confident', + 'content' + ]), + Emoji( + name: 'smiling face with tear', + char: '\u{1F972}', + shortName: 'smiling_face_with_tear', + emojiGroup: EmojiGroup.smileysEmotion, + emojiSubgroup: EmojiSubgroup.faceAffection, + keywords: [ + 'uc13', + 'smiley', + 'happy', + 'cry', + 'thank you', + 'beautiful', + 'smile', + 'blush', + 'pleased', + 'drip', + 'hope', + 'proud', + 'sentimental', + 'smileys', + 'mood', + 'emotion', + 'emotions', + 'emotional', + 'hooray', + 'cheek', + 'cheeky', + 'excited', + 'feliz', + 'heureux', + 'cheerful', + 'delighted', + 'ecstatic', + 'elated', + 'glad', + 'joy', + 'merry', + 'crying', + 'weeping', + 'weep', + 'sob', + 'sobbing', + 'tear', + 'tears', + 'bawling', + 'thanks', + 'thankful', + 'praise', + 'gracias', + 'merci', + 'thankyou', + 'acceptable', + 'cute', + 'pretty', + 'adorable', + 'adore', + 'beauty', + 'cutie', + 'babe', + 'lovely', + 'smiles', + 'blushing', + 'bella', + 'embarrassed', + 'creep', + 'please', + 'chill', + 'confident', + 'content', + 'swear', + 'promise', + 'nostalgic', + 'tender', + 'dreamy', + 'touched' + ]), + Emoji( + name: 'smiling face with heart-eyes', + char: '\u{1F60D}', + shortName: 'heart_eyes', + emojiGroup: EmojiGroup.smileysEmotion, + emojiSubgroup: EmojiSubgroup.faceAffection, + keywords: [ + 'eye', + 'face', + 'love', + 'smile', + 'uc6', + 'smiley', + 'happy', + 'love', + 'heart eyes', + 'beautiful', + 'smile', + 'hola', + 'facebook', + 'porn', + 'heart', + 'smileys', + 'mood', + 'emotion', + 'emotions', + 'emotional', + 'hooray', + 'cheek', + 'cheeky', + 'excited', + 'feliz', + 'heureux', + 'cheerful', + 'delighted', + 'ecstatic', + 'elated', + 'glad', + 'joy', + 'merry', + 'i love you', + 'te amo', + "je t'aime", + 'anniversary', + 'lovin', + 'amour', + 'aimer', + 'amor', + 'valentines day', + 'enamour', + 'lovey', + 'cute', + 'pretty', + 'adorable', + 'adore', + 'beauty', + 'cutie', + 'babe', + 'lovely', + 'smiles', + 'hearts', + 'serce', + 'corazón', + 'coração', + 'coeur' + ]), + Emoji( + name: 'smiling face with hearts', + char: '\u{1F970}', + shortName: 'smiling_face_with_3_hearts', + emojiGroup: EmojiGroup.smileysEmotion, + emojiSubgroup: EmojiSubgroup.faceAffection, + keywords: [ + 'uc11', + 'smiley', + 'wedding', + 'happy', + 'love', + 'hug', + 'smile', + 'friend', + 'blush', + 'pleased', + 'heart', + 'smileys', + 'mood', + 'emotion', + 'emotions', + 'emotional', + 'weddings', + 'marriage', + 'newlywed', + 'bride', + 'groome', + 'groom', + 'married', + 'marry', + 'hooray', + 'cheek', + 'cheeky', + 'excited', + 'feliz', + 'heureux', + 'cheerful', + 'delighted', + 'ecstatic', + 'elated', + 'glad', + 'joy', + 'merry', + 'i love you', + 'te amo', + "je t'aime", + 'anniversary', + 'lovin', + 'amour', + 'aimer', + 'amor', + 'valentines day', + 'enamour', + 'lovey', + 'embrace', + 'hugs', + 'smiles', + 'friends', + 'friendship', + 'best friends', + 'bestfriends', + 'ami', + 'amiga', + 'amigo', + 'blushing', + 'bella', + 'embarrassed', + 'creep', + 'please', + 'chill', + 'confident', + 'content', + 'hearts', + 'serce', + 'corazón', + 'coração', + 'coeur' + ]), + Emoji( + name: 'face blowing a kiss', + char: '\u{1F618}', + shortName: 'kissing_heart', + emojiGroup: EmojiGroup.smileysEmotion, + emojiSubgroup: EmojiSubgroup.faceAffection, + keywords: [ + 'face', + 'kiss', + 'uc6', + 'smiley', + 'wedding', + 'love', + 'sexy', + 'beautiful', + 'disney', + 'kisses', + 'hit', + 'smileys', + 'mood', + 'emotion', + 'emotions', + 'emotional', + 'weddings', + 'marriage', + 'newlywed', + 'bride', + 'groome', + 'groom', + 'married', + 'marry', + 'i love you', + 'te amo', + "je t'aime", + 'anniversary', + 'lovin', + 'amour', + 'aimer', + 'amor', + 'valentines day', + 'enamour', + 'lovey', + 'cute', + 'pretty', + 'adorable', + 'adore', + 'beauty', + 'cutie', + 'babe', + 'lovely', + 'cartoon', + 'bisous', + 'beijos', + 'besos', + 'bise', + 'blowing kisses', + 'kissy', + 'punch', + 'pow', + 'bam', + ':*', + ':-*', + '=*', + ':^*' + ]), + Emoji( + name: 'kissing face', + char: '\u{1F617}', + shortName: 'kissing', + emojiGroup: EmojiGroup.smileysEmotion, + emojiSubgroup: EmojiSubgroup.faceAffection, + keywords: [ + 'face', + 'kiss', + 'uc6', + 'smiley', + 'sexy', + 'beautiful', + 'selfie', + 'kisses', + 'smileys', + 'mood', + 'emotion', + 'emotions', + 'emotional', + 'cute', + 'pretty', + 'adorable', + 'adore', + 'beauty', + 'cutie', + 'babe', + 'lovely', + 'bisous', + 'beijos', + 'besos', + 'bise', + 'blowing kisses', + 'kissy' + ]), + Emoji( + name: 'kissing face with smiling eyes', + char: '\u{1F619}', + shortName: 'kissing_smiling_eyes', + emojiGroup: EmojiGroup.smileysEmotion, + emojiSubgroup: EmojiSubgroup.faceAffection, + keywords: [ + 'eye', + 'face', + 'kiss', + 'smile', + 'uc6', + 'smiley', + 'love', + 'sexy', + 'kisses', + 'smileys', + 'mood', + 'emotion', + 'emotions', + 'emotional', + 'i love you', + 'te amo', + "je t'aime", + 'anniversary', + 'lovin', + 'amour', + 'aimer', + 'amor', + 'valentines day', + 'enamour', + 'lovey', + 'bisous', + 'beijos', + 'besos', + 'bise', + 'blowing kisses', + 'kissy' + ]), + Emoji( + name: 'kissing face with closed eyes', + char: '\u{1F61A}', + shortName: 'kissing_closed_eyes', + emojiGroup: EmojiGroup.smileysEmotion, + emojiSubgroup: EmojiSubgroup.faceAffection, + keywords: [ + 'closed', + 'eye', + 'face', + 'kiss', + 'uc6', + 'smiley', + 'love', + 'sexy', + 'blush', + 'kisses', + 'smileys', + 'mood', + 'emotion', + 'emotions', + 'emotional', + 'i love you', + 'te amo', + "je t'aime", + 'anniversary', + 'lovin', + 'amour', + 'aimer', + 'amor', + 'valentines day', + 'enamour', + 'lovey', + 'blushing', + 'bella', + 'embarrassed', + 'creep', + 'bisous', + 'beijos', + 'besos', + 'bise', + 'blowing kisses', + 'kissy' + ]), + Emoji( + name: 'face savoring food', + char: '\u{1F60B}', + shortName: 'yum', + emojiGroup: EmojiGroup.smileysEmotion, + emojiSubgroup: EmojiSubgroup.faceTongue, + keywords: [ + 'delicious', + 'face', + 'savouring', + 'smile', + 'um', + 'yum', + 'uc6', + 'smiley', + 'food', + 'happy', + 'silly', + 'sarcastic', + 'good', + 'smile', + 'pink', + 'lick', + 'tongue', + 'dinner', + 'picnic', + 'delicious', + 'smileys', + 'mood', + 'emotion', + 'emotions', + 'emotional', + 'foods', + 'eat', + 'meal', + 'comida', + 'nourriture', + 'eats', + 'groceries', + 'grocery', + 'hungry', + 'tasty', + 'mmm', + 'yummy', + 'feed', + 'hunger', + 'hooray', + 'cheek', + 'cheeky', + 'excited', + 'feliz', + 'heureux', + 'cheerful', + 'delighted', + 'ecstatic', + 'elated', + 'glad', + 'joy', + 'merry', + 'funny', + 'sarcasm', + 'good job', + 'nice', + 'well done', + 'bravo', + 'congratulations', + 'congrats', + 'smiles', + 'rose', + 'toung', + 'tounge', + 'lunch', + 'savour' + ]), + Emoji( + name: 'face with tongue', + char: '\u{1F61B}', + shortName: 'stuck_out_tongue', + emojiGroup: EmojiGroup.smileysEmotion, + emojiSubgroup: EmojiSubgroup.faceTongue, + keywords: [ + 'face', + 'tongue', + 'uc6', + 'smiley', + 'happy', + 'silly', + 'pink', + 'tease', + 'lick', + 'tongue', + 'smileys', + 'mood', + 'emotion', + 'emotions', + 'emotional', + 'hooray', + 'cheek', + 'cheeky', + 'excited', + 'feliz', + 'heureux', + 'cheerful', + 'delighted', + 'ecstatic', + 'elated', + 'glad', + 'joy', + 'merry', + 'funny', + 'rose', + 'joke', + 'kidding', + 'toung', + 'tounge', + ':P', + ':-P', + '=P', + ':-Þ', + ':Þ', + ':-b', + ':b' + ]), + Emoji( + name: 'squinting face with tongue', + char: '\u{1F61D}', + shortName: 'stuck_out_tongue_closed_eyes', + emojiGroup: EmojiGroup.smileysEmotion, + emojiSubgroup: EmojiSubgroup.faceTongue, + keywords: [ + 'eye', + 'face', + 'horrible', + 'taste', + 'tongue', + 'uc6', + 'smiley', + 'happy', + 'silly', + 'laugh', + 'pink', + 'tease', + 'grimace', + 'lick', + 'tongue', + 'smileys', + 'mood', + 'emotion', + 'emotions', + 'emotional', + 'hooray', + 'cheek', + 'cheeky', + 'excited', + 'feliz', + 'heureux', + 'cheerful', + 'delighted', + 'ecstatic', + 'elated', + 'glad', + 'joy', + 'merry', + 'funny', + 'laughing', + 'lol', + 'rofl', + 'lmao', + 'lmfao', + 'hilarious', + 'ha', + 'haha', + 'chuckle', + 'comedy', + 'giggle', + 'hehe', + 'joyful', + 'laugh out loud', + 'rire', + 'tee hee', + 'jaja', + 'rose', + 'joke', + 'kidding', + 'toung', + 'tounge' + ]), + Emoji( + name: 'winking face with tongue', + char: '\u{1F61C}', + shortName: 'stuck_out_tongue_winking_eye', + emojiGroup: EmojiGroup.smileysEmotion, + emojiSubgroup: EmojiSubgroup.faceTongue, + keywords: [ + 'eye', + 'face', + 'joke', + 'tongue', + 'wink', + 'uc6', + 'smiley', + 'happy', + 'silly', + 'pink', + 'tease', + 'pleased', + 'lick', + 'porn', + 'crazy', + 'tongue', + 'smileys', + 'mood', + 'emotion', + 'emotions', + 'emotional', + 'hooray', + 'cheek', + 'cheeky', + 'excited', + 'feliz', + 'heureux', + 'cheerful', + 'delighted', + 'ecstatic', + 'elated', + 'glad', + 'joy', + 'merry', + 'funny', + 'rose', + 'joke', + 'kidding', + 'please', + 'chill', + 'confident', + 'content', + 'weird', + 'awkward', + 'insane', + 'wild', + 'toung', + 'tounge', + '>:P', + 'X-P' + ]), + Emoji( + name: 'zany face', + char: '\u{1F92A}', + shortName: 'zany_face', + emojiGroup: EmojiGroup.smileysEmotion, + emojiSubgroup: EmojiSubgroup.faceTongue, + keywords: [ + 'eye', + 'large', + 'small', + 'uc10', + 'smiley', + 'silly', + 'nutcase', + 'crazy', + 'smileys', + 'mood', + 'emotion', + 'emotions', + 'emotional', + 'funny', + 'weird', + 'awkward', + 'insane', + 'wild' + ]), + Emoji( + name: 'face with raised eyebrow', + char: '\u{1F928}', + shortName: 'face_with_raised_eyebrow', + emojiGroup: EmojiGroup.smileysEmotion, + emojiSubgroup: EmojiSubgroup.faceNeutralSkeptical, + keywords: [ + 'uc10', + 'smiley', + 'doubt', + 'jealous', + 'colbert', + 'smileys', + 'mood', + 'emotion', + 'emotions', + 'emotional', + 'unsure', + 'thinking', + 'wonder', + 'curious', + 'worry', + 'pensive', + 'remember', + 'skeptical' + ]), + Emoji( + name: 'face with monocle', + char: '\u{1F9D0}', + shortName: 'face_with_monocle', + emojiGroup: EmojiGroup.smileysEmotion, + emojiSubgroup: EmojiSubgroup.faceGlasses, + keywords: [ + 'uc10', + 'smiley', + 'nerd', + 'rich', + 'mystery', + 'proud', + 'smileys', + 'mood', + 'emotion', + 'emotions', + 'emotional', + 'smart', + 'geek', + 'serious', + 'grand', + 'expensive', + 'fancy' + ]), + Emoji( + name: 'nerd face', + char: '\u{1F913}', + shortName: 'nerd', + emojiGroup: EmojiGroup.smileysEmotion, + emojiSubgroup: EmojiSubgroup.faceGlasses, + keywords: [ + 'face', + 'geek', + 'nerd', + 'uc8', + 'smiley', + 'glasses', + 'nerd', + 'google', + 'brain', + 'teeth', + 'dumb', + 'disguise', + 'smileys', + 'mood', + 'emotion', + 'emotions', + 'emotional', + 'eyeglasses', + 'eye glasses', + 'smart', + 'geek', + 'serious', + 'mind', + 'memory', + 'thought', + 'conscience', + 'dentist', + 'idiot', + 'ignorant', + 'stupid' + ]), + Emoji( + name: 'smiling face with sunglasses', + char: '\u{1F60E}', + shortName: 'sunglasses', + emojiGroup: EmojiGroup.smileysEmotion, + emojiSubgroup: EmojiSubgroup.faceGlasses, + keywords: [ + 'bright', + 'cool', + 'eye', + 'eyewear', + 'face', + 'glasses', + 'smile', + 'sun', + 'sunglasses', + 'uc6', + 'smiley', + 'happy', + 'silly', + 'glasses', + 'emojione', + 'awesome', + 'beautiful', + 'boys night', + 'smile', + 'sunglasses', + 'hawaii', + 'california', + 'florida', + 'las vegas', + 'fun', + 'summer', + 'clever', + 'smileys', + 'mood', + 'emotion', + 'emotions', + 'emotional', + 'hooray', + 'cheek', + 'cheeky', + 'excited', + 'feliz', + 'heureux', + 'cheerful', + 'delighted', + 'ecstatic', + 'elated', + 'glad', + 'joy', + 'merry', + 'funny', + 'eyeglasses', + 'eye glasses', + 'emoji one', + 'okay', + 'got it', + 'cool', + 'ok', + 'will do', + 'like', + 'bien', + 'yep', + 'yup', + 'cute', + 'pretty', + 'adorable', + 'adore', + 'beauty', + 'cutie', + 'babe', + 'lovely', + 'guys night', + 'smiles', + 'shades', + 'lunettes de soleil', + 'sun glasses', + 'aloha', + 'kawaii', + 'maui', + 'moana', + 'vegas', + 'weekend', + 'witty', + 'B-)', + 'B)', + '8)', + '8-)', + 'B-D', + '8-D' + ]), + Emoji( + name: 'star-struck', + char: '\u{1F929}', + shortName: 'star_struck', + emojiGroup: EmojiGroup.smileysEmotion, + emojiSubgroup: EmojiSubgroup.faceAffection, + keywords: [ + 'uc10', + 'smiley', + 'happy', + 'selfie', + 'fame', + 'smileys', + 'mood', + 'emotion', + 'emotions', + 'emotional', + 'hooray', + 'cheek', + 'cheeky', + 'excited', + 'feliz', + 'heureux', + 'cheerful', + 'delighted', + 'ecstatic', + 'elated', + 'glad', + 'joy', + 'merry', + 'famous', + 'celebrity' + ]), + Emoji( + name: 'partying face', + char: '\u{1F973}', + shortName: 'partying_face', + emojiGroup: EmojiGroup.smileysEmotion, + emojiSubgroup: EmojiSubgroup.faceHat, + keywords: [ + 'uc11', + 'smiley', + 'holidays', + 'happy', + 'silly', + 'hat', + 'cheers', + 'happy birthday', + 'confetti', + 'celebrate', + 'fun', + 'smileys', + 'mood', + 'emotion', + 'emotions', + 'emotional', + 'holiday', + 'hooray', + 'cheek', + 'cheeky', + 'excited', + 'feliz', + 'heureux', + 'cheerful', + 'delighted', + 'ecstatic', + 'elated', + 'glad', + 'joy', + 'merry', + 'funny', + 'hats', + 'cap', + 'caps', + 'gān bēi', + 'Na zdravi', + 'Proost', + 'Prost', + 'Sláinte', + 'Cin cin', + 'Kanpai', + 'Na zdrowie', + 'Saúde', + 'На здоровье', + 'Salud', + 'Skål', + 'Sei gesund', + 'santé', + 'Bon anniversaire', + 'joyeux anniversaire', + 'buon compleanno', + 'feliz cumpleaños', + 'alles Gute zum Geburtstag', + 'feliz Aniversário', + 'Gratulerer med dagen', + 'celebration', + 'event', + 'celebrating', + 'festa', + 'parties', + 'events', + 'new years', + 'new year', + 'fiesta', + 'fete', + 'newyear', + 'party', + 'festive', + 'festival', + 'yolo', + 'festejar' + ]), + Emoji( + name: 'smirking face', + char: '\u{1F60F}', + shortName: 'smirk', + emojiGroup: EmojiGroup.smileysEmotion, + emojiSubgroup: EmojiSubgroup.faceNeutralSkeptical, + keywords: [ + 'face', + 'smirk', + 'uc6', + 'smiley', + 'happy', + 'silly', + 'sexy', + 'sarcastic', + 'smile', + 'pleased', + 'clever', + 'proud', + 'smileys', + 'mood', + 'emotion', + 'emotions', + 'emotional', + 'hooray', + 'cheek', + 'cheeky', + 'excited', + 'feliz', + 'heureux', + 'cheerful', + 'delighted', + 'ecstatic', + 'elated', + 'glad', + 'joy', + 'merry', + 'funny', + 'sarcasm', + 'smiles', + 'please', + 'chill', + 'confident', + 'content', + 'witty' + ]), + Emoji( + name: 'unamused face', + char: '\u{1F612}', + shortName: 'unamused', + emojiGroup: EmojiGroup.smileysEmotion, + emojiSubgroup: EmojiSubgroup.faceNeutralSkeptical, + keywords: [ + 'face', + 'unamused', + 'unhappy', + 'uc6', + 'smiley', + 'sad', + 'tired', + 'angry', + 'bored', + 'hate', + 'doubt', + 'grimace', + 'smileys', + 'mood', + 'emotion', + 'emotions', + 'emotional', + 'triste', + 'depression', + 'negative', + 'sadness', + 'sleepy', + 'sleep', + 'dormi', + 'pillow', + 'blanket', + 'exhausted', + 'upset', + 'pissed', + 'pissed off', + 'unhappy', + 'frustrated', + 'anger', + 'rage', + 'frustration', + 'furious', + 'mad', + 'boring', + 'agree', + 'whatever', + 'boredom', + 'i hate', + 'disgust', + 'stump', + 'shout', + 'dislike', + 'rude', + 'annoy', + 'grinch', + 'gross', + 'grumpy', + 'mean', + 'problem', + 'suck', + 'jerk', + 'asshole', + 'no', + 'unsure', + 'thinking', + 'wonder', + 'curious', + 'worry', + 'pensive', + 'remember', + 'skeptical' + ]), + Emoji( + name: 'disappointed face', + char: '\u{1F61E}', + shortName: 'disappointed', + emojiGroup: EmojiGroup.smileysEmotion, + emojiSubgroup: EmojiSubgroup.faceConcerned, + keywords: [ + 'disappointed', + 'face', + 'uc6', + 'smiley', + 'sad', + 'tired', + 'angry', + 'bored', + 'smileys', + 'mood', + 'emotion', + 'emotions', + 'emotional', + 'triste', + 'depression', + 'negative', + 'sadness', + 'sleepy', + 'sleep', + 'dormi', + 'pillow', + 'blanket', + 'exhausted', + 'upset', + 'pissed', + 'pissed off', + 'unhappy', + 'frustrated', + 'anger', + 'rage', + 'frustration', + 'furious', + 'mad', + 'boring', + 'agree', + 'whatever', + 'boredom', + '>:[', + ':-(', + ':(', + ':-[', + ':[', + '=(' + ]), + Emoji( + name: 'pensive face', + char: '\u{1F614}', + shortName: 'pensive', + emojiGroup: EmojiGroup.smileysEmotion, + emojiSubgroup: EmojiSubgroup.faceSleepy, + keywords: [ + 'dejected', + 'face', + 'pensive', + 'uc6', + 'smiley', + 'sad', + 'rip', + 'guilty', + 'smileys', + 'mood', + 'emotion', + 'emotions', + 'emotional', + 'triste', + 'depression', + 'negative', + 'sadness', + 'rest in peace' + ]), + Emoji( + name: 'worried face', + char: '\u{1F61F}', + shortName: 'worried', + emojiGroup: EmojiGroup.smileysEmotion, + emojiSubgroup: EmojiSubgroup.faceConcerned, + keywords: [ + 'face', + 'worried', + 'uc6', + 'smiley', + 'sad', + 'angry', + 'doubt', + 'guilty', + 'jealous', + 'smileys', + 'mood', + 'emotion', + 'emotions', + 'emotional', + 'triste', + 'depression', + 'negative', + 'sadness', + 'upset', + 'pissed', + 'pissed off', + 'unhappy', + 'frustrated', + 'anger', + 'rage', + 'frustration', + 'furious', + 'mad', + 'unsure', + 'thinking', + 'wonder', + 'curious', + 'worry', + 'pensive', + 'remember', + 'skeptical' + ]), + Emoji( + name: 'confused face', + char: '\u{1F615}', + shortName: 'confused', + emojiGroup: EmojiGroup.smileysEmotion, + emojiSubgroup: EmojiSubgroup.faceConcerned, + keywords: [ + 'confused', + 'face', + 'uc6', + 'smiley', + 'nurse', + 'doubt', + 'what', + 'smileys', + 'mood', + 'emotion', + 'emotions', + 'emotional', + 'unsure', + 'thinking', + 'wonder', + 'curious', + 'worry', + 'pensive', + 'remember', + 'skeptical', + '>:\\', + '>:/', + ':-/', + ':-.', + ':/', + ':\\', + '=/', + '=\\', + ':L', + '=L' + ]), + Emoji( + name: 'slightly frowning face', + char: '\u{1F641}', + shortName: 'slight_frown', + emojiGroup: EmojiGroup.smileysEmotion, + emojiSubgroup: EmojiSubgroup.faceConcerned, + keywords: [ + 'face', + 'frown', + 'uc7', + 'smiley', + 'sad', + 'angry', + 'hate', + 'smileys', + 'mood', + 'emotion', + 'emotions', + 'emotional', + 'triste', + 'depression', + 'negative', + 'sadness', + 'upset', + 'pissed', + 'pissed off', + 'unhappy', + 'frustrated', + 'anger', + 'rage', + 'frustration', + 'furious', + 'mad', + 'i hate', + 'disgust', + 'stump', + 'shout', + 'dislike', + 'rude', + 'annoy', + 'grinch', + 'gross', + 'grumpy', + 'mean', + 'problem', + 'suck', + 'jerk', + 'asshole', + 'no' + ]), + Emoji( + name: 'frowning face', + char: '\u{2639}\u{FE0F}', + shortName: 'frowning2', + emojiGroup: EmojiGroup.smileysEmotion, + emojiSubgroup: EmojiSubgroup.faceConcerned, + keywords: [ + 'face', + 'frown', + 'uc1', + 'smiley', + 'sad', + 'angry', + 'hate', + 'smileys', + 'mood', + 'emotion', + 'emotions', + 'emotional', + 'triste', + 'depression', + 'negative', + 'sadness', + 'upset', + 'pissed', + 'pissed off', + 'unhappy', + 'frustrated', + 'anger', + 'rage', + 'frustration', + 'furious', + 'mad', + 'i hate', + 'disgust', + 'stump', + 'shout', + 'dislike', + 'rude', + 'annoy', + 'grinch', + 'gross', + 'grumpy', + 'mean', + 'problem', + 'suck', + 'jerk', + 'asshole', + 'no' + ]), + Emoji( + name: 'persevering face', + char: '\u{1F623}', + shortName: 'persevere', + emojiGroup: EmojiGroup.smileysEmotion, + emojiSubgroup: EmojiSubgroup.faceConcerned, + keywords: [ + 'face', + 'persevere', + 'uc6', + 'smiley', + 'angry', + 'smileys', + 'mood', + 'emotion', + 'emotions', + 'emotional', + 'upset', + 'pissed', + 'pissed off', + 'unhappy', + 'frustrated', + 'anger', + 'rage', + 'frustration', + 'furious', + 'mad', + '>.<' + ]), + Emoji( + name: 'confounded face', + char: '\u{1F616}', + shortName: 'confounded', + emojiGroup: EmojiGroup.smileysEmotion, + emojiSubgroup: EmojiSubgroup.faceConcerned, + keywords: [ + 'confounded', + 'face', + 'uc6', + 'smiley', + 'angry', + 'wow', + 'hate', + 'stinky', + 'ugly', + 'confused', + 'smileys', + 'mood', + 'emotion', + 'emotions', + 'emotional', + 'upset', + 'pissed', + 'pissed off', + 'unhappy', + 'frustrated', + 'anger', + 'rage', + 'frustration', + 'furious', + 'mad', + 'surprised', + 'scared', + 'shocked', + 'whoa', + 'surprise', + 'scary', + 'nervous', + 'shaking', + 'afraid', + 'amaze', + 'amazing', + 'creepy', + 'cringe', + 'gasp', + 'anxious', + 'mind blown', + 'i hate', + 'disgust', + 'stump', + 'shout', + 'dislike', + 'rude', + 'annoy', + 'grinch', + 'gross', + 'grumpy', + 'mean', + 'problem', + 'suck', + 'jerk', + 'asshole', + 'no', + 'smell', + 'stink', + 'odor', + 'perplexed' + ]), + Emoji( + name: 'tired face', + char: '\u{1F62B}', + shortName: 'tired_face', + emojiGroup: EmojiGroup.smileysEmotion, + emojiSubgroup: EmojiSubgroup.faceConcerned, + keywords: [ + 'face', + 'tired', + 'uc6', + 'smiley', + 'sad', + 'tired', + 'angry', + 'sick', + 'wow', + 'smileys', + 'mood', + 'emotion', + 'emotions', + 'emotional', + 'triste', + 'depression', + 'negative', + 'sadness', + 'sleepy', + 'sleep', + 'dormi', + 'pillow', + 'blanket', + 'exhausted', + 'upset', + 'pissed', + 'pissed off', + 'unhappy', + 'frustrated', + 'anger', + 'rage', + 'frustration', + 'furious', + 'mad', + 'barf', + 'vomit', + 'throw up', + 'puke', + 'get well', + 'cough', + 'puking', + 'barfing', + 'malade', + 'spew', + 'surprised', + 'scared', + 'shocked', + 'whoa', + 'surprise', + 'scary', + 'nervous', + 'shaking', + 'afraid', + 'amaze', + 'amazing', + 'creepy', + 'cringe', + 'gasp', + 'anxious', + 'mind blown' + ]), + Emoji( + name: 'weary face', + char: '\u{1F629}', + shortName: 'weary', + emojiGroup: EmojiGroup.smileysEmotion, + emojiSubgroup: EmojiSubgroup.faceConcerned, + keywords: [ + 'face', + 'tired', + 'weary', + 'uc6', + 'smiley', + 'sad', + 'tired', + 'angry', + 'stressed', + 'wow', + 'shame', + 'lazy', + 'smileys', + 'mood', + 'emotion', + 'emotions', + 'emotional', + 'triste', + 'depression', + 'negative', + 'sadness', + 'sleepy', + 'sleep', + 'dormi', + 'pillow', + 'blanket', + 'exhausted', + 'upset', + 'pissed', + 'pissed off', + 'unhappy', + 'frustrated', + 'anger', + 'rage', + 'frustration', + 'furious', + 'mad', + 'surprised', + 'scared', + 'shocked', + 'whoa', + 'surprise', + 'scary', + 'nervous', + 'shaking', + 'afraid', + 'amaze', + 'amazing', + 'creepy', + 'cringe', + 'gasp', + 'anxious', + 'mind blown' + ]), + Emoji( + name: 'pleading face', + char: '\u{1F97A}', + shortName: 'pleading_face', + emojiGroup: EmojiGroup.smileysEmotion, + emojiSubgroup: EmojiSubgroup.faceConcerned, + keywords: [ + 'uc11', + 'smiley', + 'sad', + 'cry', + 'condolence', + 'omg', + 'heartbreak', + 'blush', + 'begging', + 'doubt', + 'guilty', + 'help', + 'shame', + 'hope', + 'liar', + 'smileys', + 'mood', + 'emotion', + 'emotions', + 'emotional', + 'triste', + 'depression', + 'negative', + 'sadness', + 'crying', + 'weeping', + 'weep', + 'sob', + 'sobbing', + 'tear', + 'tears', + 'bawling', + 'compassion', + 'omfg', + 'oh my god', + 'broken heart', + 'heartbroken', + 'blushing', + 'bella', + 'embarrassed', + 'creep', + 'unsure', + 'thinking', + 'wonder', + 'curious', + 'worry', + 'pensive', + 'remember', + 'skeptical', + 'swear', + 'promise', + 'lies', + 'lying' + ]), + Emoji( + name: 'crying face', + char: '\u{1F622}', + shortName: 'cry', + emojiGroup: EmojiGroup.smileysEmotion, + emojiSubgroup: EmojiSubgroup.faceConcerned, + keywords: [ + 'cry', + 'face', + 'sad', + 'tear', + 'uc6', + 'smiley', + 'sad', + 'cry', + 'rip', + 'heartbreak', + 'drip', + 'guilty', + 'covid', + 'smileys', + 'mood', + 'emotion', + 'emotions', + 'emotional', + 'triste', + 'depression', + 'negative', + 'sadness', + 'crying', + 'weeping', + 'weep', + 'sob', + 'sobbing', + 'tear', + 'tears', + 'bawling', + 'rest in peace', + 'broken heart', + 'heartbroken', + ":'(", + ":'-(", + ';(', + ';-(' + ]), + Emoji( + name: 'loudly crying face', + char: '\u{1F62D}', + shortName: 'sob', + emojiGroup: EmojiGroup.smileysEmotion, + emojiSubgroup: EmojiSubgroup.faceConcerned, + keywords: [ + 'cry', + 'face', + 'sad', + 'sob', + 'tear', + 'uc6', + 'smiley', + 'sad', + 'cry', + 'rip', + 'heartbreak', + 'smileys', + 'mood', + 'emotion', + 'emotions', + 'emotional', + 'triste', + 'depression', + 'negative', + 'sadness', + 'crying', + 'weeping', + 'weep', + 'sob', + 'sobbing', + 'tear', + 'tears', + 'bawling', + 'rest in peace', + 'broken heart', + 'heartbroken' + ]), + Emoji( + name: 'face with steam from nose', + char: '\u{1F624}', + shortName: 'triumph', + emojiGroup: EmojiGroup.smileysEmotion, + emojiSubgroup: EmojiSubgroup.faceNegative, + keywords: [ + 'face', + 'triumph', + 'won', + 'uc6', + 'smiley', + 'angry', + 'smoking', + 'steam', + 'breathe', + 'proud', + 'festivus', + 'booger', + 'smileys', + 'mood', + 'emotion', + 'emotions', + 'emotional', + 'upset', + 'pissed', + 'pissed off', + 'unhappy', + 'frustrated', + 'anger', + 'rage', + 'frustration', + 'furious', + 'mad', + 'smoke', + 'cigarette', + 'puff', + 'steaming', + 'piping', + 'sigh', + 'inhale' + ]), + Emoji( + name: 'angry face', + char: '\u{1F620}', + shortName: 'angry', + emojiGroup: EmojiGroup.smileysEmotion, + emojiSubgroup: EmojiSubgroup.faceNegative, + keywords: [ + 'angry', + 'face', + 'mad', + 'uc6', + 'smiley', + 'angry', + 'hate', + 'smileys', + 'mood', + 'emotion', + 'emotions', + 'emotional', + 'upset', + 'pissed', + 'pissed off', + 'unhappy', + 'frustrated', + 'anger', + 'rage', + 'frustration', + 'furious', + 'mad', + 'i hate', + 'disgust', + 'stump', + 'shout', + 'dislike', + 'rude', + 'annoy', + 'grinch', + 'gross', + 'grumpy', + 'mean', + 'problem', + 'suck', + 'jerk', + 'asshole', + 'no', + '>:(', + '>:-(', + ':@' + ]), + Emoji( + name: 'pouting face', + char: '\u{1F621}', + shortName: 'rage', + emojiGroup: EmojiGroup.smileysEmotion, + emojiSubgroup: EmojiSubgroup.faceNegative, + keywords: [ + 'angry', + 'face', + 'mad', + 'pouting', + 'rage', + 'red', + 'uc6', + 'smiley', + 'angry', + 'hate', + 'bitch', + 'donald trump', + 'guilty', + 'las vegas', + 'killer', + 'smileys', + 'mood', + 'emotion', + 'emotions', + 'emotional', + 'upset', + 'pissed', + 'pissed off', + 'unhappy', + 'frustrated', + 'anger', + 'rage', + 'frustration', + 'furious', + 'mad', + 'i hate', + 'disgust', + 'stump', + 'shout', + 'dislike', + 'rude', + 'annoy', + 'grinch', + 'gross', + 'grumpy', + 'mean', + 'problem', + 'suck', + 'jerk', + 'asshole', + 'no', + 'puta', + 'pute', + 'trump', + 'vegas', + 'savage', + 'scary clown' + ]), + Emoji( + name: 'face with symbols on mouth', + char: '\u{1F92C}', + shortName: 'face_with_symbols_over_mouth', + emojiGroup: EmojiGroup.smileysEmotion, + emojiSubgroup: EmojiSubgroup.faceNegative, + keywords: [ + 'uc10', + 'smiley', + 'angry', + 'hate', + 'donald trump', + 'swearing', + 'smileys', + 'mood', + 'emotion', + 'emotions', + 'emotional', + 'upset', + 'pissed', + 'pissed off', + 'unhappy', + 'frustrated', + 'anger', + 'rage', + 'frustration', + 'furious', + 'mad', + 'i hate', + 'disgust', + 'stump', + 'shout', + 'dislike', + 'rude', + 'annoy', + 'grinch', + 'gross', + 'grumpy', + 'mean', + 'problem', + 'suck', + 'jerk', + 'asshole', + 'no', + 'trump', + 'cussing', + 'cursing' + ]), + Emoji( + name: 'exploding head', + char: '\u{1F92F}', + shortName: 'exploding_head', + emojiGroup: EmojiGroup.smileysEmotion, + emojiSubgroup: EmojiSubgroup.faceUnwell, + keywords: [ + 'shocked', + 'uc10', + 'smiley', + 'angry', + 'wow', + 'omg', + 'donald trump', + 'smileys', + 'mood', + 'emotion', + 'emotions', + 'emotional', + 'upset', + 'pissed', + 'pissed off', + 'unhappy', + 'frustrated', + 'anger', + 'rage', + 'frustration', + 'furious', + 'mad', + 'surprised', + 'scared', + 'shocked', + 'whoa', + 'surprise', + 'scary', + 'nervous', + 'shaking', + 'afraid', + 'amaze', + 'amazing', + 'creepy', + 'cringe', + 'gasp', + 'anxious', + 'mind blown', + 'omfg', + 'oh my god', + 'trump' + ]), + Emoji( + name: 'flushed face', + char: '\u{1F633}', + shortName: 'flushed', + emojiGroup: EmojiGroup.smileysEmotion, + emojiSubgroup: EmojiSubgroup.faceConcerned, + keywords: [ + 'dazed', + 'face', + 'flushed', + 'uc6', + 'smiley', + 'omg', + 'blush', + 'guilty', + 'porn', + 'smileys', + 'mood', + 'emotion', + 'emotions', + 'emotional', + 'omfg', + 'oh my god', + 'blushing', + 'bella', + 'embarrassed', + 'creep', + ':\$', + '=\$' + ]), + Emoji( + name: 'hot face', + char: '\u{1F975}', + shortName: 'hot_face', + emojiGroup: EmojiGroup.smileysEmotion, + emojiSubgroup: EmojiSubgroup.faceUnwell, + keywords: [ + 'uc11', + 'weather', + 'smiley', + 'stressed', + 'sweat', + 'hot', + 'hate', + 'summer', + 'smileys', + 'mood', + 'emotion', + 'emotions', + 'emotional', + 'heat', + 'warm', + 'caliente', + 'chaud', + 'heiß', + 'i hate', + 'disgust', + 'stump', + 'shout', + 'dislike', + 'rude', + 'annoy', + 'grinch', + 'gross', + 'grumpy', + 'mean', + 'problem', + 'suck', + 'jerk', + 'asshole', + 'no', + 'weekend' + ]), + Emoji( + name: 'cold face', + char: '\u{1F976}', + shortName: 'cold_face', + emojiGroup: EmojiGroup.smileysEmotion, + emojiSubgroup: EmojiSubgroup.faceUnwell, + keywords: [ + 'uc11', + 'weather', + 'smiley', + 'winter', + 'snow', + 'cold', + 'grimace', + 'smileys', + 'mood', + 'emotion', + 'emotions', + 'emotional', + 'freeze', + 'frozen', + 'frost', + 'ice cube', + 'chilly', + 'chilled', + 'brisk', + 'freezing', + 'frostbite', + 'icicles' + ]), + Emoji( + name: 'face screaming in fear', + char: '\u{1F631}', + shortName: 'scream', + emojiGroup: EmojiGroup.smileysEmotion, + emojiSubgroup: EmojiSubgroup.faceConcerned, + keywords: [ + 'face', + 'fear', + 'fearful', + 'munch', + 'scared', + 'scream', + 'uc6', + 'smiley', + 'halloween', + 'wow', + 'omg', + 'donald trump', + 'fame', + 'porn', + 'ugly', + 'what', + 'crazy', + 'smileys', + 'mood', + 'emotion', + 'emotions', + 'emotional', + 'samhain', + 'surprised', + 'scared', + 'shocked', + 'whoa', + 'surprise', + 'scary', + 'nervous', + 'shaking', + 'afraid', + 'amaze', + 'amazing', + 'creepy', + 'cringe', + 'gasp', + 'anxious', + 'mind blown', + 'omfg', + 'oh my god', + 'trump', + 'famous', + 'celebrity', + 'weird', + 'awkward', + 'insane', + 'wild' + ]), + Emoji( + name: 'fearful face', + char: '\u{1F628}', + shortName: 'fearful', + emojiGroup: EmojiGroup.smileysEmotion, + emojiSubgroup: EmojiSubgroup.faceConcerned, + keywords: [ + 'face', + 'fear', + 'fearful', + 'scared', + 'uc6', + 'smiley', + 'halloween', + 'stressed', + 'wow', + 'guilty', + 'smileys', + 'mood', + 'emotion', + 'emotions', + 'emotional', + 'samhain', + 'surprised', + 'scared', + 'shocked', + 'whoa', + 'surprise', + 'scary', + 'nervous', + 'shaking', + 'afraid', + 'amaze', + 'amazing', + 'creepy', + 'cringe', + 'gasp', + 'anxious', + 'mind blown', + 'D:' + ]), + Emoji( + name: 'anxious face with sweat', + char: '\u{1F630}', + shortName: 'cold_sweat', + emojiGroup: EmojiGroup.smileysEmotion, + emojiSubgroup: EmojiSubgroup.faceConcerned, + keywords: [ + 'blue', + 'cold', + 'face', + 'mouth', + 'open', + 'rushed', + 'sweat', + 'uc6', + 'smiley', + 'halloween', + 'angry', + 'stressed', + 'sweat', + 'drip', + 'porn', + 'covid', + 'smileys', + 'mood', + 'emotion', + 'emotions', + 'emotional', + 'samhain', + 'upset', + 'pissed', + 'pissed off', + 'unhappy', + 'frustrated', + 'anger', + 'rage', + 'frustration', + 'furious', + 'mad' + ]), + Emoji( + name: 'sad but relieved face', + char: '\u{1F625}', + shortName: 'disappointed_relieved', + emojiGroup: EmojiGroup.smileysEmotion, + emojiSubgroup: EmojiSubgroup.faceConcerned, + keywords: [ + 'disappointed', + 'face', + 'relieved', + 'whew', + 'uc6', + 'smiley', + 'sad', + 'cry', + 'stressed', + 'sweat', + 'calm', + 'drip', + 'guilty', + 'smileys', + 'mood', + 'emotion', + 'emotions', + 'emotional', + 'triste', + 'depression', + 'negative', + 'sadness', + 'crying', + 'weeping', + 'weep', + 'sob', + 'sobbing', + 'tear', + 'tears', + 'bawling' + ]), + Emoji( + name: 'downcast face with sweat', + char: '\u{1F613}', + shortName: 'sweat', + emojiGroup: EmojiGroup.smileysEmotion, + emojiSubgroup: EmojiSubgroup.faceConcerned, + keywords: [ + 'cold', + 'face', + 'sweat', + 'uc6', + 'smiley', + 'sad', + 'stressed', + 'sweat', + 'drip', + 'guilty', + 'smileys', + 'mood', + 'emotion', + 'emotions', + 'emotional', + 'triste', + 'depression', + 'negative', + 'sadness', + "':(", + "':-(", + "'=(" + ]), + Emoji( + name: 'hugging face', + char: '\u{1F917}', + shortName: 'hugging', + emojiGroup: EmojiGroup.smileysEmotion, + emojiSubgroup: EmojiSubgroup.faceHand, + keywords: [ + 'face', + 'hug', + 'hugging', + 'uc8', + 'smiley', + 'happy', + 'tired', + 'love', + 'hug', + 'thank you', + 'friend', + 'blush', + 'facebook', + 'smileys', + 'mood', + 'emotion', + 'emotions', + 'emotional', + 'hooray', + 'cheek', + 'cheeky', + 'excited', + 'feliz', + 'heureux', + 'cheerful', + 'delighted', + 'ecstatic', + 'elated', + 'glad', + 'joy', + 'merry', + 'sleepy', + 'sleep', + 'dormi', + 'pillow', + 'blanket', + 'exhausted', + 'i love you', + 'te amo', + "je t'aime", + 'anniversary', + 'lovin', + 'amour', + 'aimer', + 'amor', + 'valentines day', + 'enamour', + 'lovey', + 'embrace', + 'hugs', + 'thanks', + 'thankful', + 'praise', + 'gracias', + 'merci', + 'thankyou', + 'acceptable', + 'friends', + 'friendship', + 'best friends', + 'bestfriends', + 'ami', + 'amiga', + 'amigo', + 'blushing', + 'bella', + 'embarrassed', + 'creep' + ]), + Emoji( + name: 'thinking face', + char: '\u{1F914}', + shortName: 'thinking', + emojiGroup: EmojiGroup.smileysEmotion, + emojiSubgroup: EmojiSubgroup.faceHand, + keywords: [ + 'face', + 'thinking', + 'uc8', + 'smiley', + 'boys night', + 'dream', + 'brain', + 'doubt', + 'idea', + 'confused', + 'what', + 'mystery', + 'innovate', + 'question', + 'smileys', + 'mood', + 'emotion', + 'emotions', + 'emotional', + 'guys night', + 'dreams', + 'mind', + 'memory', + 'thought', + 'conscience', + 'unsure', + 'thinking', + 'wonder', + 'curious', + 'worry', + 'pensive', + 'remember', + 'skeptical', + 'perplexed', + 'innovation', + 'inquire', + 'quiz', + 'puzzled' + ]), + Emoji( + name: 'face with hand over mouth', + char: '\u{1F92D}', + shortName: 'face_with_hand_over_mouth', + emojiGroup: EmojiGroup.smileysEmotion, + emojiSubgroup: EmojiSubgroup.faceHand, + keywords: [ + 'uc10', + 'smiley', + 'tired', + 'blush', + 'tease', + 'quiet', + 'what', + 'secret', + 'yawn', + 'smileys', + 'mood', + 'emotion', + 'emotions', + 'emotional', + 'sleepy', + 'sleep', + 'dormi', + 'pillow', + 'blanket', + 'exhausted', + 'blushing', + 'bella', + 'embarrassed', + 'creep', + 'joke', + 'kidding', + 'shut up', + 'hushed', + 'silence', + 'silent', + 'shush', + 'shh', + 'shhhhh' + ]), + Emoji( + name: 'yawning face', + char: '\u{1F971}', + shortName: 'yawning_face', + emojiGroup: EmojiGroup.smileysEmotion, + emojiSubgroup: EmojiSubgroup.faceConcerned, + keywords: [ + 'uc12', + 'smiley', + 'tired', + 'goodnight', + 'bored', + 'calm', + 'quiet', + 'wait', + 'yawn', + 'lazy', + 'smileys', + 'mood', + 'emotion', + 'emotions', + 'emotional', + 'sleepy', + 'sleep', + 'dormi', + 'pillow', + 'blanket', + 'exhausted', + 'boring', + 'agree', + 'whatever', + 'boredom', + 'shut up', + 'hushed', + 'silence', + 'silent', + 'shush', + 'shh', + 'hours' + ]), + Emoji( + name: 'shushing face', + char: '\u{1F92B}', + shortName: 'shushing_face', + emojiGroup: EmojiGroup.smileysEmotion, + emojiSubgroup: EmojiSubgroup.faceHand, + keywords: [ + 'quiet', + 'shush', + 'uc10', + 'smiley', + 'quiet', + 'secret', + 'smileys', + 'mood', + 'emotion', + 'emotions', + 'emotional', + 'shut up', + 'hushed', + 'silence', + 'silent', + 'shush', + 'shh', + 'shhhhh' + ]), + Emoji( + name: 'lying face', + char: '\u{1F925}', + shortName: 'lying_face', + emojiGroup: EmojiGroup.smileysEmotion, + emojiSubgroup: EmojiSubgroup.faceNeutralSkeptical, + keywords: [ + 'face', + 'lie', + 'pinocchio', + 'uc9', + 'smiley', + 'donald trump', + 'guilty', + 'crazy', + 'liar', + 'smileys', + 'mood', + 'emotion', + 'emotions', + 'emotional', + 'trump', + 'weird', + 'awkward', + 'insane', + 'wild', + 'lies', + 'lying' + ]), + Emoji( + name: 'face without mouth', + char: '\u{1F636}', + shortName: 'no_mouth', + emojiGroup: EmojiGroup.smileysEmotion, + emojiSubgroup: EmojiSubgroup.faceNeutralSkeptical, + keywords: [ + 'face', + 'mouth', + 'quiet', + 'silent', + 'uc6', + 'smiley', + 'neutral', + 'hate', + 'dumb', + 'quiet', + 'smileys', + 'mood', + 'emotion', + 'emotions', + 'emotional', + 'i hate', + 'disgust', + 'stump', + 'shout', + 'dislike', + 'rude', + 'annoy', + 'grinch', + 'gross', + 'grumpy', + 'mean', + 'problem', + 'suck', + 'jerk', + 'asshole', + 'no', + 'idiot', + 'ignorant', + 'stupid', + 'shut up', + 'hushed', + 'silence', + 'silent', + 'shush', + 'shh', + ':-X', + ':X', + ':-#', + ':#', + '=X', + '=#' + ]), + Emoji( + name: 'neutral face', + char: '\u{1F610}', + shortName: 'neutral_face', + emojiGroup: EmojiGroup.smileysEmotion, + emojiSubgroup: EmojiSubgroup.faceNeutralSkeptical, + keywords: [ + 'deadpan', + 'face', + 'neutral', + 'uc6', + 'smiley', + 'shrug', + 'neutral', + 'bored', + 'calm', + 'doubt', + 'dumb', + 'quiet', + 'smileys', + 'mood', + 'emotion', + 'emotions', + 'emotional', + 'boring', + 'agree', + 'whatever', + 'boredom', + 'unsure', + 'thinking', + 'wonder', + 'curious', + 'worry', + 'pensive', + 'remember', + 'skeptical', + 'idiot', + 'ignorant', + 'stupid', + 'shut up', + 'hushed', + 'silence', + 'silent', + 'shush', + 'shh' + ]), + Emoji( + name: 'expressionless face', + char: '\u{1F611}', + shortName: 'expressionless', + emojiGroup: EmojiGroup.smileysEmotion, + emojiSubgroup: EmojiSubgroup.faceNeutralSkeptical, + keywords: [ + 'expressionless', + 'face', + 'inexpressive', + 'unexpressive', + 'uc6', + 'smiley', + 'neutral', + 'bored', + 'calm', + 'doubt', + 'dumb', + 'quiet', + 'smileys', + 'mood', + 'emotion', + 'emotions', + 'emotional', + 'boring', + 'agree', + 'whatever', + 'boredom', + 'unsure', + 'thinking', + 'wonder', + 'curious', + 'worry', + 'pensive', + 'remember', + 'skeptical', + 'idiot', + 'ignorant', + 'stupid', + 'shut up', + 'hushed', + 'silence', + 'silent', + 'shush', + 'shh', + '-_-', + '-__-', + '-___-' + ]), + Emoji( + name: 'grimacing face', + char: '\u{1F62C}', + shortName: 'grimacing', + emojiGroup: EmojiGroup.smileysEmotion, + emojiSubgroup: EmojiSubgroup.faceNeutralSkeptical, + keywords: [ + 'face', + 'grimace', + 'uc6', + 'smiley', + 'silly', + 'selfie', + 'teeth', + 'grimace', + 'help', + 'porn', + 'smileys', + 'mood', + 'emotion', + 'emotions', + 'emotional', + 'funny', + 'dentist' + ]), + Emoji( + name: 'face with rolling eyes', + char: '\u{1F644}', + shortName: 'rolling_eyes', + emojiGroup: EmojiGroup.smileysEmotion, + emojiSubgroup: EmojiSubgroup.faceNeutralSkeptical, + keywords: [ + 'eyes', + 'face', + 'rolling', + 'uc8', + 'smiley', + 'rolling eyes', + 'sarcastic', + 'bored', + 'hate', + 'doubt', + 'eyeroll', + 'jealous', + 'smileys', + 'mood', + 'emotion', + 'emotions', + 'emotional', + 'eye roll', + 'side eye', + 'sarcasm', + 'boring', + 'agree', + 'whatever', + 'boredom', + 'i hate', + 'disgust', + 'stump', + 'shout', + 'dislike', + 'rude', + 'annoy', + 'grinch', + 'gross', + 'grumpy', + 'mean', + 'problem', + 'suck', + 'jerk', + 'asshole', + 'no', + 'unsure', + 'thinking', + 'wonder', + 'curious', + 'worry', + 'pensive', + 'remember', + 'skeptical' + ]), + Emoji( + name: 'hushed face', + char: '\u{1F62F}', + shortName: 'hushed', + emojiGroup: EmojiGroup.smileysEmotion, + emojiSubgroup: EmojiSubgroup.faceConcerned, + keywords: [ + 'face', + 'hushed', + 'stunned', + 'surprised', + 'uc6', + 'smiley', + 'wow', + 'what', + 'smileys', + 'mood', + 'emotion', + 'emotions', + 'emotional', + 'surprised', + 'scared', + 'shocked', + 'whoa', + 'surprise', + 'scary', + 'nervous', + 'shaking', + 'afraid', + 'amaze', + 'amazing', + 'creepy', + 'cringe', + 'gasp', + 'anxious', + 'mind blown' + ]), + Emoji( + name: 'frowning face with open mouth', + char: '\u{1F626}', + shortName: 'frowning', + emojiGroup: EmojiGroup.smileysEmotion, + emojiSubgroup: EmojiSubgroup.faceConcerned, + keywords: [ + 'face', + 'frown', + 'mouth', + 'open', + 'uc6', + 'smiley', + 'sad', + 'jealous', + 'what', + 'smileys', + 'mood', + 'emotion', + 'emotions', + 'emotional', + 'triste', + 'depression', + 'negative', + 'sadness' + ]), + Emoji( + name: 'anguished face', + char: '\u{1F627}', + shortName: 'anguished', + emojiGroup: EmojiGroup.smileysEmotion, + emojiSubgroup: EmojiSubgroup.faceConcerned, + keywords: [ + 'anguished', + 'face', + 'uc6', + 'smiley', + 'sad', + 'stressed', + 'wow', + 'smileys', + 'mood', + 'emotion', + 'emotions', + 'emotional', + 'triste', + 'depression', + 'negative', + 'sadness', + 'surprised', + 'scared', + 'shocked', + 'whoa', + 'surprise', + 'scary', + 'nervous', + 'shaking', + 'afraid', + 'amaze', + 'amazing', + 'creepy', + 'cringe', + 'gasp', + 'anxious', + 'mind blown' + ]), + Emoji( + name: 'face with open mouth', + char: '\u{1F62E}', + shortName: 'open_mouth', + emojiGroup: EmojiGroup.smileysEmotion, + emojiSubgroup: EmojiSubgroup.faceConcerned, + keywords: [ + 'face', + 'mouth', + 'open', + 'sympathy', + 'uc6', + 'smiley', + 'wow', + 'dumb', + 'what', + 'smileys', + 'mood', + 'emotion', + 'emotions', + 'emotional', + 'surprised', + 'scared', + 'shocked', + 'whoa', + 'surprise', + 'scary', + 'nervous', + 'shaking', + 'afraid', + 'amaze', + 'amazing', + 'creepy', + 'cringe', + 'gasp', + 'anxious', + 'mind blown', + 'idiot', + 'ignorant', + 'stupid', + ':-O', + ':O', + 'O_O', + '>:O' + ]), + Emoji( + name: 'astonished face', + char: '\u{1F632}', + shortName: 'astonished', + emojiGroup: EmojiGroup.smileysEmotion, + emojiSubgroup: EmojiSubgroup.faceConcerned, + keywords: [ + 'astonished', + 'face', + 'shocked', + 'totally', + 'uc6', + 'smiley', + 'wow', + 'omg', + 'donald trump', + 'crazy', + 'mystery', + 'smileys', + 'mood', + 'emotion', + 'emotions', + 'emotional', + 'surprised', + 'scared', + 'shocked', + 'whoa', + 'surprise', + 'scary', + 'nervous', + 'shaking', + 'afraid', + 'amaze', + 'amazing', + 'creepy', + 'cringe', + 'gasp', + 'anxious', + 'mind blown', + 'omfg', + 'oh my god', + 'trump', + 'weird', + 'awkward', + 'insane', + 'wild' + ]), + Emoji( + name: 'sleeping face', + char: '\u{1F634}', + shortName: 'sleeping', + emojiGroup: EmojiGroup.smileysEmotion, + emojiSubgroup: EmojiSubgroup.faceSleepy, + keywords: [ + 'face', + 'sleep', + 'zzz', + 'uc6', + 'smiley', + 'tired', + 'goodnight', + 'coffee', + 'dream', + 'calm', + 'lazy', + 'smileys', + 'mood', + 'emotion', + 'emotions', + 'emotional', + 'sleepy', + 'sleep', + 'dormi', + 'pillow', + 'blanket', + 'exhausted', + 'starbucks', + 'dreams' + ]), + Emoji( + name: 'drooling face', + char: '\u{1F924}', + shortName: 'drooling_face', + emojiGroup: EmojiGroup.smileysEmotion, + emojiSubgroup: EmojiSubgroup.faceSleepy, + keywords: [ + 'drooling', + 'face', + 'uc9', + 'smiley', + 'beautiful', + 'dumb', + 'porn', + 'ugly', + 'what', + 'crazy', + 'smileys', + 'mood', + 'emotion', + 'emotions', + 'emotional', + 'cute', + 'pretty', + 'adorable', + 'adore', + 'beauty', + 'cutie', + 'babe', + 'lovely', + 'idiot', + 'ignorant', + 'stupid', + 'weird', + 'awkward', + 'insane', + 'wild' + ]), + Emoji( + name: 'sleepy face', + char: '\u{1F62A}', + shortName: 'sleepy', + emojiGroup: EmojiGroup.smileysEmotion, + emojiSubgroup: EmojiSubgroup.faceSleepy, + keywords: [ + 'face', + 'sleep', + 'uc6', + 'smiley', + 'sad', + 'sick', + 'costume', + 'smileys', + 'mood', + 'emotion', + 'emotions', + 'emotional', + 'triste', + 'depression', + 'negative', + 'sadness', + 'barf', + 'vomit', + 'throw up', + 'puke', + 'get well', + 'cough', + 'puking', + 'barfing', + 'malade', + 'spew' + ]), + Emoji( + name: 'dizzy face', + char: '\u{1F635}', + shortName: 'dizzy_face', + emojiGroup: EmojiGroup.smileysEmotion, + emojiSubgroup: EmojiSubgroup.faceUnwell, + keywords: [ + 'dizzy', + 'face', + 'uc6', + 'smiley', + 'dead', + 'wow', + 'nutcase', + 'omg', + 'hate', + 'drunk', + 'dumb', + 'las vegas', + 'what', + 'crazy', + 'smileys', + 'mood', + 'emotion', + 'emotions', + 'emotional', + 'death', + 'die', + 'dying', + 'fart', + 'goth', + 'grave', + 'headstone', + 'horror', + 'hurt', + 'kill', + 'murder', + 'tomb', + 'toot', + 'died', + 'surprised', + 'scared', + 'shocked', + 'whoa', + 'surprise', + 'scary', + 'nervous', + 'shaking', + 'afraid', + 'amaze', + 'amazing', + 'creepy', + 'cringe', + 'gasp', + 'anxious', + 'mind blown', + 'omfg', + 'oh my god', + 'i hate', + 'disgust', + 'stump', + 'shout', + 'dislike', + 'rude', + 'annoy', + 'grinch', + 'gross', + 'grumpy', + 'mean', + 'problem', + 'suck', + 'jerk', + 'asshole', + 'no', + 'flustered', + 'dizzy', + 'idiot', + 'ignorant', + 'stupid', + 'vegas', + 'weird', + 'awkward', + 'insane', + 'wild', + '#-)', + '#)', + '%-)', + '%)', + 'X)', + 'X-)' + ]), + Emoji( + name: 'zipper-mouth face', + char: '\u{1F910}', + shortName: 'zipper_mouth', + emojiGroup: EmojiGroup.smileysEmotion, + emojiSubgroup: EmojiSubgroup.faceNeutralSkeptical, + keywords: [ + 'face', + 'mouth', + 'zipper', + 'uc8', + 'smiley', + 'angry', + 'fight', + 'dumb', + 'quiet', + 'crazy', + 'secret', + 'smileys', + 'mood', + 'emotion', + 'emotions', + 'emotional', + 'upset', + 'pissed', + 'pissed off', + 'unhappy', + 'frustrated', + 'anger', + 'rage', + 'frustration', + 'furious', + 'mad', + 'idiot', + 'ignorant', + 'stupid', + 'shut up', + 'hushed', + 'silence', + 'silent', + 'shush', + 'shh', + 'weird', + 'awkward', + 'insane', + 'wild', + 'shhhhh' + ]), + Emoji( + name: 'woozy face', + char: '\u{1F974}', + shortName: 'woozy_face', + emojiGroup: EmojiGroup.smileysEmotion, + emojiSubgroup: EmojiSubgroup.faceUnwell, + keywords: [ + 'uc11', + 'smiley', + 'silly', + 'drugs', + 'sick', + 'drunk', + 'dumb', + 'ugly', + 'crazy', + 'smileys', + 'mood', + 'emotion', + 'emotions', + 'emotional', + 'funny', + 'drug', + 'narcotics', + 'barf', + 'vomit', + 'throw up', + 'puke', + 'get well', + 'cough', + 'puking', + 'barfing', + 'malade', + 'spew', + 'flustered', + 'dizzy', + 'idiot', + 'ignorant', + 'stupid', + 'weird', + 'awkward', + 'insane', + 'wild' + ]), + Emoji( + name: 'nauseated face', + char: '\u{1F922}', + shortName: 'nauseated_face', + emojiGroup: EmojiGroup.smileysEmotion, + emojiSubgroup: EmojiSubgroup.faceUnwell, + keywords: [ + 'face', + 'nauseated', + 'vomit', + 'uc9', + 'smiley', + 'bathroom', + 'sick', + 'hate', + 'drunk', + 'stinky', + 'donald trump', + 'poison', + 'full', + 'Nauseated', + 'smileys', + 'mood', + 'emotion', + 'emotions', + 'emotional', + 'barf', + 'vomit', + 'throw up', + 'puke', + 'get well', + 'cough', + 'puking', + 'barfing', + 'malade', + 'spew', + 'i hate', + 'disgust', + 'stump', + 'shout', + 'dislike', + 'rude', + 'annoy', + 'grinch', + 'gross', + 'grumpy', + 'mean', + 'problem', + 'suck', + 'jerk', + 'asshole', + 'no', + 'flustered', + 'dizzy', + 'smell', + 'stink', + 'odor', + 'trump', + 'toxic', + 'toxins', + 'green face' + ]), + Emoji( + name: 'face vomiting', + char: '\u{1F92E}', + shortName: 'face_vomiting', + emojiGroup: EmojiGroup.smileysEmotion, + emojiSubgroup: EmojiSubgroup.faceUnwell, + keywords: [ + 'sick', + 'vomit', + 'uc10', + 'smiley', + 'bathroom', + 'sick', + 'hate', + 'donald trump', + 'Nauseated', + 'smileys', + 'mood', + 'emotion', + 'emotions', + 'emotional', + 'barf', + 'vomit', + 'throw up', + 'puke', + 'get well', + 'cough', + 'puking', + 'barfing', + 'malade', + 'spew', + 'i hate', + 'disgust', + 'stump', + 'shout', + 'dislike', + 'rude', + 'annoy', + 'grinch', + 'gross', + 'grumpy', + 'mean', + 'problem', + 'suck', + 'jerk', + 'asshole', + 'no', + 'trump', + 'green face' + ]), + Emoji( + name: 'sneezing face', + char: '\u{1F927}', + shortName: 'sneezing_face', + emojiGroup: EmojiGroup.smileysEmotion, + emojiSubgroup: EmojiSubgroup.faceUnwell, + keywords: [ + 'face', + 'gesundheit', + 'sneeze', + 'uc9', + 'smiley', + 'sick', + 'nurse', + 'stinky', + 'booger', + 'smileys', + 'mood', + 'emotion', + 'emotions', + 'emotional', + 'barf', + 'vomit', + 'throw up', + 'puke', + 'get well', + 'cough', + 'puking', + 'barfing', + 'malade', + 'spew', + 'smell', + 'stink', + 'odor' + ]), + Emoji( + name: 'face with medical mask', + char: '\u{1F637}', + shortName: 'mask', + emojiGroup: EmojiGroup.smileysEmotion, + emojiSubgroup: EmojiSubgroup.faceUnwell, + keywords: [ + 'cold', + 'doctor', + 'face', + 'mask', + 'medicine', + 'sick', + 'uc6', + 'smiley', + 'dead', + 'health', + 'sick', + 'teeth', + 'nurse', + 'clean', + 'poison', + 'mask', + 'virus', + 'covid', + 'smileys', + 'mood', + 'emotion', + 'emotions', + 'emotional', + 'death', + 'die', + 'dying', + 'fart', + 'goth', + 'grave', + 'headstone', + 'horror', + 'hurt', + 'kill', + 'murder', + 'tomb', + 'toot', + 'died', + 'medicine', + 'doctor', + 'barf', + 'vomit', + 'throw up', + 'puke', + 'get well', + 'cough', + 'puking', + 'barfing', + 'malade', + 'spew', + 'dentist', + 'toxic', + 'toxins', + 'corona' + ]), + Emoji( + name: 'face with thermometer', + char: '\u{1F912}', + shortName: 'thermometer_face', + emojiGroup: EmojiGroup.smileysEmotion, + emojiSubgroup: EmojiSubgroup.faceUnwell, + keywords: [ + 'face', + 'ill', + 'sick', + 'thermometer', + 'uc8', + 'smiley', + 'health', + 'sick', + 'nurse', + 'virus', + 'covid', + 'smileys', + 'mood', + 'emotion', + 'emotions', + 'emotional', + 'medicine', + 'doctor', + 'barf', + 'vomit', + 'throw up', + 'puke', + 'get well', + 'cough', + 'puking', + 'barfing', + 'malade', + 'spew', + 'corona' + ]), + Emoji( + name: 'face with head-bandage', + char: '\u{1F915}', + shortName: 'head_bandage', + emojiGroup: EmojiGroup.smileysEmotion, + emojiSubgroup: EmojiSubgroup.faceUnwell, + keywords: [ + 'bandage', + 'face', + 'hurt', + 'injury', + 'uc8', + 'smiley', + 'health', + 'sick', + 'nurse', + 'smileys', + 'mood', + 'emotion', + 'emotions', + 'emotional', + 'medicine', + 'doctor', + 'barf', + 'vomit', + 'throw up', + 'puke', + 'get well', + 'cough', + 'puking', + 'barfing', + 'malade', + 'spew' + ]), + Emoji( + name: 'money-mouth face', + char: '\u{1F911}', + shortName: 'money_mouth', + emojiGroup: EmojiGroup.smileysEmotion, + emojiSubgroup: EmojiSubgroup.faceTongue, + keywords: [ + 'face', + 'money', + 'mouth', + 'uc8', + 'smiley', + 'money', + 'win', + 'boys night', + 'power', + 'stinky', + 'coins', + 'discount', + 'donald trump', + 'jealous', + 'las vegas', + 'rich', + 'greed', + 'smileys', + 'mood', + 'emotion', + 'emotions', + 'emotional', + 'cash', + 'dollars', + 'dollar', + 'bucks', + 'currency', + 'funds', + 'payment', + 'money face', + 'reward', + 'thief', + 'bank', + 'benjamins', + 'argent', + 'dinero', + 'i soldi', + 'Geld', + 'winning', + 'killing it', + 'crushing it', + 'victory', + 'victorious', + 'success', + 'successful', + 'winner', + 'guys night', + 'smell', + 'stink', + 'odor', + 'sale', + 'bargain', + 'trump', + 'vegas', + 'grand', + 'expensive', + 'fancy', + 'selfish' + ]), + Emoji( + name: 'cowboy hat face', + char: '\u{1F920}', + shortName: 'cowboy', + emojiGroup: EmojiGroup.smileysEmotion, + emojiSubgroup: EmojiSubgroup.faceHat, + keywords: [ + 'cowboy', + 'cowgirl', + 'face', + 'hat', + 'uc9', + 'smiley', + 'america', + 'hat', + 'halloween', + 'boys night', + 'magic', + 'disney', + 'fame', + 'super hero', + 'texas', + 'costume', + 'independence day', + 'disguise', + 'smileys', + 'mood', + 'emotion', + 'emotions', + 'emotional', + 'usa', + 'united states', + 'united states of america', + 'american', + 'hats', + 'cap', + 'caps', + 'samhain', + 'guys night', + 'spell', + 'genie', + 'magical', + 'cartoon', + 'famous', + 'celebrity', + 'superhero', + 'superman', + 'batman', + '4th of july' + ]), + Emoji( + name: 'disguised face', + char: '\u{1F978}', + shortName: 'disguised_face', + emojiGroup: EmojiGroup.smileysEmotion, + emojiSubgroup: EmojiSubgroup.faceHat, + keywords: [ + 'uc13', + 'smiley', + 'silly', + 'halloween', + 'eyes', + 'boys night', + 'celebrate', + 'crazy', + 'mystery', + 'costume', + 'clever', + 'disguise', + 'smileys', + 'mood', + 'emotion', + 'emotions', + 'emotional', + 'funny', + 'samhain', + 'eye', + 'eyebrow', + 'guys night', + 'celebration', + 'event', + 'celebrating', + 'festa', + 'parties', + 'events', + 'new years', + 'new year', + 'fiesta', + 'fete', + 'newyear', + 'party', + 'festive', + 'festival', + 'yolo', + 'festejar', + 'weird', + 'awkward', + 'insane', + 'wild', + 'witty' + ]), + Emoji( + name: 'smiling face with horns', + char: '\u{1F608}', + shortName: 'smiling_imp', + emojiGroup: EmojiGroup.smileysEmotion, + emojiSubgroup: EmojiSubgroup.faceNegative, + keywords: [ + 'face', + 'fairy tale', + 'fantasy', + 'horns', + 'smile', + 'uc6', + 'smiley', + 'silly', + 'halloween', + 'angry', + 'monster', + 'boys night', + 'evil', + 'guilty', + 'jealous', + 'porn', + 'crazy', + 'killer', + 'disguise', + 'smileys', + 'mood', + 'emotion', + 'emotions', + 'emotional', + 'funny', + 'samhain', + 'upset', + 'pissed', + 'pissed off', + 'unhappy', + 'frustrated', + 'anger', + 'rage', + 'frustration', + 'furious', + 'mad', + 'monsters', + 'beast', + 'guys night', + 'imp', + 'demon', + 'devil', + 'naughty', + 'devilish', + 'diablo', + 'diable', + 'satan', + 'weird', + 'awkward', + 'insane', + 'wild', + 'savage', + 'scary clown' + ]), + Emoji( + name: 'angry face with horns', + char: '\u{1F47F}', + shortName: 'imp', + emojiGroup: EmojiGroup.smileysEmotion, + emojiSubgroup: EmojiSubgroup.faceNegative, + keywords: [ + 'demon', + 'devil', + 'face', + 'fairy tale', + 'fantasy', + 'imp', + 'uc6', + 'smiley', + 'halloween', + 'angry', + 'monster', + 'wth', + 'fight', + 'evil', + 'dumb', + 'vampire', + 'crazy', + 'killer', + 'smileys', + 'mood', + 'emotion', + 'emotions', + 'emotional', + 'samhain', + 'upset', + 'pissed', + 'pissed off', + 'unhappy', + 'frustrated', + 'anger', + 'rage', + 'frustration', + 'furious', + 'mad', + 'monsters', + 'beast', + 'what the hell', + 'imp', + 'demon', + 'devil', + 'naughty', + 'devilish', + 'diablo', + 'diable', + 'satan', + 'idiot', + 'ignorant', + 'stupid', + 'dracula', + 'weird', + 'awkward', + 'insane', + 'wild', + 'savage', + 'scary clown' + ]), + Emoji( + name: 'ogre', + char: '\u{1F479}', + shortName: 'japanese_ogre', + emojiGroup: EmojiGroup.smileysEmotion, + emojiSubgroup: EmojiSubgroup.faceCostume, + keywords: [ + 'uc6', + 'halloween', + 'japan', + 'angry', + 'monster', + 'wow', + 'evil', + 'super hero', + 'ugly', + 'crazy', + 'killer', + 'disguise', + 'samhain', + 'japanese', + 'ninja', + 'upset', + 'pissed', + 'pissed off', + 'unhappy', + 'frustrated', + 'anger', + 'rage', + 'frustration', + 'furious', + 'mad', + 'monsters', + 'beast', + 'surprised', + 'scared', + 'shocked', + 'whoa', + 'surprise', + 'scary', + 'nervous', + 'shaking', + 'afraid', + 'amaze', + 'amazing', + 'creepy', + 'cringe', + 'gasp', + 'anxious', + 'mind blown', + 'imp', + 'demon', + 'devil', + 'naughty', + 'devilish', + 'diablo', + 'diable', + 'satan', + 'superhero', + 'superman', + 'batman', + 'weird', + 'awkward', + 'insane', + 'wild', + 'savage', + 'scary clown' + ]), + Emoji( + name: 'goblin', + char: '\u{1F47A}', + shortName: 'japanese_goblin', + emojiGroup: EmojiGroup.smileysEmotion, + emojiSubgroup: EmojiSubgroup.faceCostume, + keywords: [ + 'creature', + 'face', + 'fairy tale', + 'fantasy', + 'monster', + 'uc6', + 'halloween', + 'japan', + 'angry', + 'monster', + 'wow', + 'mustache', + 'evil', + 'super hero', + 'ugly', + 'crazy', + 'killer', + 'mask', + 'disguise', + 'samhain', + 'japanese', + 'ninja', + 'upset', + 'pissed', + 'pissed off', + 'unhappy', + 'frustrated', + 'anger', + 'rage', + 'frustration', + 'furious', + 'mad', + 'monsters', + 'beast', + 'surprised', + 'scared', + 'shocked', + 'whoa', + 'surprise', + 'scary', + 'nervous', + 'shaking', + 'afraid', + 'amaze', + 'amazing', + 'creepy', + 'cringe', + 'gasp', + 'anxious', + 'mind blown', + 'imp', + 'demon', + 'devil', + 'naughty', + 'devilish', + 'diablo', + 'diable', + 'satan', + 'superhero', + 'superman', + 'batman', + 'weird', + 'awkward', + 'insane', + 'wild', + 'savage', + 'scary clown' + ]), + Emoji( + name: 'clown face', + char: '\u{1F921}', + shortName: 'clown', + emojiGroup: EmojiGroup.smileysEmotion, + emojiSubgroup: EmojiSubgroup.faceCostume, + keywords: [ + 'clown', + 'face', + 'uc9', + 'smiley', + 'silly', + 'halloween', + 'laugh', + 'circus', + 'magic', + 'donald trump', + 'mcdonalds', + 'super hero', + 'crazy', + 'killer', + 'costume', + 'disguise', + 'smileys', + 'mood', + 'emotion', + 'emotions', + 'emotional', + 'funny', + 'samhain', + 'laughing', + 'lol', + 'rofl', + 'lmao', + 'lmfao', + 'hilarious', + 'ha', + 'haha', + 'chuckle', + 'comedy', + 'giggle', + 'hehe', + 'joyful', + 'laugh out loud', + 'rire', + 'tee hee', + 'jaja', + 'circus tent', + 'clown', + 'clowns', + 'spell', + 'genie', + 'magical', + 'trump', + 'ronald mcdonald', + 'macdo', + 'superhero', + 'superman', + 'batman', + 'weird', + 'awkward', + 'insane', + 'wild', + 'savage', + 'scary clown' + ]), + Emoji( + name: 'pile of poo', + char: '\u{1F4A9}', + shortName: 'poop', + emojiGroup: EmojiGroup.smileysEmotion, + emojiSubgroup: EmojiSubgroup.faceCostume, + keywords: [ + 'comic', + 'dung', + 'face', + 'monster', + 'poo', + 'poop', + 'uc6', + 'silly', + 'bathroom', + 'dead', + 'sol', + 'diarrhea', + 'shit', + 'bitch', + 'stinky', + 'donald trump', + 'dumb', + 'ugly', + 'funny', + 'death', + 'die', + 'dying', + 'fart', + 'goth', + 'grave', + 'headstone', + 'horror', + 'hurt', + 'kill', + 'murder', + 'tomb', + 'toot', + 'died', + 'shit outta luck', + 'shit out of luck', + 'bad luck', + 'shits', + 'the shits', + 'poop', + 'turd', + 'feces', + 'pile', + 'merde', + 'butthole', + 'caca', + 'crap', + 'dirty', + 'pooo', + 'mess', + 'brown', + 'poopoo', + 'puta', + 'pute', + 'smell', + 'stink', + 'odor', + 'trump', + 'idiot', + 'ignorant', + 'stupid' + ]), + Emoji( + name: 'ghost', + char: '\u{1F47B}', + shortName: 'ghost', + emojiGroup: EmojiGroup.smileysEmotion, + emojiSubgroup: EmojiSubgroup.faceCostume, + keywords: [ + 'creature', + 'face', + 'fairy tale', + 'fantasy', + 'monster', + 'uc6', + 'holidays', + 'halloween', + 'dead', + 'monster', + 'wow', + 'disney', + 'pacman', + 'disguise', + 'holiday', + 'samhain', + 'death', + 'die', + 'dying', + 'fart', + 'goth', + 'grave', + 'headstone', + 'horror', + 'hurt', + 'kill', + 'murder', + 'tomb', + 'toot', + 'died', + 'monsters', + 'beast', + 'surprised', + 'scared', + 'shocked', + 'whoa', + 'surprise', + 'scary', + 'nervous', + 'shaking', + 'afraid', + 'amaze', + 'amazing', + 'creepy', + 'cringe', + 'gasp', + 'anxious', + 'mind blown', + 'cartoon', + 'pac man' + ]), + Emoji( + name: 'skull', + char: '\u{1F480}', + shortName: 'skull', + emojiGroup: EmojiGroup.smileysEmotion, + emojiSubgroup: EmojiSubgroup.faceNegative, + keywords: [ + 'death', + 'face', + 'fairy tale', + 'monster', + 'uc6', + 'halloween', + 'dead', + 'skull', + 'wow', + 'harry potter', + 'pirate', + 'poison', + 'super hero', + 'killer', + 'bones', + 'samhain', + 'death', + 'die', + 'dying', + 'fart', + 'goth', + 'grave', + 'headstone', + 'horror', + 'hurt', + 'kill', + 'murder', + 'tomb', + 'toot', + 'died', + 'skull and crossbones', + 'skeleton', + 'surprised', + 'scared', + 'shocked', + 'whoa', + 'surprise', + 'scary', + 'nervous', + 'shaking', + 'afraid', + 'amaze', + 'amazing', + 'creepy', + 'cringe', + 'gasp', + 'anxious', + 'mind blown', + 'toxic', + 'toxins', + 'superhero', + 'superman', + 'batman', + 'savage', + 'scary clown', + 'Os', + 'hueso' + ]), + Emoji( + name: 'skull and crossbones', + char: '\u{2620}\u{FE0F}', + shortName: 'skull_crossbones', + emojiGroup: EmojiGroup.smileysEmotion, + emojiSubgroup: EmojiSubgroup.faceNegative, + keywords: [ + 'crossbones', + 'death', + 'face', + 'monster', + 'skull', + 'uc1', + 'halloween', + 'dead', + 'skull', + 'wow', + 'deadpool', + 'pirate', + 'danger', + 'disney', + 'poison', + 'killer', + 'bones', + 'samhain', + 'death', + 'die', + 'dying', + 'fart', + 'goth', + 'grave', + 'headstone', + 'horror', + 'hurt', + 'kill', + 'murder', + 'tomb', + 'toot', + 'died', + 'skull and crossbones', + 'skeleton', + 'surprised', + 'scared', + 'shocked', + 'whoa', + 'surprise', + 'scary', + 'nervous', + 'shaking', + 'afraid', + 'amaze', + 'amazing', + 'creepy', + 'cringe', + 'gasp', + 'anxious', + 'mind blown', + 'warn', + 'attention', + 'caution', + 'alert', + 'error', + 'panic', + 'restricted', + "don't", + 'dont', + 'dangerous', + 'cartoon', + 'toxic', + 'toxins', + 'savage', + 'scary clown', + 'Os', + 'hueso' + ]), + Emoji( + name: 'alien', + char: '\u{1F47D}', + shortName: 'alien', + emojiGroup: EmojiGroup.smileysEmotion, + emojiSubgroup: EmojiSubgroup.faceCostume, + keywords: [ + 'creature', + 'extraterrestrial', + 'face', + 'fairy tale', + 'fantasy', + 'monster', + 'ufo', + 'uc6', + 'halloween', + 'space', + 'monster', + 'alien', + 'scientology', + 'star wars', + 'disguise', + 'samhain', + 'outer space', + 'galaxy', + 'universe', + 'nasa', + 'spaceship', + 'monsters', + 'beast', + 'ufo', + 'scientologist' + ]), + Emoji( + name: 'alien monster', + char: '\u{1F47E}', + shortName: 'space_invader', + emojiGroup: EmojiGroup.smileysEmotion, + emojiSubgroup: EmojiSubgroup.faceCostume, + keywords: [ + 'alien', + 'creature', + 'extraterrestrial', + 'face', + 'fairy tale', + 'fantasy', + 'monster', + 'ufo', + 'uc6', + 'halloween', + 'space', + 'monster', + 'alien', + 'star wars', + 'vintage', + 'pacman', + 'samhain', + 'outer space', + 'galaxy', + 'universe', + 'nasa', + 'spaceship', + 'monsters', + 'beast', + 'ufo', + 'pac man' + ]), + Emoji( + name: 'robot', + char: '\u{1F916}', + shortName: 'robot', + emojiGroup: EmojiGroup.smileysEmotion, + emojiSubgroup: EmojiSubgroup.faceCostume, + keywords: [ + 'face', + 'monster', + 'robot', + 'uc8', + 'halloween', + 'monster', + 'disney', + 'drone', + 'samhain', + 'monsters', + 'beast', + 'cartoon' + ]), + Emoji( + name: 'jack-o-lantern', + char: '\u{1F383}', + shortName: 'jack_o_lantern', + emojiGroup: EmojiGroup.activities, + emojiSubgroup: EmojiSubgroup.event, + keywords: [ + 'celebration', + 'halloween', + 'jack', + 'lantern', + 'uc6', + 'holidays', + 'halloween', + 'pumpkin', + 'minecraft', + 'holiday', + 'samhain', + 'jack o lantern', + 'zucca', + 'citrouille' + ]), + Emoji( + name: 'grinning cat', + char: '\u{1F63A}', + shortName: 'smiley_cat', + emojiGroup: EmojiGroup.smileysEmotion, + emojiSubgroup: EmojiSubgroup.catFace, + keywords: [ + 'cat', + 'face', + 'mouth', + 'open', + 'smile', + 'uc6', + 'animal', + 'happy', + 'silly', + 'cat', + 'animals', + 'animal kingdom', + 'hooray', + 'cheek', + 'cheeky', + 'excited', + 'feliz', + 'heureux', + 'cheerful', + 'delighted', + 'ecstatic', + 'elated', + 'glad', + 'joy', + 'merry', + 'funny', + 'kitty', + 'kitten', + 'cats', + 'kittens', + 'kitties', + 'feline', + 'felines', + 'cat face', + 'gato', + 'meow' + ]), + Emoji( + name: 'grinning cat with smiling eyes', + char: '\u{1F638}', + shortName: 'smile_cat', + emojiGroup: EmojiGroup.smileysEmotion, + emojiSubgroup: EmojiSubgroup.catFace, + keywords: [ + 'cat', + 'eye', + 'face', + 'grin', + 'smile', + 'uc6', + 'animal', + 'happy', + 'silly', + 'cat', + 'porn', + 'animals', + 'animal kingdom', + 'hooray', + 'cheek', + 'cheeky', + 'excited', + 'feliz', + 'heureux', + 'cheerful', + 'delighted', + 'ecstatic', + 'elated', + 'glad', + 'joy', + 'merry', + 'funny', + 'kitty', + 'kitten', + 'cats', + 'kittens', + 'kitties', + 'feline', + 'felines', + 'cat face', + 'gato', + 'meow' + ]), + Emoji( + name: 'cat with tears of joy', + char: '\u{1F639}', + shortName: 'joy_cat', + emojiGroup: EmojiGroup.smileysEmotion, + emojiSubgroup: EmojiSubgroup.catFace, + keywords: [ + 'cat', + 'face', + 'joy', + 'tear', + 'uc6', + 'animal', + 'happy', + 'silly', + 'cry', + 'laugh', + 'cat', + 'sarcastic', + 'tease', + 'animals', + 'animal kingdom', + 'hooray', + 'cheek', + 'cheeky', + 'excited', + 'feliz', + 'heureux', + 'cheerful', + 'delighted', + 'ecstatic', + 'elated', + 'glad', + 'joy', + 'merry', + 'funny', + 'crying', + 'weeping', + 'weep', + 'sob', + 'sobbing', + 'tear', + 'tears', + 'bawling', + 'laughing', + 'lol', + 'rofl', + 'lmao', + 'lmfao', + 'hilarious', + 'ha', + 'haha', + 'chuckle', + 'comedy', + 'giggle', + 'hehe', + 'joyful', + 'laugh out loud', + 'rire', + 'tee hee', + 'jaja', + 'kitty', + 'kitten', + 'cats', + 'kittens', + 'kitties', + 'feline', + 'felines', + 'cat face', + 'gato', + 'meow', + 'sarcasm', + 'joke', + 'kidding' + ]), + Emoji( + name: 'smiling cat with heart-eyes', + char: '\u{1F63B}', + shortName: 'heart_eyes_cat', + emojiGroup: EmojiGroup.smileysEmotion, + emojiSubgroup: EmojiSubgroup.catFace, + keywords: [ + 'cat', + 'eye', + 'face', + 'love', + 'smile', + 'uc6', + 'animal', + 'happy', + 'love', + 'cat', + 'heart eyes', + 'beautiful', + 'pussy', + 'porn', + 'animals', + 'animal kingdom', + 'hooray', + 'cheek', + 'cheeky', + 'excited', + 'feliz', + 'heureux', + 'cheerful', + 'delighted', + 'ecstatic', + 'elated', + 'glad', + 'joy', + 'merry', + 'i love you', + 'te amo', + "je t'aime", + 'anniversary', + 'lovin', + 'amour', + 'aimer', + 'amor', + 'valentines day', + 'enamour', + 'lovey', + 'kitty', + 'kitten', + 'cats', + 'kittens', + 'kitties', + 'feline', + 'felines', + 'cat face', + 'gato', + 'meow', + 'cute', + 'pretty', + 'adorable', + 'adore', + 'beauty', + 'cutie', + 'babe', + 'lovely', + 'condom' + ]), + Emoji( + name: 'cat with wry smile', + char: '\u{1F63C}', + shortName: 'smirk_cat', + emojiGroup: EmojiGroup.smileysEmotion, + emojiSubgroup: EmojiSubgroup.catFace, + keywords: [ + 'cat', + 'face', + 'ironic', + 'smile', + 'wry', + 'uc6', + 'animal', + 'cat', + 'animals', + 'animal kingdom', + 'kitty', + 'kitten', + 'cats', + 'kittens', + 'kitties', + 'feline', + 'felines', + 'cat face', + 'gato', + 'meow' + ]), + Emoji( + name: 'kissing cat', + char: '\u{1F63D}', + shortName: 'kissing_cat', + emojiGroup: EmojiGroup.smileysEmotion, + emojiSubgroup: EmojiSubgroup.catFace, + keywords: [ + 'cat', + 'eye', + 'face', + 'kiss', + 'uc6', + 'animal', + 'love', + 'cat', + 'kisses', + 'animals', + 'animal kingdom', + 'i love you', + 'te amo', + "je t'aime", + 'anniversary', + 'lovin', + 'amour', + 'aimer', + 'amor', + 'valentines day', + 'enamour', + 'lovey', + 'kitty', + 'kitten', + 'cats', + 'kittens', + 'kitties', + 'feline', + 'felines', + 'cat face', + 'gato', + 'meow', + 'bisous', + 'beijos', + 'besos', + 'bise', + 'blowing kisses', + 'kissy' + ]), + Emoji( + name: 'weary cat', + char: '\u{1F640}', + shortName: 'scream_cat', + emojiGroup: EmojiGroup.smileysEmotion, + emojiSubgroup: EmojiSubgroup.catFace, + keywords: [ + 'cat', + 'face', + 'oh', + 'surprised', + 'weary', + 'uc6', + 'animal', + 'cat', + 'animals', + 'animal kingdom', + 'kitty', + 'kitten', + 'cats', + 'kittens', + 'kitties', + 'feline', + 'felines', + 'cat face', + 'gato', + 'meow' + ]), + Emoji( + name: 'crying cat', + char: '\u{1F63F}', + shortName: 'crying_cat_face', + emojiGroup: EmojiGroup.smileysEmotion, + emojiSubgroup: EmojiSubgroup.catFace, + keywords: [ + 'cat', + 'cry', + 'face', + 'sad', + 'tear', + 'uc6', + 'animal', + 'cry', + 'cat', + 'animals', + 'animal kingdom', + 'crying', + 'weeping', + 'weep', + 'sob', + 'sobbing', + 'tear', + 'tears', + 'bawling', + 'kitty', + 'kitten', + 'cats', + 'kittens', + 'kitties', + 'feline', + 'felines', + 'cat face', + 'gato', + 'meow' + ]), + Emoji( + name: 'pouting cat', + char: '\u{1F63E}', + shortName: 'pouting_cat', + emojiGroup: EmojiGroup.smileysEmotion, + emojiSubgroup: EmojiSubgroup.catFace, + keywords: [ + 'cat', + 'face', + 'pouting', + 'uc6', + 'animal', + 'cat', + 'animals', + 'animal kingdom', + 'kitty', + 'kitten', + 'cats', + 'kittens', + 'kitties', + 'feline', + 'felines', + 'cat face', + 'gato', + 'meow' + ]), + Emoji( + name: 'palms up together', + char: '\u{1F932}', + shortName: 'palms_up_together', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.hands, + keywords: [ + 'uc10', + 'diversity', + 'body', + 'hands', + 'pray', + 'soul', + 'language', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'body part', + 'anatomy', + 'hand', + 'finger', + 'fingers', + 'prayer', + 'praying', + 'prayers', + 'grateful', + 'sorry', + 'heaven', + 'bless', + 'faith', + 'holy', + 'spirit', + 'hopeful', + 'blessed', + 'preach', + 'offering' + ]), + Emoji( + name: 'palms up together: light skin tone', + char: '\u{1F932}\u{1F3FB}', + shortName: 'palms_up_together_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.hands, + keywords: [ + 'light skin tone', + 'prayer', + 'uc10', + 'diversity', + 'body', + 'hands', + 'pray', + 'soul', + 'language', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'body part', + 'anatomy', + 'hand', + 'finger', + 'fingers', + 'prayer', + 'praying', + 'prayers', + 'grateful', + 'sorry', + 'heaven', + 'bless', + 'faith', + 'holy', + 'spirit', + 'hopeful', + 'blessed', + 'preach', + 'offering' + ], + modifiable: true), + Emoji( + name: 'palms up together: medium-light skin tone', + char: '\u{1F932}\u{1F3FC}', + shortName: 'palms_up_together_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.hands, + keywords: [ + 'medium-light skin tone', + 'prayer', + 'uc10', + 'diversity', + 'body', + 'hands', + 'pray', + 'soul', + 'language', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'body part', + 'anatomy', + 'hand', + 'finger', + 'fingers', + 'prayer', + 'praying', + 'prayers', + 'grateful', + 'sorry', + 'heaven', + 'bless', + 'faith', + 'holy', + 'spirit', + 'hopeful', + 'blessed', + 'preach', + 'offering' + ], + modifiable: true), + Emoji( + name: 'palms up together: medium skin tone', + char: '\u{1F932}\u{1F3FD}', + shortName: 'palms_up_together_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.hands, + keywords: [ + 'medium skin tone', + 'prayer', + 'uc10', + 'diversity', + 'body', + 'hands', + 'pray', + 'soul', + 'language', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'body part', + 'anatomy', + 'hand', + 'finger', + 'fingers', + 'prayer', + 'praying', + 'prayers', + 'grateful', + 'sorry', + 'heaven', + 'bless', + 'faith', + 'holy', + 'spirit', + 'hopeful', + 'blessed', + 'preach', + 'offering' + ], + modifiable: true), + Emoji( + name: 'palms up together: medium-dark skin tone', + char: '\u{1F932}\u{1F3FE}', + shortName: 'palms_up_together_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.hands, + keywords: [ + 'medium-dark skin tone', + 'prayer', + 'uc10', + 'diversity', + 'body', + 'hands', + 'pray', + 'soul', + 'language', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'body part', + 'anatomy', + 'hand', + 'finger', + 'fingers', + 'prayer', + 'praying', + 'prayers', + 'grateful', + 'sorry', + 'heaven', + 'bless', + 'faith', + 'holy', + 'spirit', + 'hopeful', + 'blessed', + 'preach', + 'offering' + ], + modifiable: true), + Emoji( + name: 'palms up together: dark skin tone', + char: '\u{1F932}\u{1F3FF}', + shortName: 'palms_up_together_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.hands, + keywords: [ + 'dark skin tone', + 'prayer', + 'uc10', + 'diversity', + 'body', + 'hands', + 'pray', + 'soul', + 'language', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'body part', + 'anatomy', + 'hand', + 'finger', + 'fingers', + 'prayer', + 'praying', + 'prayers', + 'grateful', + 'sorry', + 'heaven', + 'bless', + 'faith', + 'holy', + 'spirit', + 'hopeful', + 'blessed', + 'preach', + 'offering' + ], + modifiable: true), + Emoji( + name: 'open hands', + char: '\u{1F450}', + shortName: 'open_hands', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.hands, + keywords: [ + 'hand', + 'open', + 'uc6', + 'diversity', + 'body', + 'hands', + 'hi', + 'thank you', + 'condolence', + 'private', + 'language', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'body part', + 'anatomy', + 'hand', + 'finger', + 'fingers', + 'hello', + 'greeting', + 'bonjour', + 'bye', + 'ciao', + 'adios', + 'goodbye', + 'hey', + 'holla', + 'my name is', + 'salut', + 'welcome', + 'ПРИВЕТ', + 'tu tapelle', + 'thanks', + 'thankful', + 'praise', + 'gracias', + 'merci', + 'thankyou', + 'acceptable', + 'compassion', + 'прив', + 'privé', + 'privado', + 'reserved' + ]), + Emoji( + name: 'open hands: light skin tone', + char: '\u{1F450}\u{1F3FB}', + shortName: 'open_hands_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.hands, + keywords: [ + 'hand', + 'light skin tone', + 'open', + 'uc8', + 'diversity', + 'body', + 'hands', + 'hi', + 'thank you', + 'condolence', + 'private', + 'language', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'body part', + 'anatomy', + 'hand', + 'finger', + 'fingers', + 'hello', + 'greeting', + 'bonjour', + 'bye', + 'ciao', + 'adios', + 'goodbye', + 'hey', + 'holla', + 'my name is', + 'salut', + 'welcome', + 'ПРИВЕТ', + 'tu tapelle', + 'thanks', + 'thankful', + 'praise', + 'gracias', + 'merci', + 'thankyou', + 'acceptable', + 'compassion', + 'прив', + 'privé', + 'privado', + 'reserved' + ], + modifiable: true), + Emoji( + name: 'open hands: medium-light skin tone', + char: '\u{1F450}\u{1F3FC}', + shortName: 'open_hands_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.hands, + keywords: [ + 'hand', + 'medium-light skin tone', + 'open', + 'uc8', + 'diversity', + 'body', + 'hands', + 'hi', + 'thank you', + 'condolence', + 'private', + 'language', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'body part', + 'anatomy', + 'hand', + 'finger', + 'fingers', + 'hello', + 'greeting', + 'bonjour', + 'bye', + 'ciao', + 'adios', + 'goodbye', + 'hey', + 'holla', + 'my name is', + 'salut', + 'welcome', + 'ПРИВЕТ', + 'tu tapelle', + 'thanks', + 'thankful', + 'praise', + 'gracias', + 'merci', + 'thankyou', + 'acceptable', + 'compassion', + 'прив', + 'privé', + 'privado', + 'reserved' + ], + modifiable: true), + Emoji( + name: 'open hands: medium skin tone', + char: '\u{1F450}\u{1F3FD}', + shortName: 'open_hands_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.hands, + keywords: [ + 'hand', + 'medium skin tone', + 'open', + 'uc8', + 'diversity', + 'body', + 'hands', + 'hi', + 'thank you', + 'condolence', + 'private', + 'language', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'body part', + 'anatomy', + 'hand', + 'finger', + 'fingers', + 'hello', + 'greeting', + 'bonjour', + 'bye', + 'ciao', + 'adios', + 'goodbye', + 'hey', + 'holla', + 'my name is', + 'salut', + 'welcome', + 'ПРИВЕТ', + 'tu tapelle', + 'thanks', + 'thankful', + 'praise', + 'gracias', + 'merci', + 'thankyou', + 'acceptable', + 'compassion', + 'прив', + 'privé', + 'privado', + 'reserved' + ], + modifiable: true), + Emoji( + name: 'open hands: medium-dark skin tone', + char: '\u{1F450}\u{1F3FE}', + shortName: 'open_hands_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.hands, + keywords: [ + 'hand', + 'medium-dark skin tone', + 'open', + 'uc8', + 'diversity', + 'body', + 'hands', + 'hi', + 'thank you', + 'condolence', + 'private', + 'language', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'body part', + 'anatomy', + 'hand', + 'finger', + 'fingers', + 'hello', + 'greeting', + 'bonjour', + 'bye', + 'ciao', + 'adios', + 'goodbye', + 'hey', + 'holla', + 'my name is', + 'salut', + 'welcome', + 'ПРИВЕТ', + 'tu tapelle', + 'thanks', + 'thankful', + 'praise', + 'gracias', + 'merci', + 'thankyou', + 'acceptable', + 'compassion', + 'прив', + 'privé', + 'privado', + 'reserved' + ], + modifiable: true), + Emoji( + name: 'open hands: dark skin tone', + char: '\u{1F450}\u{1F3FF}', + shortName: 'open_hands_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.hands, + keywords: [ + 'dark skin tone', + 'hand', + 'open', + 'uc8', + 'diversity', + 'body', + 'hands', + 'hi', + 'thank you', + 'condolence', + 'private', + 'language', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'body part', + 'anatomy', + 'hand', + 'finger', + 'fingers', + 'hello', + 'greeting', + 'bonjour', + 'bye', + 'ciao', + 'adios', + 'goodbye', + 'hey', + 'holla', + 'my name is', + 'salut', + 'welcome', + 'ПРИВЕТ', + 'tu tapelle', + 'thanks', + 'thankful', + 'praise', + 'gracias', + 'merci', + 'thankyou', + 'acceptable', + 'compassion', + 'прив', + 'privé', + 'privado', + 'reserved' + ], + modifiable: true), + Emoji( + name: 'raising hands', + char: '\u{1F64C}', + shortName: 'raised_hands', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.hands, + keywords: [ + 'celebration', + 'gesture', + 'hand', + 'hooray', + 'raised', + 'uc6', + 'diversity', + 'happy', + 'body', + 'hands', + 'award', + 'hi', + 'thank you', + 'perfect', + 'pray', + 'good', + 'girls night', + 'easter', + 'fame', + 'festivus', + 'language', + 'protest', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'hooray', + 'cheek', + 'cheeky', + 'excited', + 'feliz', + 'heureux', + 'cheerful', + 'delighted', + 'ecstatic', + 'elated', + 'glad', + 'joy', + 'merry', + 'body part', + 'anatomy', + 'hand', + 'finger', + 'fingers', + 'awards', + 'prize', + 'prizes', + 'trophy', + 'trophies', + 'spot', + 'best', + 'champion', + 'hero', + 'hello', + 'greeting', + 'bonjour', + 'bye', + 'ciao', + 'adios', + 'goodbye', + 'hey', + 'holla', + 'my name is', + 'salut', + 'welcome', + 'ПРИВЕТ', + 'tu tapelle', + 'thanks', + 'thankful', + 'praise', + 'gracias', + 'merci', + 'thankyou', + 'acceptable', + 'perfecto', + 'perfection', + 'superb', + 'flawless', + 'excellent', + 'supreme', + 'super', + 'great', + 'prayer', + 'praying', + 'prayers', + 'grateful', + 'sorry', + 'heaven', + 'bless', + 'faith', + 'holy', + 'spirit', + 'hopeful', + 'blessed', + 'preach', + 'offering', + 'good job', + 'nice', + 'well done', + 'bravo', + 'congratulations', + 'congrats', + 'ladies night', + 'girls only', + 'girlfriend', + 'famous', + 'celebrity', + 'blm', + 'demonstration' + ]), + Emoji( + name: 'raising hands: light skin tone', + char: '\u{1F64C}\u{1F3FB}', + shortName: 'raised_hands_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.hands, + keywords: [ + 'celebration', + 'gesture', + 'hand', + 'hooray', + 'light skin tone', + 'raised', + 'uc8', + 'diversity', + 'happy', + 'body', + 'hands', + 'award', + 'hi', + 'thank you', + 'perfect', + 'pray', + 'good', + 'girls night', + 'easter', + 'fame', + 'festivus', + 'language', + 'protest', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'hooray', + 'cheek', + 'cheeky', + 'excited', + 'feliz', + 'heureux', + 'cheerful', + 'delighted', + 'ecstatic', + 'elated', + 'glad', + 'joy', + 'merry', + 'body part', + 'anatomy', + 'hand', + 'finger', + 'fingers', + 'awards', + 'prize', + 'prizes', + 'trophy', + 'trophies', + 'spot', + 'best', + 'champion', + 'hero', + 'hello', + 'greeting', + 'bonjour', + 'bye', + 'ciao', + 'adios', + 'goodbye', + 'hey', + 'holla', + 'my name is', + 'salut', + 'welcome', + 'ПРИВЕТ', + 'tu tapelle', + 'thanks', + 'thankful', + 'praise', + 'gracias', + 'merci', + 'thankyou', + 'acceptable', + 'perfecto', + 'perfection', + 'superb', + 'flawless', + 'excellent', + 'supreme', + 'super', + 'great', + 'prayer', + 'praying', + 'prayers', + 'grateful', + 'sorry', + 'heaven', + 'bless', + 'faith', + 'holy', + 'spirit', + 'hopeful', + 'blessed', + 'preach', + 'offering', + 'good job', + 'nice', + 'well done', + 'bravo', + 'congratulations', + 'congrats', + 'ladies night', + 'girls only', + 'girlfriend', + 'famous', + 'celebrity', + 'blm', + 'demonstration' + ], + modifiable: true), + Emoji( + name: 'raising hands: medium-light skin tone', + char: '\u{1F64C}\u{1F3FC}', + shortName: 'raised_hands_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.hands, + keywords: [ + 'celebration', + 'gesture', + 'hand', + 'hooray', + 'medium-light skin tone', + 'raised', + 'uc8', + 'diversity', + 'happy', + 'body', + 'hands', + 'award', + 'hi', + 'thank you', + 'perfect', + 'pray', + 'good', + 'girls night', + 'easter', + 'fame', + 'festivus', + 'language', + 'protest', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'hooray', + 'cheek', + 'cheeky', + 'excited', + 'feliz', + 'heureux', + 'cheerful', + 'delighted', + 'ecstatic', + 'elated', + 'glad', + 'joy', + 'merry', + 'body part', + 'anatomy', + 'hand', + 'finger', + 'fingers', + 'awards', + 'prize', + 'prizes', + 'trophy', + 'trophies', + 'spot', + 'best', + 'champion', + 'hero', + 'hello', + 'greeting', + 'bonjour', + 'bye', + 'ciao', + 'adios', + 'goodbye', + 'hey', + 'holla', + 'my name is', + 'salut', + 'welcome', + 'ПРИВЕТ', + 'tu tapelle', + 'thanks', + 'thankful', + 'praise', + 'gracias', + 'merci', + 'thankyou', + 'acceptable', + 'perfecto', + 'perfection', + 'superb', + 'flawless', + 'excellent', + 'supreme', + 'super', + 'great', + 'prayer', + 'praying', + 'prayers', + 'grateful', + 'sorry', + 'heaven', + 'bless', + 'faith', + 'holy', + 'spirit', + 'hopeful', + 'blessed', + 'preach', + 'offering', + 'good job', + 'nice', + 'well done', + 'bravo', + 'congratulations', + 'congrats', + 'ladies night', + 'girls only', + 'girlfriend', + 'famous', + 'celebrity', + 'blm', + 'demonstration' + ], + modifiable: true), + Emoji( + name: 'raising hands: medium skin tone', + char: '\u{1F64C}\u{1F3FD}', + shortName: 'raised_hands_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.hands, + keywords: [ + 'celebration', + 'gesture', + 'hand', + 'hooray', + 'medium skin tone', + 'raised', + 'uc8', + 'diversity', + 'happy', + 'body', + 'hands', + 'award', + 'hi', + 'thank you', + 'perfect', + 'pray', + 'good', + 'girls night', + 'easter', + 'fame', + 'festivus', + 'language', + 'protest', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'hooray', + 'cheek', + 'cheeky', + 'excited', + 'feliz', + 'heureux', + 'cheerful', + 'delighted', + 'ecstatic', + 'elated', + 'glad', + 'joy', + 'merry', + 'body part', + 'anatomy', + 'hand', + 'finger', + 'fingers', + 'awards', + 'prize', + 'prizes', + 'trophy', + 'trophies', + 'spot', + 'best', + 'champion', + 'hero', + 'hello', + 'greeting', + 'bonjour', + 'bye', + 'ciao', + 'adios', + 'goodbye', + 'hey', + 'holla', + 'my name is', + 'salut', + 'welcome', + 'ПРИВЕТ', + 'tu tapelle', + 'thanks', + 'thankful', + 'praise', + 'gracias', + 'merci', + 'thankyou', + 'acceptable', + 'perfecto', + 'perfection', + 'superb', + 'flawless', + 'excellent', + 'supreme', + 'super', + 'great', + 'prayer', + 'praying', + 'prayers', + 'grateful', + 'sorry', + 'heaven', + 'bless', + 'faith', + 'holy', + 'spirit', + 'hopeful', + 'blessed', + 'preach', + 'offering', + 'good job', + 'nice', + 'well done', + 'bravo', + 'congratulations', + 'congrats', + 'ladies night', + 'girls only', + 'girlfriend', + 'famous', + 'celebrity', + 'blm', + 'demonstration' + ], + modifiable: true), + Emoji( + name: 'raising hands: medium-dark skin tone', + char: '\u{1F64C}\u{1F3FE}', + shortName: 'raised_hands_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.hands, + keywords: [ + 'celebration', + 'gesture', + 'hand', + 'hooray', + 'medium-dark skin tone', + 'raised', + 'uc8', + 'diversity', + 'happy', + 'body', + 'hands', + 'award', + 'hi', + 'thank you', + 'perfect', + 'pray', + 'good', + 'girls night', + 'easter', + 'fame', + 'festivus', + 'language', + 'protest', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'hooray', + 'cheek', + 'cheeky', + 'excited', + 'feliz', + 'heureux', + 'cheerful', + 'delighted', + 'ecstatic', + 'elated', + 'glad', + 'joy', + 'merry', + 'body part', + 'anatomy', + 'hand', + 'finger', + 'fingers', + 'awards', + 'prize', + 'prizes', + 'trophy', + 'trophies', + 'spot', + 'best', + 'champion', + 'hero', + 'hello', + 'greeting', + 'bonjour', + 'bye', + 'ciao', + 'adios', + 'goodbye', + 'hey', + 'holla', + 'my name is', + 'salut', + 'welcome', + 'ПРИВЕТ', + 'tu tapelle', + 'thanks', + 'thankful', + 'praise', + 'gracias', + 'merci', + 'thankyou', + 'acceptable', + 'perfecto', + 'perfection', + 'superb', + 'flawless', + 'excellent', + 'supreme', + 'super', + 'great', + 'prayer', + 'praying', + 'prayers', + 'grateful', + 'sorry', + 'heaven', + 'bless', + 'faith', + 'holy', + 'spirit', + 'hopeful', + 'blessed', + 'preach', + 'offering', + 'good job', + 'nice', + 'well done', + 'bravo', + 'congratulations', + 'congrats', + 'ladies night', + 'girls only', + 'girlfriend', + 'famous', + 'celebrity', + 'blm', + 'demonstration' + ], + modifiable: true), + Emoji( + name: 'raising hands: dark skin tone', + char: '\u{1F64C}\u{1F3FF}', + shortName: 'raised_hands_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.hands, + keywords: [ + 'celebration', + 'dark skin tone', + 'gesture', + 'hand', + 'hooray', + 'raised', + 'uc8', + 'diversity', + 'happy', + 'body', + 'hands', + 'award', + 'hi', + 'thank you', + 'perfect', + 'pray', + 'good', + 'girls night', + 'easter', + 'fame', + 'festivus', + 'language', + 'protest', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'hooray', + 'cheek', + 'cheeky', + 'excited', + 'feliz', + 'heureux', + 'cheerful', + 'delighted', + 'ecstatic', + 'elated', + 'glad', + 'joy', + 'merry', + 'body part', + 'anatomy', + 'hand', + 'finger', + 'fingers', + 'awards', + 'prize', + 'prizes', + 'trophy', + 'trophies', + 'spot', + 'best', + 'champion', + 'hero', + 'hello', + 'greeting', + 'bonjour', + 'bye', + 'ciao', + 'adios', + 'goodbye', + 'hey', + 'holla', + 'my name is', + 'salut', + 'welcome', + 'ПРИВЕТ', + 'tu tapelle', + 'thanks', + 'thankful', + 'praise', + 'gracias', + 'merci', + 'thankyou', + 'acceptable', + 'perfecto', + 'perfection', + 'superb', + 'flawless', + 'excellent', + 'supreme', + 'super', + 'great', + 'prayer', + 'praying', + 'prayers', + 'grateful', + 'sorry', + 'heaven', + 'bless', + 'faith', + 'holy', + 'spirit', + 'hopeful', + 'blessed', + 'preach', + 'offering', + 'good job', + 'nice', + 'well done', + 'bravo', + 'congratulations', + 'congrats', + 'ladies night', + 'girls only', + 'girlfriend', + 'famous', + 'celebrity', + 'blm', + 'demonstration' + ], + modifiable: true), + Emoji( + name: 'clapping hands', + char: '\u{1F44F}', + shortName: 'clap', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.hands, + keywords: [ + 'clap', + 'hand', + 'uc6', + 'diversity', + 'happy', + 'body', + 'hands', + 'thank you', + 'win', + 'awesome', + 'good', + 'beautiful', + 'clap', + 'pussy', + 'celebrate', + 'pleased', + 'language', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'hooray', + 'cheek', + 'cheeky', + 'excited', + 'feliz', + 'heureux', + 'cheerful', + 'delighted', + 'ecstatic', + 'elated', + 'glad', + 'joy', + 'merry', + 'body part', + 'anatomy', + 'hand', + 'finger', + 'fingers', + 'thanks', + 'thankful', + 'praise', + 'gracias', + 'merci', + 'thankyou', + 'acceptable', + 'winning', + 'killing it', + 'crushing it', + 'victory', + 'victorious', + 'success', + 'successful', + 'winner', + 'okay', + 'got it', + 'cool', + 'ok', + 'will do', + 'like', + 'bien', + 'yep', + 'yup', + 'good job', + 'nice', + 'well done', + 'bravo', + 'congratulations', + 'congrats', + 'cute', + 'pretty', + 'adorable', + 'adore', + 'beauty', + 'cutie', + 'babe', + 'lovely', + 'clapping', + 'claps', + 'clapping hands', + 'applause', + 'condom', + 'celebration', + 'event', + 'celebrating', + 'festa', + 'parties', + 'events', + 'new years', + 'new year', + 'fiesta', + 'fete', + 'newyear', + 'party', + 'festive', + 'festival', + 'yolo', + 'festejar', + 'please', + 'chill', + 'confident', + 'content' + ]), + Emoji( + name: 'clapping hands: light skin tone', + char: '\u{1F44F}\u{1F3FB}', + shortName: 'clap_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.hands, + keywords: [ + 'clap', + 'hand', + 'light skin tone', + 'uc8', + 'diversity', + 'happy', + 'body', + 'hands', + 'thank you', + 'win', + 'awesome', + 'good', + 'beautiful', + 'clap', + 'pussy', + 'celebrate', + 'pleased', + 'language', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'hooray', + 'cheek', + 'cheeky', + 'excited', + 'feliz', + 'heureux', + 'cheerful', + 'delighted', + 'ecstatic', + 'elated', + 'glad', + 'joy', + 'merry', + 'body part', + 'anatomy', + 'hand', + 'finger', + 'fingers', + 'thanks', + 'thankful', + 'praise', + 'gracias', + 'merci', + 'thankyou', + 'acceptable', + 'winning', + 'killing it', + 'crushing it', + 'victory', + 'victorious', + 'success', + 'successful', + 'winner', + 'okay', + 'got it', + 'cool', + 'ok', + 'will do', + 'like', + 'bien', + 'yep', + 'yup', + 'good job', + 'nice', + 'well done', + 'bravo', + 'congratulations', + 'congrats', + 'cute', + 'pretty', + 'adorable', + 'adore', + 'beauty', + 'cutie', + 'babe', + 'lovely', + 'clapping', + 'claps', + 'clapping hands', + 'applause', + 'condom', + 'celebration', + 'event', + 'celebrating', + 'festa', + 'parties', + 'events', + 'new years', + 'new year', + 'fiesta', + 'fete', + 'newyear', + 'party', + 'festive', + 'festival', + 'yolo', + 'festejar', + 'please', + 'chill', + 'confident', + 'content' + ], + modifiable: true), + Emoji( + name: 'clapping hands: medium-light skin tone', + char: '\u{1F44F}\u{1F3FC}', + shortName: 'clap_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.hands, + keywords: [ + 'clap', + 'hand', + 'medium-light skin tone', + 'uc8', + 'diversity', + 'happy', + 'body', + 'hands', + 'thank you', + 'win', + 'awesome', + 'good', + 'beautiful', + 'clap', + 'pussy', + 'celebrate', + 'pleased', + 'language', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'hooray', + 'cheek', + 'cheeky', + 'excited', + 'feliz', + 'heureux', + 'cheerful', + 'delighted', + 'ecstatic', + 'elated', + 'glad', + 'joy', + 'merry', + 'body part', + 'anatomy', + 'hand', + 'finger', + 'fingers', + 'thanks', + 'thankful', + 'praise', + 'gracias', + 'merci', + 'thankyou', + 'acceptable', + 'winning', + 'killing it', + 'crushing it', + 'victory', + 'victorious', + 'success', + 'successful', + 'winner', + 'okay', + 'got it', + 'cool', + 'ok', + 'will do', + 'like', + 'bien', + 'yep', + 'yup', + 'good job', + 'nice', + 'well done', + 'bravo', + 'congratulations', + 'congrats', + 'cute', + 'pretty', + 'adorable', + 'adore', + 'beauty', + 'cutie', + 'babe', + 'lovely', + 'clapping', + 'claps', + 'clapping hands', + 'applause', + 'condom', + 'celebration', + 'event', + 'celebrating', + 'festa', + 'parties', + 'events', + 'new years', + 'new year', + 'fiesta', + 'fete', + 'newyear', + 'party', + 'festive', + 'festival', + 'yolo', + 'festejar', + 'please', + 'chill', + 'confident', + 'content' + ], + modifiable: true), + Emoji( + name: 'clapping hands: medium skin tone', + char: '\u{1F44F}\u{1F3FD}', + shortName: 'clap_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.hands, + keywords: [ + 'clap', + 'hand', + 'medium skin tone', + 'uc8', + 'diversity', + 'happy', + 'body', + 'hands', + 'thank you', + 'win', + 'awesome', + 'good', + 'beautiful', + 'clap', + 'pussy', + 'celebrate', + 'pleased', + 'language', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'hooray', + 'cheek', + 'cheeky', + 'excited', + 'feliz', + 'heureux', + 'cheerful', + 'delighted', + 'ecstatic', + 'elated', + 'glad', + 'joy', + 'merry', + 'body part', + 'anatomy', + 'hand', + 'finger', + 'fingers', + 'thanks', + 'thankful', + 'praise', + 'gracias', + 'merci', + 'thankyou', + 'acceptable', + 'winning', + 'killing it', + 'crushing it', + 'victory', + 'victorious', + 'success', + 'successful', + 'winner', + 'okay', + 'got it', + 'cool', + 'ok', + 'will do', + 'like', + 'bien', + 'yep', + 'yup', + 'good job', + 'nice', + 'well done', + 'bravo', + 'congratulations', + 'congrats', + 'cute', + 'pretty', + 'adorable', + 'adore', + 'beauty', + 'cutie', + 'babe', + 'lovely', + 'clapping', + 'claps', + 'clapping hands', + 'applause', + 'condom', + 'celebration', + 'event', + 'celebrating', + 'festa', + 'parties', + 'events', + 'new years', + 'new year', + 'fiesta', + 'fete', + 'newyear', + 'party', + 'festive', + 'festival', + 'yolo', + 'festejar', + 'please', + 'chill', + 'confident', + 'content' + ], + modifiable: true), + Emoji( + name: 'clapping hands: medium-dark skin tone', + char: '\u{1F44F}\u{1F3FE}', + shortName: 'clap_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.hands, + keywords: [ + 'clap', + 'hand', + 'medium-dark skin tone', + 'uc8', + 'diversity', + 'happy', + 'body', + 'hands', + 'thank you', + 'win', + 'awesome', + 'good', + 'beautiful', + 'clap', + 'pussy', + 'celebrate', + 'pleased', + 'language', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'hooray', + 'cheek', + 'cheeky', + 'excited', + 'feliz', + 'heureux', + 'cheerful', + 'delighted', + 'ecstatic', + 'elated', + 'glad', + 'joy', + 'merry', + 'body part', + 'anatomy', + 'hand', + 'finger', + 'fingers', + 'thanks', + 'thankful', + 'praise', + 'gracias', + 'merci', + 'thankyou', + 'acceptable', + 'winning', + 'killing it', + 'crushing it', + 'victory', + 'victorious', + 'success', + 'successful', + 'winner', + 'okay', + 'got it', + 'cool', + 'ok', + 'will do', + 'like', + 'bien', + 'yep', + 'yup', + 'good job', + 'nice', + 'well done', + 'bravo', + 'congratulations', + 'congrats', + 'cute', + 'pretty', + 'adorable', + 'adore', + 'beauty', + 'cutie', + 'babe', + 'lovely', + 'clapping', + 'claps', + 'clapping hands', + 'applause', + 'condom', + 'celebration', + 'event', + 'celebrating', + 'festa', + 'parties', + 'events', + 'new years', + 'new year', + 'fiesta', + 'fete', + 'newyear', + 'party', + 'festive', + 'festival', + 'yolo', + 'festejar', + 'please', + 'chill', + 'confident', + 'content' + ], + modifiable: true), + Emoji( + name: 'clapping hands: dark skin tone', + char: '\u{1F44F}\u{1F3FF}', + shortName: 'clap_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.hands, + keywords: [ + 'clap', + 'dark skin tone', + 'hand', + 'uc8', + 'diversity', + 'happy', + 'body', + 'hands', + 'thank you', + 'win', + 'awesome', + 'good', + 'beautiful', + 'clap', + 'pussy', + 'celebrate', + 'pleased', + 'language', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'hooray', + 'cheek', + 'cheeky', + 'excited', + 'feliz', + 'heureux', + 'cheerful', + 'delighted', + 'ecstatic', + 'elated', + 'glad', + 'joy', + 'merry', + 'body part', + 'anatomy', + 'hand', + 'finger', + 'fingers', + 'thanks', + 'thankful', + 'praise', + 'gracias', + 'merci', + 'thankyou', + 'acceptable', + 'winning', + 'killing it', + 'crushing it', + 'victory', + 'victorious', + 'success', + 'successful', + 'winner', + 'okay', + 'got it', + 'cool', + 'ok', + 'will do', + 'like', + 'bien', + 'yep', + 'yup', + 'good job', + 'nice', + 'well done', + 'bravo', + 'congratulations', + 'congrats', + 'cute', + 'pretty', + 'adorable', + 'adore', + 'beauty', + 'cutie', + 'babe', + 'lovely', + 'clapping', + 'claps', + 'clapping hands', + 'applause', + 'condom', + 'celebration', + 'event', + 'celebrating', + 'festa', + 'parties', + 'events', + 'new years', + 'new year', + 'fiesta', + 'fete', + 'newyear', + 'party', + 'festive', + 'festival', + 'yolo', + 'festejar', + 'please', + 'chill', + 'confident', + 'content' + ], + modifiable: true), + Emoji( + name: 'handshake', + char: '\u{1F91D}', + shortName: 'handshake', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.hands, + keywords: [ + 'agreement', + 'hand', + 'handshake', + 'meeting', + 'shake', + 'uc9', + 'diversity', + 'body', + 'hands', + 'hi', + 'business', + 'proud', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'body part', + 'anatomy', + 'hand', + 'finger', + 'fingers', + 'hello', + 'greeting', + 'bonjour', + 'bye', + 'ciao', + 'adios', + 'goodbye', + 'hey', + 'holla', + 'my name is', + 'salut', + 'welcome', + 'ПРИВЕТ', + 'tu tapelle' + ]), + Emoji( + name: 'thumbs up', + char: '\u{1F44D}', + shortName: 'thumbsup', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.handFingersClosed, + keywords: [ + '+1', + 'hand', + 'thumb', + 'up', + 'uc6', + 'diversity', + 'happy', + 'body', + 'hands', + 'award', + 'hi', + 'luck', + 'thank you', + 'perfect', + 'awesome', + 'good', + 'beautiful', + 'correct', + 'fun', + 'proud', + 'language', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'hooray', + 'cheek', + 'cheeky', + 'excited', + 'feliz', + 'heureux', + 'cheerful', + 'delighted', + 'ecstatic', + 'elated', + 'glad', + 'joy', + 'merry', + 'body part', + 'anatomy', + 'hand', + 'finger', + 'fingers', + 'awards', + 'prize', + 'prizes', + 'trophy', + 'trophies', + 'spot', + 'best', + 'champion', + 'hero', + 'hello', + 'greeting', + 'bonjour', + 'bye', + 'ciao', + 'adios', + 'goodbye', + 'hey', + 'holla', + 'my name is', + 'salut', + 'welcome', + 'ПРИВЕТ', + 'tu tapelle', + 'good luck', + 'lucky', + 'thanks', + 'thankful', + 'praise', + 'gracias', + 'merci', + 'thankyou', + 'acceptable', + 'perfecto', + 'perfection', + 'superb', + 'flawless', + 'excellent', + 'supreme', + 'super', + 'great', + 'okay', + 'got it', + 'cool', + 'ok', + 'will do', + 'like', + 'bien', + 'yep', + 'yup', + 'good job', + 'nice', + 'well done', + 'bravo', + 'congratulations', + 'congrats', + 'cute', + 'pretty', + 'adorable', + 'adore', + 'beauty', + 'cutie', + 'babe', + 'lovely', + 'passing grade', + '(y)' + ]), + Emoji( + name: 'thumbs up: light skin tone', + char: '\u{1F44D}\u{1F3FB}', + shortName: 'thumbsup_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.handFingersClosed, + keywords: [ + '+1', + 'hand', + 'light skin tone', + 'thumb', + 'up', + 'uc8', + 'diversity', + 'happy', + 'body', + 'hands', + 'award', + 'hi', + 'luck', + 'thank you', + 'perfect', + 'awesome', + 'good', + 'beautiful', + 'correct', + 'fun', + 'proud', + 'language', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'hooray', + 'cheek', + 'cheeky', + 'excited', + 'feliz', + 'heureux', + 'cheerful', + 'delighted', + 'ecstatic', + 'elated', + 'glad', + 'joy', + 'merry', + 'body part', + 'anatomy', + 'hand', + 'finger', + 'fingers', + 'awards', + 'prize', + 'prizes', + 'trophy', + 'trophies', + 'spot', + 'best', + 'champion', + 'hero', + 'hello', + 'greeting', + 'bonjour', + 'bye', + 'ciao', + 'adios', + 'goodbye', + 'hey', + 'holla', + 'my name is', + 'salut', + 'welcome', + 'ПРИВЕТ', + 'tu tapelle', + 'good luck', + 'lucky', + 'thanks', + 'thankful', + 'praise', + 'gracias', + 'merci', + 'thankyou', + 'acceptable', + 'perfecto', + 'perfection', + 'superb', + 'flawless', + 'excellent', + 'supreme', + 'super', + 'great', + 'okay', + 'got it', + 'cool', + 'ok', + 'will do', + 'like', + 'bien', + 'yep', + 'yup', + 'good job', + 'nice', + 'well done', + 'bravo', + 'congratulations', + 'congrats', + 'cute', + 'pretty', + 'adorable', + 'adore', + 'beauty', + 'cutie', + 'babe', + 'lovely', + 'passing grade' + ], + modifiable: true), + Emoji( + name: 'thumbs up: medium-light skin tone', + char: '\u{1F44D}\u{1F3FC}', + shortName: 'thumbsup_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.handFingersClosed, + keywords: [ + '+1', + 'hand', + 'medium-light skin tone', + 'thumb', + 'up', + 'uc8', + 'diversity', + 'happy', + 'body', + 'hands', + 'award', + 'hi', + 'luck', + 'thank you', + 'perfect', + 'awesome', + 'good', + 'beautiful', + 'correct', + 'fun', + 'proud', + 'language', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'hooray', + 'cheek', + 'cheeky', + 'excited', + 'feliz', + 'heureux', + 'cheerful', + 'delighted', + 'ecstatic', + 'elated', + 'glad', + 'joy', + 'merry', + 'body part', + 'anatomy', + 'hand', + 'finger', + 'fingers', + 'awards', + 'prize', + 'prizes', + 'trophy', + 'trophies', + 'spot', + 'best', + 'champion', + 'hero', + 'hello', + 'greeting', + 'bonjour', + 'bye', + 'ciao', + 'adios', + 'goodbye', + 'hey', + 'holla', + 'my name is', + 'salut', + 'welcome', + 'ПРИВЕТ', + 'tu tapelle', + 'good luck', + 'lucky', + 'thanks', + 'thankful', + 'praise', + 'gracias', + 'merci', + 'thankyou', + 'acceptable', + 'perfecto', + 'perfection', + 'superb', + 'flawless', + 'excellent', + 'supreme', + 'super', + 'great', + 'okay', + 'got it', + 'cool', + 'ok', + 'will do', + 'like', + 'bien', + 'yep', + 'yup', + 'good job', + 'nice', + 'well done', + 'bravo', + 'congratulations', + 'congrats', + 'cute', + 'pretty', + 'adorable', + 'adore', + 'beauty', + 'cutie', + 'babe', + 'lovely', + 'passing grade' + ], + modifiable: true), + Emoji( + name: 'thumbs up: medium skin tone', + char: '\u{1F44D}\u{1F3FD}', + shortName: 'thumbsup_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.handFingersClosed, + keywords: [ + '+1', + 'hand', + 'medium skin tone', + 'thumb', + 'up', + 'uc8', + 'diversity', + 'happy', + 'body', + 'hands', + 'award', + 'hi', + 'luck', + 'thank you', + 'perfect', + 'awesome', + 'good', + 'beautiful', + 'correct', + 'fun', + 'proud', + 'language', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'hooray', + 'cheek', + 'cheeky', + 'excited', + 'feliz', + 'heureux', + 'cheerful', + 'delighted', + 'ecstatic', + 'elated', + 'glad', + 'joy', + 'merry', + 'body part', + 'anatomy', + 'hand', + 'finger', + 'fingers', + 'awards', + 'prize', + 'prizes', + 'trophy', + 'trophies', + 'spot', + 'best', + 'champion', + 'hero', + 'hello', + 'greeting', + 'bonjour', + 'bye', + 'ciao', + 'adios', + 'goodbye', + 'hey', + 'holla', + 'my name is', + 'salut', + 'welcome', + 'ПРИВЕТ', + 'tu tapelle', + 'good luck', + 'lucky', + 'thanks', + 'thankful', + 'praise', + 'gracias', + 'merci', + 'thankyou', + 'acceptable', + 'perfecto', + 'perfection', + 'superb', + 'flawless', + 'excellent', + 'supreme', + 'super', + 'great', + 'okay', + 'got it', + 'cool', + 'ok', + 'will do', + 'like', + 'bien', + 'yep', + 'yup', + 'good job', + 'nice', + 'well done', + 'bravo', + 'congratulations', + 'congrats', + 'cute', + 'pretty', + 'adorable', + 'adore', + 'beauty', + 'cutie', + 'babe', + 'lovely', + 'passing grade' + ], + modifiable: true), + Emoji( + name: 'thumbs up: medium-dark skin tone', + char: '\u{1F44D}\u{1F3FE}', + shortName: 'thumbsup_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.handFingersClosed, + keywords: [ + '+1', + 'hand', + 'medium-dark skin tone', + 'thumb', + 'up', + 'uc8', + 'diversity', + 'happy', + 'body', + 'hands', + 'award', + 'hi', + 'luck', + 'thank you', + 'perfect', + 'awesome', + 'good', + 'beautiful', + 'correct', + 'fun', + 'proud', + 'language', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'hooray', + 'cheek', + 'cheeky', + 'excited', + 'feliz', + 'heureux', + 'cheerful', + 'delighted', + 'ecstatic', + 'elated', + 'glad', + 'joy', + 'merry', + 'body part', + 'anatomy', + 'hand', + 'finger', + 'fingers', + 'awards', + 'prize', + 'prizes', + 'trophy', + 'trophies', + 'spot', + 'best', + 'champion', + 'hero', + 'hello', + 'greeting', + 'bonjour', + 'bye', + 'ciao', + 'adios', + 'goodbye', + 'hey', + 'holla', + 'my name is', + 'salut', + 'welcome', + 'ПРИВЕТ', + 'tu tapelle', + 'good luck', + 'lucky', + 'thanks', + 'thankful', + 'praise', + 'gracias', + 'merci', + 'thankyou', + 'acceptable', + 'perfecto', + 'perfection', + 'superb', + 'flawless', + 'excellent', + 'supreme', + 'super', + 'great', + 'okay', + 'got it', + 'cool', + 'ok', + 'will do', + 'like', + 'bien', + 'yep', + 'yup', + 'good job', + 'nice', + 'well done', + 'bravo', + 'congratulations', + 'congrats', + 'cute', + 'pretty', + 'adorable', + 'adore', + 'beauty', + 'cutie', + 'babe', + 'lovely', + 'passing grade' + ], + modifiable: true), + Emoji( + name: 'thumbs up: dark skin tone', + char: '\u{1F44D}\u{1F3FF}', + shortName: 'thumbsup_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.handFingersClosed, + keywords: [ + '+1', + 'dark skin tone', + 'hand', + 'thumb', + 'up', + 'uc8', + 'diversity', + 'happy', + 'body', + 'hands', + 'award', + 'hi', + 'luck', + 'thank you', + 'perfect', + 'awesome', + 'good', + 'beautiful', + 'correct', + 'fun', + 'proud', + 'language', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'hooray', + 'cheek', + 'cheeky', + 'excited', + 'feliz', + 'heureux', + 'cheerful', + 'delighted', + 'ecstatic', + 'elated', + 'glad', + 'joy', + 'merry', + 'body part', + 'anatomy', + 'hand', + 'finger', + 'fingers', + 'awards', + 'prize', + 'prizes', + 'trophy', + 'trophies', + 'spot', + 'best', + 'champion', + 'hero', + 'hello', + 'greeting', + 'bonjour', + 'bye', + 'ciao', + 'adios', + 'goodbye', + 'hey', + 'holla', + 'my name is', + 'salut', + 'welcome', + 'ПРИВЕТ', + 'tu tapelle', + 'good luck', + 'lucky', + 'thanks', + 'thankful', + 'praise', + 'gracias', + 'merci', + 'thankyou', + 'acceptable', + 'perfecto', + 'perfection', + 'superb', + 'flawless', + 'excellent', + 'supreme', + 'super', + 'great', + 'okay', + 'got it', + 'cool', + 'ok', + 'will do', + 'like', + 'bien', + 'yep', + 'yup', + 'good job', + 'nice', + 'well done', + 'bravo', + 'congratulations', + 'congrats', + 'cute', + 'pretty', + 'adorable', + 'adore', + 'beauty', + 'cutie', + 'babe', + 'lovely', + 'passing grade' + ], + modifiable: true), + Emoji( + name: 'thumbs down', + char: '\u{1F44E}', + shortName: 'thumbsdown', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.handFingersClosed, + keywords: [ + '-1', + 'down', + 'hand', + 'thumb', + 'uc6', + 'diversity', + 'sad', + 'body', + 'hands', + 'language', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'triste', + 'depression', + 'negative', + 'sadness', + 'body part', + 'anatomy', + 'hand', + 'finger', + 'fingers' + ]), + Emoji( + name: 'thumbs down: light skin tone', + char: '\u{1F44E}\u{1F3FB}', + shortName: 'thumbsdown_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.handFingersClosed, + keywords: [ + '-1', + 'down', + 'hand', + 'light skin tone', + 'thumb', + 'uc8', + 'diversity', + 'sad', + 'body', + 'hands', + 'language', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'triste', + 'depression', + 'negative', + 'sadness', + 'body part', + 'anatomy', + 'hand', + 'finger', + 'fingers' + ], + modifiable: true), + Emoji( + name: 'thumbs down: medium-light skin tone', + char: '\u{1F44E}\u{1F3FC}', + shortName: 'thumbsdown_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.handFingersClosed, + keywords: [ + '-1', + 'down', + 'hand', + 'medium-light skin tone', + 'thumb', + 'uc8', + 'diversity', + 'sad', + 'body', + 'hands', + 'language', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'triste', + 'depression', + 'negative', + 'sadness', + 'body part', + 'anatomy', + 'hand', + 'finger', + 'fingers' + ], + modifiable: true), + Emoji( + name: 'thumbs down: medium skin tone', + char: '\u{1F44E}\u{1F3FD}', + shortName: 'thumbsdown_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.handFingersClosed, + keywords: [ + '-1', + 'down', + 'hand', + 'medium skin tone', + 'thumb', + 'uc8', + 'diversity', + 'sad', + 'body', + 'hands', + 'language', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'triste', + 'depression', + 'negative', + 'sadness', + 'body part', + 'anatomy', + 'hand', + 'finger', + 'fingers' + ], + modifiable: true), + Emoji( + name: 'thumbs down: medium-dark skin tone', + char: '\u{1F44E}\u{1F3FE}', + shortName: 'thumbsdown_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.handFingersClosed, + keywords: [ + '-1', + 'down', + 'hand', + 'medium-dark skin tone', + 'thumb', + 'uc8', + 'diversity', + 'sad', + 'body', + 'hands', + 'language', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'triste', + 'depression', + 'negative', + 'sadness', + 'body part', + 'anatomy', + 'hand', + 'finger', + 'fingers' + ], + modifiable: true), + Emoji( + name: 'thumbs down: dark skin tone', + char: '\u{1F44E}\u{1F3FF}', + shortName: 'thumbsdown_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.handFingersClosed, + keywords: [ + '-1', + 'dark skin tone', + 'down', + 'hand', + 'thumb', + 'uc8', + 'diversity', + 'sad', + 'body', + 'hands', + 'language', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'triste', + 'depression', + 'negative', + 'sadness', + 'body part', + 'anatomy', + 'hand', + 'finger', + 'fingers' + ], + modifiable: true), + Emoji( + name: 'oncoming fist', + char: '\u{1F44A}', + shortName: 'punch', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.handFingersClosed, + keywords: [ + 'clenched', + 'fist', + 'hand', + 'punch', + 'uc6', + 'diversity', + 'body', + 'hands', + 'hi', + 'fist bump', + 'win', + 'awesome', + 'boys night', + 'friend', + 'fight', + 'hit', + 'proud', + 'language', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'body part', + 'anatomy', + 'hand', + 'finger', + 'fingers', + 'hello', + 'greeting', + 'bonjour', + 'bye', + 'ciao', + 'adios', + 'goodbye', + 'hey', + 'holla', + 'my name is', + 'salut', + 'welcome', + 'ПРИВЕТ', + 'tu tapelle', + 'fist', + 'winning', + 'killing it', + 'crushing it', + 'victory', + 'victorious', + 'success', + 'successful', + 'winner', + 'okay', + 'got it', + 'cool', + 'ok', + 'will do', + 'like', + 'bien', + 'yep', + 'yup', + 'guys night', + 'friends', + 'friendship', + 'best friends', + 'bestfriends', + 'ami', + 'amiga', + 'amigo', + 'punch', + 'pow', + 'bam' + ]), + Emoji( + name: 'oncoming fist: light skin tone', + char: '\u{1F44A}\u{1F3FB}', + shortName: 'punch_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.handFingersClosed, + keywords: [ + 'clenched', + 'fist', + 'hand', + 'light skin tone', + 'punch', + 'uc8', + 'diversity', + 'body', + 'hands', + 'hi', + 'fist bump', + 'win', + 'awesome', + 'boys night', + 'friend', + 'fight', + 'hit', + 'proud', + 'language', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'body part', + 'anatomy', + 'hand', + 'finger', + 'fingers', + 'hello', + 'greeting', + 'bonjour', + 'bye', + 'ciao', + 'adios', + 'goodbye', + 'hey', + 'holla', + 'my name is', + 'salut', + 'welcome', + 'ПРИВЕТ', + 'tu tapelle', + 'fist', + 'winning', + 'killing it', + 'crushing it', + 'victory', + 'victorious', + 'success', + 'successful', + 'winner', + 'okay', + 'got it', + 'cool', + 'ok', + 'will do', + 'like', + 'bien', + 'yep', + 'yup', + 'guys night', + 'friends', + 'friendship', + 'best friends', + 'bestfriends', + 'ami', + 'amiga', + 'amigo', + 'punch', + 'pow', + 'bam' + ], + modifiable: true), + Emoji( + name: 'oncoming fist: medium-light skin tone', + char: '\u{1F44A}\u{1F3FC}', + shortName: 'punch_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.handFingersClosed, + keywords: [ + 'clenched', + 'fist', + 'hand', + 'medium-light skin tone', + 'punch', + 'uc8', + 'diversity', + 'body', + 'hands', + 'hi', + 'fist bump', + 'win', + 'awesome', + 'boys night', + 'friend', + 'fight', + 'hit', + 'proud', + 'language', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'body part', + 'anatomy', + 'hand', + 'finger', + 'fingers', + 'hello', + 'greeting', + 'bonjour', + 'bye', + 'ciao', + 'adios', + 'goodbye', + 'hey', + 'holla', + 'my name is', + 'salut', + 'welcome', + 'ПРИВЕТ', + 'tu tapelle', + 'fist', + 'winning', + 'killing it', + 'crushing it', + 'victory', + 'victorious', + 'success', + 'successful', + 'winner', + 'okay', + 'got it', + 'cool', + 'ok', + 'will do', + 'like', + 'bien', + 'yep', + 'yup', + 'guys night', + 'friends', + 'friendship', + 'best friends', + 'bestfriends', + 'ami', + 'amiga', + 'amigo', + 'punch', + 'pow', + 'bam' + ], + modifiable: true), + Emoji( + name: 'oncoming fist: medium skin tone', + char: '\u{1F44A}\u{1F3FD}', + shortName: 'punch_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.handFingersClosed, + keywords: [ + 'clenched', + 'fist', + 'hand', + 'medium skin tone', + 'punch', + 'uc8', + 'diversity', + 'body', + 'hands', + 'hi', + 'fist bump', + 'win', + 'awesome', + 'boys night', + 'friend', + 'fight', + 'hit', + 'proud', + 'language', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'body part', + 'anatomy', + 'hand', + 'finger', + 'fingers', + 'hello', + 'greeting', + 'bonjour', + 'bye', + 'ciao', + 'adios', + 'goodbye', + 'hey', + 'holla', + 'my name is', + 'salut', + 'welcome', + 'ПРИВЕТ', + 'tu tapelle', + 'fist', + 'winning', + 'killing it', + 'crushing it', + 'victory', + 'victorious', + 'success', + 'successful', + 'winner', + 'okay', + 'got it', + 'cool', + 'ok', + 'will do', + 'like', + 'bien', + 'yep', + 'yup', + 'guys night', + 'friends', + 'friendship', + 'best friends', + 'bestfriends', + 'ami', + 'amiga', + 'amigo', + 'punch', + 'pow', + 'bam' + ], + modifiable: true), + Emoji( + name: 'oncoming fist: medium-dark skin tone', + char: '\u{1F44A}\u{1F3FE}', + shortName: 'punch_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.handFingersClosed, + keywords: [ + 'clenched', + 'fist', + 'hand', + 'medium-dark skin tone', + 'punch', + 'uc8', + 'diversity', + 'body', + 'hands', + 'hi', + 'fist bump', + 'win', + 'awesome', + 'boys night', + 'friend', + 'fight', + 'hit', + 'proud', + 'language', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'body part', + 'anatomy', + 'hand', + 'finger', + 'fingers', + 'hello', + 'greeting', + 'bonjour', + 'bye', + 'ciao', + 'adios', + 'goodbye', + 'hey', + 'holla', + 'my name is', + 'salut', + 'welcome', + 'ПРИВЕТ', + 'tu tapelle', + 'fist', + 'winning', + 'killing it', + 'crushing it', + 'victory', + 'victorious', + 'success', + 'successful', + 'winner', + 'okay', + 'got it', + 'cool', + 'ok', + 'will do', + 'like', + 'bien', + 'yep', + 'yup', + 'guys night', + 'friends', + 'friendship', + 'best friends', + 'bestfriends', + 'ami', + 'amiga', + 'amigo', + 'punch', + 'pow', + 'bam' + ], + modifiable: true), + Emoji( + name: 'oncoming fist: dark skin tone', + char: '\u{1F44A}\u{1F3FF}', + shortName: 'punch_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.handFingersClosed, + keywords: [ + 'clenched', + 'dark skin tone', + 'fist', + 'hand', + 'punch', + 'uc8', + 'diversity', + 'body', + 'hands', + 'hi', + 'fist bump', + 'win', + 'awesome', + 'boys night', + 'friend', + 'fight', + 'hit', + 'proud', + 'language', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'body part', + 'anatomy', + 'hand', + 'finger', + 'fingers', + 'hello', + 'greeting', + 'bonjour', + 'bye', + 'ciao', + 'adios', + 'goodbye', + 'hey', + 'holla', + 'my name is', + 'salut', + 'welcome', + 'ПРИВЕТ', + 'tu tapelle', + 'fist', + 'winning', + 'killing it', + 'crushing it', + 'victory', + 'victorious', + 'success', + 'successful', + 'winner', + 'okay', + 'got it', + 'cool', + 'ok', + 'will do', + 'like', + 'bien', + 'yep', + 'yup', + 'guys night', + 'friends', + 'friendship', + 'best friends', + 'bestfriends', + 'ami', + 'amiga', + 'amigo', + 'punch', + 'pow', + 'bam' + ], + modifiable: true), + Emoji( + name: 'raised fist', + char: '\u{270A}', + shortName: 'fist', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.handFingersClosed, + keywords: [ + 'clenched', + 'fist', + 'hand', + 'punch', + 'uc6', + 'diversity', + 'body', + 'hands', + 'hi', + 'fist bump', + 'win', + 'condolence', + 'proud', + 'language', + 'protest', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'body part', + 'anatomy', + 'hand', + 'finger', + 'fingers', + 'hello', + 'greeting', + 'bonjour', + 'bye', + 'ciao', + 'adios', + 'goodbye', + 'hey', + 'holla', + 'my name is', + 'salut', + 'welcome', + 'ПРИВЕТ', + 'tu tapelle', + 'fist', + 'winning', + 'killing it', + 'crushing it', + 'victory', + 'victorious', + 'success', + 'successful', + 'winner', + 'compassion', + 'blm', + 'demonstration' + ]), + Emoji( + name: 'raised fist: light skin tone', + char: '\u{270A}\u{1F3FB}', + shortName: 'fist_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.handFingersClosed, + keywords: [ + 'clenched', + 'fist', + 'hand', + 'light skin tone', + 'punch', + 'uc8', + 'diversity', + 'body', + 'hands', + 'hi', + 'fist bump', + 'win', + 'condolence', + 'proud', + 'language', + 'protest', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'body part', + 'anatomy', + 'hand', + 'finger', + 'fingers', + 'hello', + 'greeting', + 'bonjour', + 'bye', + 'ciao', + 'adios', + 'goodbye', + 'hey', + 'holla', + 'my name is', + 'salut', + 'welcome', + 'ПРИВЕТ', + 'tu tapelle', + 'fist', + 'winning', + 'killing it', + 'crushing it', + 'victory', + 'victorious', + 'success', + 'successful', + 'winner', + 'compassion', + 'blm', + 'demonstration' + ], + modifiable: true), + Emoji( + name: 'raised fist: medium-light skin tone', + char: '\u{270A}\u{1F3FC}', + shortName: 'fist_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.handFingersClosed, + keywords: [ + 'clenched', + 'fist', + 'hand', + 'medium-light skin tone', + 'punch', + 'uc8', + 'diversity', + 'body', + 'hands', + 'hi', + 'fist bump', + 'win', + 'condolence', + 'proud', + 'language', + 'protest', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'body part', + 'anatomy', + 'hand', + 'finger', + 'fingers', + 'hello', + 'greeting', + 'bonjour', + 'bye', + 'ciao', + 'adios', + 'goodbye', + 'hey', + 'holla', + 'my name is', + 'salut', + 'welcome', + 'ПРИВЕТ', + 'tu tapelle', + 'fist', + 'winning', + 'killing it', + 'crushing it', + 'victory', + 'victorious', + 'success', + 'successful', + 'winner', + 'compassion', + 'blm', + 'demonstration' + ], + modifiable: true), + Emoji( + name: 'raised fist: medium skin tone', + char: '\u{270A}\u{1F3FD}', + shortName: 'fist_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.handFingersClosed, + keywords: [ + 'clenched', + 'fist', + 'hand', + 'medium skin tone', + 'punch', + 'uc8', + 'diversity', + 'body', + 'hands', + 'hi', + 'fist bump', + 'win', + 'condolence', + 'proud', + 'language', + 'protest', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'body part', + 'anatomy', + 'hand', + 'finger', + 'fingers', + 'hello', + 'greeting', + 'bonjour', + 'bye', + 'ciao', + 'adios', + 'goodbye', + 'hey', + 'holla', + 'my name is', + 'salut', + 'welcome', + 'ПРИВЕТ', + 'tu tapelle', + 'fist', + 'winning', + 'killing it', + 'crushing it', + 'victory', + 'victorious', + 'success', + 'successful', + 'winner', + 'compassion', + 'blm', + 'demonstration' + ], + modifiable: true), + Emoji( + name: 'raised fist: medium-dark skin tone', + char: '\u{270A}\u{1F3FE}', + shortName: 'fist_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.handFingersClosed, + keywords: [ + 'clenched', + 'fist', + 'hand', + 'medium-dark skin tone', + 'punch', + 'uc8', + 'diversity', + 'body', + 'hands', + 'hi', + 'fist bump', + 'win', + 'condolence', + 'proud', + 'language', + 'protest', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'body part', + 'anatomy', + 'hand', + 'finger', + 'fingers', + 'hello', + 'greeting', + 'bonjour', + 'bye', + 'ciao', + 'adios', + 'goodbye', + 'hey', + 'holla', + 'my name is', + 'salut', + 'welcome', + 'ПРИВЕТ', + 'tu tapelle', + 'fist', + 'winning', + 'killing it', + 'crushing it', + 'victory', + 'victorious', + 'success', + 'successful', + 'winner', + 'compassion', + 'blm', + 'demonstration' + ], + modifiable: true), + Emoji( + name: 'raised fist: dark skin tone', + char: '\u{270A}\u{1F3FF}', + shortName: 'fist_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.handFingersClosed, + keywords: [ + 'clenched', + 'dark skin tone', + 'fist', + 'hand', + 'punch', + 'uc8', + 'diversity', + 'body', + 'hands', + 'hi', + 'fist bump', + 'win', + 'condolence', + 'proud', + 'language', + 'protest', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'body part', + 'anatomy', + 'hand', + 'finger', + 'fingers', + 'hello', + 'greeting', + 'bonjour', + 'bye', + 'ciao', + 'adios', + 'goodbye', + 'hey', + 'holla', + 'my name is', + 'salut', + 'welcome', + 'ПРИВЕТ', + 'tu tapelle', + 'fist', + 'winning', + 'killing it', + 'crushing it', + 'victory', + 'victorious', + 'success', + 'successful', + 'winner', + 'compassion', + 'blm', + 'demonstration' + ], + modifiable: true), + Emoji( + name: 'left-facing fist', + char: '\u{1F91B}', + shortName: 'left_facing_fist', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.handFingersClosed, + keywords: [ + 'fist', + 'leftwards', + 'uc9', + 'diversity', + 'body', + 'hands', + 'hi', + 'fist bump', + 'win', + 'friend', + 'hit', + 'language', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'body part', + 'anatomy', + 'hand', + 'finger', + 'fingers', + 'hello', + 'greeting', + 'bonjour', + 'bye', + 'ciao', + 'adios', + 'goodbye', + 'hey', + 'holla', + 'my name is', + 'salut', + 'welcome', + 'ПРИВЕТ', + 'tu tapelle', + 'fist', + 'winning', + 'killing it', + 'crushing it', + 'victory', + 'victorious', + 'success', + 'successful', + 'winner', + 'friends', + 'friendship', + 'best friends', + 'bestfriends', + 'ami', + 'amiga', + 'amigo', + 'punch', + 'pow', + 'bam' + ]), + Emoji( + name: 'left-facing fist: light skin tone', + char: '\u{1F91B}\u{1F3FB}', + shortName: 'left_facing_fist_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.handFingersClosed, + keywords: [ + 'fist', + 'leftwards', + 'light skin tone', + 'uc9', + 'diversity', + 'body', + 'hands', + 'hi', + 'fist bump', + 'win', + 'friend', + 'hit', + 'language', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'body part', + 'anatomy', + 'hand', + 'finger', + 'fingers', + 'hello', + 'greeting', + 'bonjour', + 'bye', + 'ciao', + 'adios', + 'goodbye', + 'hey', + 'holla', + 'my name is', + 'salut', + 'welcome', + 'ПРИВЕТ', + 'tu tapelle', + 'fist', + 'winning', + 'killing it', + 'crushing it', + 'victory', + 'victorious', + 'success', + 'successful', + 'winner', + 'friends', + 'friendship', + 'best friends', + 'bestfriends', + 'ami', + 'amiga', + 'amigo', + 'punch', + 'pow', + 'bam' + ], + modifiable: true), + Emoji( + name: 'left-facing fist: medium-light skin tone', + char: '\u{1F91B}\u{1F3FC}', + shortName: 'left_facing_fist_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.handFingersClosed, + keywords: [ + 'fist', + 'leftwards', + 'medium-light skin tone', + 'uc9', + 'diversity', + 'body', + 'hands', + 'hi', + 'fist bump', + 'win', + 'friend', + 'hit', + 'language', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'body part', + 'anatomy', + 'hand', + 'finger', + 'fingers', + 'hello', + 'greeting', + 'bonjour', + 'bye', + 'ciao', + 'adios', + 'goodbye', + 'hey', + 'holla', + 'my name is', + 'salut', + 'welcome', + 'ПРИВЕТ', + 'tu tapelle', + 'fist', + 'winning', + 'killing it', + 'crushing it', + 'victory', + 'victorious', + 'success', + 'successful', + 'winner', + 'friends', + 'friendship', + 'best friends', + 'bestfriends', + 'ami', + 'amiga', + 'amigo', + 'punch', + 'pow', + 'bam' + ], + modifiable: true), + Emoji( + name: 'left-facing fist: medium skin tone', + char: '\u{1F91B}\u{1F3FD}', + shortName: 'left_facing_fist_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.handFingersClosed, + keywords: [ + 'fist', + 'leftwards', + 'medium skin tone', + 'uc9', + 'diversity', + 'body', + 'hands', + 'hi', + 'fist bump', + 'win', + 'friend', + 'hit', + 'language', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'body part', + 'anatomy', + 'hand', + 'finger', + 'fingers', + 'hello', + 'greeting', + 'bonjour', + 'bye', + 'ciao', + 'adios', + 'goodbye', + 'hey', + 'holla', + 'my name is', + 'salut', + 'welcome', + 'ПРИВЕТ', + 'tu tapelle', + 'fist', + 'winning', + 'killing it', + 'crushing it', + 'victory', + 'victorious', + 'success', + 'successful', + 'winner', + 'friends', + 'friendship', + 'best friends', + 'bestfriends', + 'ami', + 'amiga', + 'amigo', + 'punch', + 'pow', + 'bam' + ], + modifiable: true), + Emoji( + name: 'left-facing fist: medium-dark skin tone', + char: '\u{1F91B}\u{1F3FE}', + shortName: 'left_facing_fist_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.handFingersClosed, + keywords: [ + 'fist', + 'leftwards', + 'medium-dark skin tone', + 'uc9', + 'diversity', + 'body', + 'hands', + 'hi', + 'fist bump', + 'win', + 'friend', + 'hit', + 'language', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'body part', + 'anatomy', + 'hand', + 'finger', + 'fingers', + 'hello', + 'greeting', + 'bonjour', + 'bye', + 'ciao', + 'adios', + 'goodbye', + 'hey', + 'holla', + 'my name is', + 'salut', + 'welcome', + 'ПРИВЕТ', + 'tu tapelle', + 'fist', + 'winning', + 'killing it', + 'crushing it', + 'victory', + 'victorious', + 'success', + 'successful', + 'winner', + 'friends', + 'friendship', + 'best friends', + 'bestfriends', + 'ami', + 'amiga', + 'amigo', + 'punch', + 'pow', + 'bam' + ], + modifiable: true), + Emoji( + name: 'left-facing fist: dark skin tone', + char: '\u{1F91B}\u{1F3FF}', + shortName: 'left_facing_fist_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.handFingersClosed, + keywords: [ + 'dark skin tone', + 'fist', + 'leftwards', + 'uc9', + 'diversity', + 'body', + 'hands', + 'hi', + 'fist bump', + 'win', + 'friend', + 'hit', + 'language', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'body part', + 'anatomy', + 'hand', + 'finger', + 'fingers', + 'hello', + 'greeting', + 'bonjour', + 'bye', + 'ciao', + 'adios', + 'goodbye', + 'hey', + 'holla', + 'my name is', + 'salut', + 'welcome', + 'ПРИВЕТ', + 'tu tapelle', + 'fist', + 'winning', + 'killing it', + 'crushing it', + 'victory', + 'victorious', + 'success', + 'successful', + 'winner', + 'friends', + 'friendship', + 'best friends', + 'bestfriends', + 'ami', + 'amiga', + 'amigo', + 'punch', + 'pow', + 'bam' + ], + modifiable: true), + Emoji( + name: 'right-facing fist', + char: '\u{1F91C}', + shortName: 'right_facing_fist', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.handFingersClosed, + keywords: [ + 'fist', + 'rightwards', + 'uc9', + 'diversity', + 'body', + 'hands', + 'hi', + 'fist bump', + 'friend', + 'hit', + 'language', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'body part', + 'anatomy', + 'hand', + 'finger', + 'fingers', + 'hello', + 'greeting', + 'bonjour', + 'bye', + 'ciao', + 'adios', + 'goodbye', + 'hey', + 'holla', + 'my name is', + 'salut', + 'welcome', + 'ПРИВЕТ', + 'tu tapelle', + 'fist', + 'friends', + 'friendship', + 'best friends', + 'bestfriends', + 'ami', + 'amiga', + 'amigo', + 'punch', + 'pow', + 'bam' + ]), + Emoji( + name: 'right-facing fist: light skin tone', + char: '\u{1F91C}\u{1F3FB}', + shortName: 'right_facing_fist_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.handFingersClosed, + keywords: [ + 'fist', + 'light skin tone', + 'rightwards', + 'uc9', + 'diversity', + 'body', + 'hands', + 'hi', + 'fist bump', + 'friend', + 'hit', + 'language', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'body part', + 'anatomy', + 'hand', + 'finger', + 'fingers', + 'hello', + 'greeting', + 'bonjour', + 'bye', + 'ciao', + 'adios', + 'goodbye', + 'hey', + 'holla', + 'my name is', + 'salut', + 'welcome', + 'ПРИВЕТ', + 'tu tapelle', + 'fist', + 'friends', + 'friendship', + 'best friends', + 'bestfriends', + 'ami', + 'amiga', + 'amigo', + 'punch', + 'pow', + 'bam' + ], + modifiable: true), + Emoji( + name: 'right-facing fist: medium-light skin tone', + char: '\u{1F91C}\u{1F3FC}', + shortName: 'right_facing_fist_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.handFingersClosed, + keywords: [ + 'fist', + 'medium-light skin tone', + 'rightwards', + 'uc9', + 'diversity', + 'body', + 'hands', + 'hi', + 'fist bump', + 'friend', + 'hit', + 'language', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'body part', + 'anatomy', + 'hand', + 'finger', + 'fingers', + 'hello', + 'greeting', + 'bonjour', + 'bye', + 'ciao', + 'adios', + 'goodbye', + 'hey', + 'holla', + 'my name is', + 'salut', + 'welcome', + 'ПРИВЕТ', + 'tu tapelle', + 'fist', + 'friends', + 'friendship', + 'best friends', + 'bestfriends', + 'ami', + 'amiga', + 'amigo', + 'punch', + 'pow', + 'bam' + ], + modifiable: true), + Emoji( + name: 'right-facing fist: medium skin tone', + char: '\u{1F91C}\u{1F3FD}', + shortName: 'right_facing_fist_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.handFingersClosed, + keywords: [ + 'fist', + 'medium skin tone', + 'rightwards', + 'uc9', + 'diversity', + 'body', + 'hands', + 'hi', + 'fist bump', + 'friend', + 'hit', + 'language', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'body part', + 'anatomy', + 'hand', + 'finger', + 'fingers', + 'hello', + 'greeting', + 'bonjour', + 'bye', + 'ciao', + 'adios', + 'goodbye', + 'hey', + 'holla', + 'my name is', + 'salut', + 'welcome', + 'ПРИВЕТ', + 'tu tapelle', + 'fist', + 'friends', + 'friendship', + 'best friends', + 'bestfriends', + 'ami', + 'amiga', + 'amigo', + 'punch', + 'pow', + 'bam' + ], + modifiable: true), + Emoji( + name: 'right-facing fist: medium-dark skin tone', + char: '\u{1F91C}\u{1F3FE}', + shortName: 'right_facing_fist_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.handFingersClosed, + keywords: [ + 'fist', + 'medium-dark skin tone', + 'rightwards', + 'uc9', + 'diversity', + 'body', + 'hands', + 'hi', + 'fist bump', + 'friend', + 'hit', + 'language', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'body part', + 'anatomy', + 'hand', + 'finger', + 'fingers', + 'hello', + 'greeting', + 'bonjour', + 'bye', + 'ciao', + 'adios', + 'goodbye', + 'hey', + 'holla', + 'my name is', + 'salut', + 'welcome', + 'ПРИВЕТ', + 'tu tapelle', + 'fist', + 'friends', + 'friendship', + 'best friends', + 'bestfriends', + 'ami', + 'amiga', + 'amigo', + 'punch', + 'pow', + 'bam' + ], + modifiable: true), + Emoji( + name: 'right-facing fist: dark skin tone', + char: '\u{1F91C}\u{1F3FF}', + shortName: 'right_facing_fist_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.handFingersClosed, + keywords: [ + 'dark skin tone', + 'fist', + 'rightwards', + 'uc9', + 'diversity', + 'body', + 'hands', + 'hi', + 'fist bump', + 'friend', + 'hit', + 'language', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'body part', + 'anatomy', + 'hand', + 'finger', + 'fingers', + 'hello', + 'greeting', + 'bonjour', + 'bye', + 'ciao', + 'adios', + 'goodbye', + 'hey', + 'holla', + 'my name is', + 'salut', + 'welcome', + 'ПРИВЕТ', + 'tu tapelle', + 'fist', + 'friends', + 'friendship', + 'best friends', + 'bestfriends', + 'ami', + 'amiga', + 'amigo', + 'punch', + 'pow', + 'bam' + ], + modifiable: true), + Emoji( + name: 'crossed fingers', + char: '\u{1F91E}', + shortName: 'fingers_crossed', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.handFingersPartial, + keywords: [ + 'cross', + 'finger', + 'hand', + 'luck', + 'uc9', + 'diversity', + 'body', + 'hands', + 'donald trump', + 'irish', + 'hope', + 'language', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'body part', + 'anatomy', + 'hand', + 'finger', + 'fingers', + 'trump', + 'saint patricks day', + 'st patricks day', + 'leprechaun', + 'swear', + 'promise' + ]), + Emoji( + name: 'crossed fingers: light skin tone', + char: '\u{1F91E}\u{1F3FB}', + shortName: 'fingers_crossed_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.handFingersPartial, + keywords: [ + 'cross', + 'finger', + 'hand', + 'light skin tone', + 'luck', + 'uc9', + 'diversity', + 'body', + 'hands', + 'donald trump', + 'irish', + 'hope', + 'language', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'body part', + 'anatomy', + 'hand', + 'finger', + 'fingers', + 'trump', + 'saint patricks day', + 'st patricks day', + 'leprechaun', + 'swear', + 'promise' + ], + modifiable: true), + Emoji( + name: 'crossed fingers: medium-light skin tone', + char: '\u{1F91E}\u{1F3FC}', + shortName: 'fingers_crossed_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.handFingersPartial, + keywords: [ + 'cross', + 'finger', + 'hand', + 'luck', + 'medium-light skin tone', + 'uc9', + 'diversity', + 'body', + 'hands', + 'donald trump', + 'irish', + 'hope', + 'language', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'body part', + 'anatomy', + 'hand', + 'finger', + 'fingers', + 'trump', + 'saint patricks day', + 'st patricks day', + 'leprechaun', + 'swear', + 'promise' + ], + modifiable: true), + Emoji( + name: 'crossed fingers: medium skin tone', + char: '\u{1F91E}\u{1F3FD}', + shortName: 'fingers_crossed_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.handFingersPartial, + keywords: [ + 'cross', + 'finger', + 'hand', + 'luck', + 'medium skin tone', + 'uc9', + 'diversity', + 'body', + 'hands', + 'donald trump', + 'irish', + 'hope', + 'language', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'body part', + 'anatomy', + 'hand', + 'finger', + 'fingers', + 'trump', + 'saint patricks day', + 'st patricks day', + 'leprechaun', + 'swear', + 'promise' + ], + modifiable: true), + Emoji( + name: 'crossed fingers: medium-dark skin tone', + char: '\u{1F91E}\u{1F3FE}', + shortName: 'fingers_crossed_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.handFingersPartial, + keywords: [ + 'cross', + 'finger', + 'hand', + 'luck', + 'medium-dark skin tone', + 'uc9', + 'diversity', + 'body', + 'hands', + 'donald trump', + 'irish', + 'hope', + 'language', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'body part', + 'anatomy', + 'hand', + 'finger', + 'fingers', + 'trump', + 'saint patricks day', + 'st patricks day', + 'leprechaun', + 'swear', + 'promise' + ], + modifiable: true), + Emoji( + name: 'crossed fingers: dark skin tone', + char: '\u{1F91E}\u{1F3FF}', + shortName: 'fingers_crossed_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.handFingersPartial, + keywords: [ + 'cross', + 'dark skin tone', + 'finger', + 'hand', + 'luck', + 'uc9', + 'diversity', + 'body', + 'hands', + 'donald trump', + 'irish', + 'hope', + 'language', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'body part', + 'anatomy', + 'hand', + 'finger', + 'fingers', + 'trump', + 'saint patricks day', + 'st patricks day', + 'leprechaun', + 'swear', + 'promise' + ], + modifiable: true), + Emoji( + name: 'victory hand', + char: '\u{270C}\u{FE0F}', + shortName: 'v', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.handFingersPartial, + keywords: [ + 'hand', + 'v', + 'victory', + 'uc1', + 'diversity', + 'peace', + 'body', + 'hands', + 'hi', + 'thank you', + 'girls night', + 'language', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'peace out', + 'peace sign', + 'body part', + 'anatomy', + 'hand', + 'finger', + 'fingers', + 'hello', + 'greeting', + 'bonjour', + 'bye', + 'ciao', + 'adios', + 'goodbye', + 'hey', + 'holla', + 'my name is', + 'salut', + 'welcome', + 'ПРИВЕТ', + 'tu tapelle', + 'thanks', + 'thankful', + 'praise', + 'gracias', + 'merci', + 'thankyou', + 'acceptable', + 'ladies night', + 'girls only', + 'girlfriend' + ]), + Emoji( + name: 'victory hand: light skin tone', + char: '\u{270C}\u{1F3FB}', + shortName: 'v_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.handFingersPartial, + keywords: [ + 'hand', + 'light skin tone', + 'v', + 'victory', + 'uc8', + 'diversity', + 'peace', + 'body', + 'hands', + 'hi', + 'thank you', + 'girls night', + 'language', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'peace out', + 'peace sign', + 'body part', + 'anatomy', + 'hand', + 'finger', + 'fingers', + 'hello', + 'greeting', + 'bonjour', + 'bye', + 'ciao', + 'adios', + 'goodbye', + 'hey', + 'holla', + 'my name is', + 'salut', + 'welcome', + 'ПРИВЕТ', + 'tu tapelle', + 'thanks', + 'thankful', + 'praise', + 'gracias', + 'merci', + 'thankyou', + 'acceptable', + 'ladies night', + 'girls only', + 'girlfriend' + ], + modifiable: true), + Emoji( + name: 'victory hand: medium-light skin tone', + char: '\u{270C}\u{1F3FC}', + shortName: 'v_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.handFingersPartial, + keywords: [ + 'hand', + 'medium-light skin tone', + 'v', + 'victory', + 'uc8', + 'diversity', + 'peace', + 'body', + 'hands', + 'hi', + 'thank you', + 'girls night', + 'language', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'peace out', + 'peace sign', + 'body part', + 'anatomy', + 'hand', + 'finger', + 'fingers', + 'hello', + 'greeting', + 'bonjour', + 'bye', + 'ciao', + 'adios', + 'goodbye', + 'hey', + 'holla', + 'my name is', + 'salut', + 'welcome', + 'ПРИВЕТ', + 'tu tapelle', + 'thanks', + 'thankful', + 'praise', + 'gracias', + 'merci', + 'thankyou', + 'acceptable', + 'ladies night', + 'girls only', + 'girlfriend' + ], + modifiable: true), + Emoji( + name: 'victory hand: medium skin tone', + char: '\u{270C}\u{1F3FD}', + shortName: 'v_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.handFingersPartial, + keywords: [ + 'hand', + 'medium skin tone', + 'v', + 'victory', + 'uc8', + 'diversity', + 'peace', + 'body', + 'hands', + 'hi', + 'thank you', + 'girls night', + 'language', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'peace out', + 'peace sign', + 'body part', + 'anatomy', + 'hand', + 'finger', + 'fingers', + 'hello', + 'greeting', + 'bonjour', + 'bye', + 'ciao', + 'adios', + 'goodbye', + 'hey', + 'holla', + 'my name is', + 'salut', + 'welcome', + 'ПРИВЕТ', + 'tu tapelle', + 'thanks', + 'thankful', + 'praise', + 'gracias', + 'merci', + 'thankyou', + 'acceptable', + 'ladies night', + 'girls only', + 'girlfriend' + ], + modifiable: true), + Emoji( + name: 'victory hand: medium-dark skin tone', + char: '\u{270C}\u{1F3FE}', + shortName: 'v_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.handFingersPartial, + keywords: [ + 'hand', + 'medium-dark skin tone', + 'v', + 'victory', + 'uc8', + 'diversity', + 'peace', + 'body', + 'hands', + 'hi', + 'thank you', + 'girls night', + 'language', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'peace out', + 'peace sign', + 'body part', + 'anatomy', + 'hand', + 'finger', + 'fingers', + 'hello', + 'greeting', + 'bonjour', + 'bye', + 'ciao', + 'adios', + 'goodbye', + 'hey', + 'holla', + 'my name is', + 'salut', + 'welcome', + 'ПРИВЕТ', + 'tu tapelle', + 'thanks', + 'thankful', + 'praise', + 'gracias', + 'merci', + 'thankyou', + 'acceptable', + 'ladies night', + 'girls only', + 'girlfriend' + ], + modifiable: true), + Emoji( + name: 'victory hand: dark skin tone', + char: '\u{270C}\u{1F3FF}', + shortName: 'v_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.handFingersPartial, + keywords: [ + 'dark skin tone', + 'hand', + 'v', + 'victory', + 'uc8', + 'diversity', + 'peace', + 'body', + 'hands', + 'hi', + 'thank you', + 'girls night', + 'language', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'peace out', + 'peace sign', + 'body part', + 'anatomy', + 'hand', + 'finger', + 'fingers', + 'hello', + 'greeting', + 'bonjour', + 'bye', + 'ciao', + 'adios', + 'goodbye', + 'hey', + 'holla', + 'my name is', + 'salut', + 'welcome', + 'ПРИВЕТ', + 'tu tapelle', + 'thanks', + 'thankful', + 'praise', + 'gracias', + 'merci', + 'thankyou', + 'acceptable', + 'ladies night', + 'girls only', + 'girlfriend' + ], + modifiable: true), + Emoji( + name: 'love-you gesture', + char: '\u{1F91F}', + shortName: 'love_you_gesture', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.handFingersPartial, + keywords: [ + 'ILY', + 'hand', + 'uc10', + 'diversity', + 'body', + 'hands', + 'love', + 'beautiful', + 'language', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'body part', + 'anatomy', + 'hand', + 'finger', + 'fingers', + 'i love you', + 'te amo', + "je t'aime", + 'anniversary', + 'lovin', + 'amour', + 'aimer', + 'amor', + 'valentines day', + 'enamour', + 'lovey', + 'cute', + 'pretty', + 'adorable', + 'adore', + 'beauty', + 'cutie', + 'babe', + 'lovely' + ]), + Emoji( + name: 'love-you gesture: light skin tone', + char: '\u{1F91F}\u{1F3FB}', + shortName: 'love_you_gesture_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.handFingersPartial, + keywords: [ + 'ILY', + 'hand', + 'light skin tone', + 'uc10', + 'diversity', + 'body', + 'hands', + 'love', + 'beautiful', + 'language', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'body part', + 'anatomy', + 'hand', + 'finger', + 'fingers', + 'i love you', + 'te amo', + "je t'aime", + 'anniversary', + 'lovin', + 'amour', + 'aimer', + 'amor', + 'valentines day', + 'enamour', + 'lovey', + 'cute', + 'pretty', + 'adorable', + 'adore', + 'beauty', + 'cutie', + 'babe', + 'lovely' + ], + modifiable: true), + Emoji( + name: 'love-you gesture: medium-light skin tone', + char: '\u{1F91F}\u{1F3FC}', + shortName: 'love_you_gesture_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.handFingersPartial, + keywords: [ + 'ILY', + 'hand', + 'medium-light skin tone', + 'uc10', + 'diversity', + 'body', + 'hands', + 'love', + 'beautiful', + 'language', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'body part', + 'anatomy', + 'hand', + 'finger', + 'fingers', + 'i love you', + 'te amo', + "je t'aime", + 'anniversary', + 'lovin', + 'amour', + 'aimer', + 'amor', + 'valentines day', + 'enamour', + 'lovey', + 'cute', + 'pretty', + 'adorable', + 'adore', + 'beauty', + 'cutie', + 'babe', + 'lovely' + ], + modifiable: true), + Emoji( + name: 'love-you gesture: medium skin tone', + char: '\u{1F91F}\u{1F3FD}', + shortName: 'love_you_gesture_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.handFingersPartial, + keywords: [ + 'ILY', + 'hand', + 'medium skin tone', + 'uc10', + 'diversity', + 'body', + 'hands', + 'love', + 'beautiful', + 'language', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'body part', + 'anatomy', + 'hand', + 'finger', + 'fingers', + 'i love you', + 'te amo', + "je t'aime", + 'anniversary', + 'lovin', + 'amour', + 'aimer', + 'amor', + 'valentines day', + 'enamour', + 'lovey', + 'cute', + 'pretty', + 'adorable', + 'adore', + 'beauty', + 'cutie', + 'babe', + 'lovely' + ], + modifiable: true), + Emoji( + name: 'love-you gesture: medium-dark skin tone', + char: '\u{1F91F}\u{1F3FE}', + shortName: 'love_you_gesture_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.handFingersPartial, + keywords: [ + 'ILY', + 'hand', + 'medium-dark skin tone', + 'uc10', + 'diversity', + 'body', + 'hands', + 'love', + 'beautiful', + 'language', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'body part', + 'anatomy', + 'hand', + 'finger', + 'fingers', + 'i love you', + 'te amo', + "je t'aime", + 'anniversary', + 'lovin', + 'amour', + 'aimer', + 'amor', + 'valentines day', + 'enamour', + 'lovey', + 'cute', + 'pretty', + 'adorable', + 'adore', + 'beauty', + 'cutie', + 'babe', + 'lovely' + ], + modifiable: true), + Emoji( + name: 'love-you gesture: dark skin tone', + char: '\u{1F91F}\u{1F3FF}', + shortName: 'love_you_gesture_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.handFingersPartial, + keywords: [ + 'ILY', + 'dark skin tone', + 'hand', + 'uc10', + 'diversity', + 'body', + 'hands', + 'love', + 'beautiful', + 'language', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'body part', + 'anatomy', + 'hand', + 'finger', + 'fingers', + 'i love you', + 'te amo', + "je t'aime", + 'anniversary', + 'lovin', + 'amour', + 'aimer', + 'amor', + 'valentines day', + 'enamour', + 'lovey', + 'cute', + 'pretty', + 'adorable', + 'adore', + 'beauty', + 'cutie', + 'babe', + 'lovely' + ], + modifiable: true), + Emoji( + name: 'sign of the horns', + char: '\u{1F918}', + shortName: 'metal', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.handFingersPartial, + keywords: [ + 'finger', + 'hand', + 'horns', + 'rock-on', + 'uc8', + 'diversity', + 'body', + 'hands', + 'hi', + 'boys night', + 'rock and roll', + 'language', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'body part', + 'anatomy', + 'hand', + 'finger', + 'fingers', + 'hello', + 'greeting', + 'bonjour', + 'bye', + 'ciao', + 'adios', + 'goodbye', + 'hey', + 'holla', + 'my name is', + 'salut', + 'welcome', + 'ПРИВЕТ', + 'tu tapelle', + 'guys night' + ]), + Emoji( + name: 'sign of the horns: light skin tone', + char: '\u{1F918}\u{1F3FB}', + shortName: 'metal_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.handFingersPartial, + keywords: [ + 'finger', + 'hand', + 'horns', + 'light skin tone', + 'rock-on', + 'uc8', + 'diversity', + 'body', + 'hands', + 'hi', + 'boys night', + 'rock and roll', + 'language', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'body part', + 'anatomy', + 'hand', + 'finger', + 'fingers', + 'hello', + 'greeting', + 'bonjour', + 'bye', + 'ciao', + 'adios', + 'goodbye', + 'hey', + 'holla', + 'my name is', + 'salut', + 'welcome', + 'ПРИВЕТ', + 'tu tapelle', + 'guys night' + ], + modifiable: true), + Emoji( + name: 'sign of the horns: medium-light skin tone', + char: '\u{1F918}\u{1F3FC}', + shortName: 'metal_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.handFingersPartial, + keywords: [ + 'finger', + 'hand', + 'horns', + 'medium-light skin tone', + 'rock-on', + 'uc8', + 'diversity', + 'body', + 'hands', + 'hi', + 'boys night', + 'rock and roll', + 'language', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'body part', + 'anatomy', + 'hand', + 'finger', + 'fingers', + 'hello', + 'greeting', + 'bonjour', + 'bye', + 'ciao', + 'adios', + 'goodbye', + 'hey', + 'holla', + 'my name is', + 'salut', + 'welcome', + 'ПРИВЕТ', + 'tu tapelle', + 'guys night' + ], + modifiable: true), + Emoji( + name: 'sign of the horns: medium skin tone', + char: '\u{1F918}\u{1F3FD}', + shortName: 'metal_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.handFingersPartial, + keywords: [ + 'finger', + 'hand', + 'horns', + 'medium skin tone', + 'rock-on', + 'uc8', + 'diversity', + 'body', + 'hands', + 'hi', + 'boys night', + 'rock and roll', + 'language', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'body part', + 'anatomy', + 'hand', + 'finger', + 'fingers', + 'hello', + 'greeting', + 'bonjour', + 'bye', + 'ciao', + 'adios', + 'goodbye', + 'hey', + 'holla', + 'my name is', + 'salut', + 'welcome', + 'ПРИВЕТ', + 'tu tapelle', + 'guys night' + ], + modifiable: true), + Emoji( + name: 'sign of the horns: medium-dark skin tone', + char: '\u{1F918}\u{1F3FE}', + shortName: 'metal_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.handFingersPartial, + keywords: [ + 'finger', + 'hand', + 'horns', + 'medium-dark skin tone', + 'rock-on', + 'uc8', + 'diversity', + 'body', + 'hands', + 'hi', + 'boys night', + 'rock and roll', + 'language', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'body part', + 'anatomy', + 'hand', + 'finger', + 'fingers', + 'hello', + 'greeting', + 'bonjour', + 'bye', + 'ciao', + 'adios', + 'goodbye', + 'hey', + 'holla', + 'my name is', + 'salut', + 'welcome', + 'ПРИВЕТ', + 'tu tapelle', + 'guys night' + ], + modifiable: true), + Emoji( + name: 'sign of the horns: dark skin tone', + char: '\u{1F918}\u{1F3FF}', + shortName: 'metal_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.handFingersPartial, + keywords: [ + 'dark skin tone', + 'finger', + 'hand', + 'horns', + 'rock-on', + 'uc8', + 'diversity', + 'body', + 'hands', + 'hi', + 'boys night', + 'rock and roll', + 'language', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'body part', + 'anatomy', + 'hand', + 'finger', + 'fingers', + 'hello', + 'greeting', + 'bonjour', + 'bye', + 'ciao', + 'adios', + 'goodbye', + 'hey', + 'holla', + 'my name is', + 'salut', + 'welcome', + 'ПРИВЕТ', + 'tu tapelle', + 'guys night' + ], + modifiable: true), + Emoji( + name: 'OK hand', + char: '\u{1F44C}', + shortName: 'ok_hand', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.handFingersPartial, + keywords: [ + 'OK', + 'hand', + 'uc6', + 'diversity', + 'happy', + 'butt', + 'body', + 'hands', + 'hi', + 'sex', + 'thank you', + 'perfect', + 'awesome', + 'good', + 'beautiful', + 'google', + 'correct', + 'porn', + 'fun', + 'language', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'hooray', + 'cheek', + 'cheeky', + 'excited', + 'feliz', + 'heureux', + 'cheerful', + 'delighted', + 'ecstatic', + 'elated', + 'glad', + 'joy', + 'merry', + 'ass', + 'booty', + 'body part', + 'anatomy', + 'hand', + 'finger', + 'fingers', + 'hello', + 'greeting', + 'bonjour', + 'bye', + 'ciao', + 'adios', + 'goodbye', + 'hey', + 'holla', + 'my name is', + 'salut', + 'welcome', + 'ПРИВЕТ', + 'tu tapelle', + 'fuck', + 'fucking', + 'horny', + 'humping', + 'thanks', + 'thankful', + 'praise', + 'gracias', + 'merci', + 'thankyou', + 'acceptable', + 'perfecto', + 'perfection', + 'superb', + 'flawless', + 'excellent', + 'supreme', + 'super', + 'great', + 'okay', + 'got it', + 'cool', + 'ok', + 'will do', + 'like', + 'bien', + 'yep', + 'yup', + 'good job', + 'nice', + 'well done', + 'bravo', + 'congratulations', + 'congrats', + 'cute', + 'pretty', + 'adorable', + 'adore', + 'beauty', + 'cutie', + 'babe', + 'lovely', + 'passing grade' + ]), + Emoji( + name: 'OK hand: light skin tone', + char: '\u{1F44C}\u{1F3FB}', + shortName: 'ok_hand_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.handFingersPartial, + keywords: [ + 'OK', + 'hand', + 'light skin tone', + 'uc8', + 'diversity', + 'happy', + 'butt', + 'body', + 'hands', + 'hi', + 'sex', + 'thank you', + 'perfect', + 'awesome', + 'good', + 'beautiful', + 'google', + 'correct', + 'porn', + 'fun', + 'language', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'hooray', + 'cheek', + 'cheeky', + 'excited', + 'feliz', + 'heureux', + 'cheerful', + 'delighted', + 'ecstatic', + 'elated', + 'glad', + 'joy', + 'merry', + 'ass', + 'booty', + 'body part', + 'anatomy', + 'hand', + 'finger', + 'fingers', + 'hello', + 'greeting', + 'bonjour', + 'bye', + 'ciao', + 'adios', + 'goodbye', + 'hey', + 'holla', + 'my name is', + 'salut', + 'welcome', + 'ПРИВЕТ', + 'tu tapelle', + 'fuck', + 'fucking', + 'horny', + 'humping', + 'thanks', + 'thankful', + 'praise', + 'gracias', + 'merci', + 'thankyou', + 'acceptable', + 'perfecto', + 'perfection', + 'superb', + 'flawless', + 'excellent', + 'supreme', + 'super', + 'great', + 'okay', + 'got it', + 'cool', + 'ok', + 'will do', + 'like', + 'bien', + 'yep', + 'yup', + 'good job', + 'nice', + 'well done', + 'bravo', + 'congratulations', + 'congrats', + 'cute', + 'pretty', + 'adorable', + 'adore', + 'beauty', + 'cutie', + 'babe', + 'lovely', + 'passing grade' + ], + modifiable: true), + Emoji( + name: 'OK hand: medium-light skin tone', + char: '\u{1F44C}\u{1F3FC}', + shortName: 'ok_hand_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.handFingersPartial, + keywords: [ + 'OK', + 'hand', + 'medium-light skin tone', + 'uc8', + 'diversity', + 'happy', + 'butt', + 'body', + 'hands', + 'hi', + 'sex', + 'thank you', + 'perfect', + 'awesome', + 'good', + 'beautiful', + 'google', + 'correct', + 'porn', + 'fun', + 'language', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'hooray', + 'cheek', + 'cheeky', + 'excited', + 'feliz', + 'heureux', + 'cheerful', + 'delighted', + 'ecstatic', + 'elated', + 'glad', + 'joy', + 'merry', + 'ass', + 'booty', + 'body part', + 'anatomy', + 'hand', + 'finger', + 'fingers', + 'hello', + 'greeting', + 'bonjour', + 'bye', + 'ciao', + 'adios', + 'goodbye', + 'hey', + 'holla', + 'my name is', + 'salut', + 'welcome', + 'ПРИВЕТ', + 'tu tapelle', + 'fuck', + 'fucking', + 'horny', + 'humping', + 'thanks', + 'thankful', + 'praise', + 'gracias', + 'merci', + 'thankyou', + 'acceptable', + 'perfecto', + 'perfection', + 'superb', + 'flawless', + 'excellent', + 'supreme', + 'super', + 'great', + 'okay', + 'got it', + 'cool', + 'ok', + 'will do', + 'like', + 'bien', + 'yep', + 'yup', + 'good job', + 'nice', + 'well done', + 'bravo', + 'congratulations', + 'congrats', + 'cute', + 'pretty', + 'adorable', + 'adore', + 'beauty', + 'cutie', + 'babe', + 'lovely', + 'passing grade' + ], + modifiable: true), + Emoji( + name: 'OK hand: medium skin tone', + char: '\u{1F44C}\u{1F3FD}', + shortName: 'ok_hand_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.handFingersPartial, + keywords: [ + 'OK', + 'hand', + 'medium skin tone', + 'uc8', + 'diversity', + 'happy', + 'butt', + 'body', + 'hands', + 'hi', + 'sex', + 'thank you', + 'perfect', + 'awesome', + 'good', + 'beautiful', + 'google', + 'correct', + 'porn', + 'fun', + 'language', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'hooray', + 'cheek', + 'cheeky', + 'excited', + 'feliz', + 'heureux', + 'cheerful', + 'delighted', + 'ecstatic', + 'elated', + 'glad', + 'joy', + 'merry', + 'ass', + 'booty', + 'body part', + 'anatomy', + 'hand', + 'finger', + 'fingers', + 'hello', + 'greeting', + 'bonjour', + 'bye', + 'ciao', + 'adios', + 'goodbye', + 'hey', + 'holla', + 'my name is', + 'salut', + 'welcome', + 'ПРИВЕТ', + 'tu tapelle', + 'fuck', + 'fucking', + 'horny', + 'humping', + 'thanks', + 'thankful', + 'praise', + 'gracias', + 'merci', + 'thankyou', + 'acceptable', + 'perfecto', + 'perfection', + 'superb', + 'flawless', + 'excellent', + 'supreme', + 'super', + 'great', + 'okay', + 'got it', + 'cool', + 'ok', + 'will do', + 'like', + 'bien', + 'yep', + 'yup', + 'good job', + 'nice', + 'well done', + 'bravo', + 'congratulations', + 'congrats', + 'cute', + 'pretty', + 'adorable', + 'adore', + 'beauty', + 'cutie', + 'babe', + 'lovely', + 'passing grade' + ], + modifiable: true), + Emoji( + name: 'OK hand: medium-dark skin tone', + char: '\u{1F44C}\u{1F3FE}', + shortName: 'ok_hand_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.handFingersPartial, + keywords: [ + 'OK', + 'hand', + 'medium-dark skin tone', + 'uc8', + 'diversity', + 'happy', + 'butt', + 'body', + 'hands', + 'hi', + 'sex', + 'thank you', + 'perfect', + 'awesome', + 'good', + 'beautiful', + 'google', + 'correct', + 'porn', + 'fun', + 'language', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'hooray', + 'cheek', + 'cheeky', + 'excited', + 'feliz', + 'heureux', + 'cheerful', + 'delighted', + 'ecstatic', + 'elated', + 'glad', + 'joy', + 'merry', + 'ass', + 'booty', + 'body part', + 'anatomy', + 'hand', + 'finger', + 'fingers', + 'hello', + 'greeting', + 'bonjour', + 'bye', + 'ciao', + 'adios', + 'goodbye', + 'hey', + 'holla', + 'my name is', + 'salut', + 'welcome', + 'ПРИВЕТ', + 'tu tapelle', + 'fuck', + 'fucking', + 'horny', + 'humping', + 'thanks', + 'thankful', + 'praise', + 'gracias', + 'merci', + 'thankyou', + 'acceptable', + 'perfecto', + 'perfection', + 'superb', + 'flawless', + 'excellent', + 'supreme', + 'super', + 'great', + 'okay', + 'got it', + 'cool', + 'ok', + 'will do', + 'like', + 'bien', + 'yep', + 'yup', + 'good job', + 'nice', + 'well done', + 'bravo', + 'congratulations', + 'congrats', + 'cute', + 'pretty', + 'adorable', + 'adore', + 'beauty', + 'cutie', + 'babe', + 'lovely', + 'passing grade' + ], + modifiable: true), + Emoji( + name: 'OK hand: dark skin tone', + char: '\u{1F44C}\u{1F3FF}', + shortName: 'ok_hand_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.handFingersPartial, + keywords: [ + 'OK', + 'dark skin tone', + 'hand', + 'uc8', + 'diversity', + 'happy', + 'butt', + 'body', + 'hands', + 'hi', + 'sex', + 'thank you', + 'perfect', + 'awesome', + 'good', + 'beautiful', + 'google', + 'correct', + 'porn', + 'fun', + 'language', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'hooray', + 'cheek', + 'cheeky', + 'excited', + 'feliz', + 'heureux', + 'cheerful', + 'delighted', + 'ecstatic', + 'elated', + 'glad', + 'joy', + 'merry', + 'ass', + 'booty', + 'body part', + 'anatomy', + 'hand', + 'finger', + 'fingers', + 'hello', + 'greeting', + 'bonjour', + 'bye', + 'ciao', + 'adios', + 'goodbye', + 'hey', + 'holla', + 'my name is', + 'salut', + 'welcome', + 'ПРИВЕТ', + 'tu tapelle', + 'fuck', + 'fucking', + 'horny', + 'humping', + 'thanks', + 'thankful', + 'praise', + 'gracias', + 'merci', + 'thankyou', + 'acceptable', + 'perfecto', + 'perfection', + 'superb', + 'flawless', + 'excellent', + 'supreme', + 'super', + 'great', + 'okay', + 'got it', + 'cool', + 'ok', + 'will do', + 'like', + 'bien', + 'yep', + 'yup', + 'good job', + 'nice', + 'well done', + 'bravo', + 'congratulations', + 'congrats', + 'cute', + 'pretty', + 'adorable', + 'adore', + 'beauty', + 'cutie', + 'babe', + 'lovely', + 'passing grade' + ], + modifiable: true), + Emoji( + name: 'pinching hand', + char: '\u{1F90F}', + shortName: 'pinching_hand', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.handFingersPartial, + keywords: [ + 'uc12', + 'diversity', + 'penis', + 'body', + 'hands', + 'donald trump', + 'half', + 'quiet', + 'tiny', + 'greed', + 'language', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'dick', + 'body part', + 'anatomy', + 'hand', + 'finger', + 'fingers', + 'trump', + 'shut up', + 'hushed', + 'silence', + 'silent', + 'shush', + 'shh', + 'petite bite', + 'small dick', + 'small', + 'selfish' + ]), + Emoji( + name: 'pinching hand: light skin tone', + char: '\u{1F90F}\u{1F3FB}', + shortName: 'pinching_hand_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.handFingersPartial, + keywords: [ + 'uc12', + 'diversity', + 'penis', + 'body', + 'hands', + 'donald trump', + 'half', + 'quiet', + 'tiny', + 'greed', + 'language', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'dick', + 'body part', + 'anatomy', + 'hand', + 'finger', + 'fingers', + 'trump', + 'shut up', + 'hushed', + 'silence', + 'silent', + 'shush', + 'shh', + 'petite bite', + 'small dick', + 'small', + 'selfish' + ], + modifiable: true), + Emoji( + name: 'pinching hand: medium-light skin tone', + char: '\u{1F90F}\u{1F3FC}', + shortName: 'pinching_hand_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.handFingersPartial, + keywords: [ + 'uc12', + 'diversity', + 'penis', + 'body', + 'hands', + 'donald trump', + 'half', + 'quiet', + 'tiny', + 'greed', + 'language', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'dick', + 'body part', + 'anatomy', + 'hand', + 'finger', + 'fingers', + 'trump', + 'shut up', + 'hushed', + 'silence', + 'silent', + 'shush', + 'shh', + 'petite bite', + 'small dick', + 'small', + 'selfish' + ], + modifiable: true), + Emoji( + name: 'pinching hand: medium skin tone', + char: '\u{1F90F}\u{1F3FD}', + shortName: 'pinching_hand_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.handFingersPartial, + keywords: [ + 'uc12', + 'diversity', + 'penis', + 'body', + 'hands', + 'donald trump', + 'half', + 'quiet', + 'tiny', + 'greed', + 'language', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'dick', + 'body part', + 'anatomy', + 'hand', + 'finger', + 'fingers', + 'trump', + 'shut up', + 'hushed', + 'silence', + 'silent', + 'shush', + 'shh', + 'petite bite', + 'small dick', + 'small', + 'selfish' + ], + modifiable: true), + Emoji( + name: 'pinching hand: medium-dark skin tone', + char: '\u{1F90F}\u{1F3FE}', + shortName: 'pinching_hand_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.handFingersPartial, + keywords: [ + 'uc12', + 'diversity', + 'penis', + 'body', + 'hands', + 'donald trump', + 'half', + 'quiet', + 'tiny', + 'greed', + 'language', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'dick', + 'body part', + 'anatomy', + 'hand', + 'finger', + 'fingers', + 'trump', + 'shut up', + 'hushed', + 'silence', + 'silent', + 'shush', + 'shh', + 'petite bite', + 'small dick', + 'small', + 'selfish' + ], + modifiable: true), + Emoji( + name: 'pinching hand: dark skin tone', + char: '\u{1F90F}\u{1F3FF}', + shortName: 'pinching_hand_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.handFingersPartial, + keywords: [ + 'uc12', + 'diversity', + 'penis', + 'body', + 'hands', + 'donald trump', + 'half', + 'quiet', + 'tiny', + 'greed', + 'language', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'dick', + 'body part', + 'anatomy', + 'hand', + 'finger', + 'fingers', + 'trump', + 'shut up', + 'hushed', + 'silence', + 'silent', + 'shush', + 'shh', + 'petite bite', + 'small dick', + 'small', + 'selfish' + ], + modifiable: true), + Emoji( + name: 'pinched fingers', + char: '\u{1F90C}', + shortName: 'pinched_fingers', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.handFingersPartial, + keywords: [ + 'uc13', + 'diversity', + 'italian', + 'body', + 'hands', + 'language', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'italy', + 'italie', + 'body part', + 'anatomy', + 'hand', + 'finger', + 'fingers' + ]), + Emoji( + name: 'pinched fingers: medium-light skin tone', + char: '\u{1F90C}\u{1F3FC}', + shortName: 'pinched_fingers_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.handFingersPartial, + keywords: [ + 'uc13', + 'diversity', + 'italian', + 'body', + 'hands', + 'language', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'italy', + 'italie', + 'body part', + 'anatomy', + 'hand', + 'finger', + 'fingers' + ], + modifiable: true), + Emoji( + name: 'pinched fingers: light skin tone', + char: '\u{1F90C}\u{1F3FB}', + shortName: 'pinched_fingers_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.handFingersPartial, + keywords: [ + 'uc13', + 'diversity', + 'italian', + 'body', + 'hands', + 'language', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'italy', + 'italie', + 'body part', + 'anatomy', + 'hand', + 'finger', + 'fingers' + ], + modifiable: true), + Emoji( + name: 'pinched fingers: medium skin tone', + char: '\u{1F90C}\u{1F3FD}', + shortName: 'pinched_fingers_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.handFingersPartial, + keywords: [ + 'uc13', + 'diversity', + 'italian', + 'body', + 'hands', + 'language', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'italy', + 'italie', + 'body part', + 'anatomy', + 'hand', + 'finger', + 'fingers' + ], + modifiable: true), + Emoji( + name: 'pinched fingers: medium-dark skin tone', + char: '\u{1F90C}\u{1F3FE}', + shortName: 'pinched_fingers_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.handFingersPartial, + keywords: [ + 'uc13', + 'diversity', + 'italian', + 'body', + 'hands', + 'language', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'italy', + 'italie', + 'body part', + 'anatomy', + 'hand', + 'finger', + 'fingers' + ], + modifiable: true), + Emoji( + name: 'pinched fingers: dark skin tone', + char: '\u{1F90C}\u{1F3FF}', + shortName: 'pinched_fingers_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.handFingersPartial, + keywords: [ + 'uc13', + 'diversity', + 'italian', + 'body', + 'hands', + 'language', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'italy', + 'italie', + 'body part', + 'anatomy', + 'hand', + 'finger', + 'fingers' + ], + modifiable: true), + Emoji( + name: 'backhand index pointing left', + char: '\u{1F448}', + shortName: 'point_left', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.handSingleFinger, + keywords: [ + 'backhand', + 'finger', + 'hand', + 'index', + 'point', + 'uc6', + 'diversity', + 'body', + 'hands', + 'hi', + 'language', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'body part', + 'anatomy', + 'hand', + 'finger', + 'fingers', + 'hello', + 'greeting', + 'bonjour', + 'bye', + 'ciao', + 'adios', + 'goodbye', + 'hey', + 'holla', + 'my name is', + 'salut', + 'welcome', + 'ПРИВЕТ', + 'tu tapelle' + ]), + Emoji( + name: 'backhand index pointing left: light skin tone', + char: '\u{1F448}\u{1F3FB}', + shortName: 'point_left_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.handSingleFinger, + keywords: [ + 'backhand', + 'finger', + 'hand', + 'index', + 'light skin tone', + 'point', + 'uc8', + 'diversity', + 'body', + 'hands', + 'hi', + 'language', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'body part', + 'anatomy', + 'hand', + 'finger', + 'fingers', + 'hello', + 'greeting', + 'bonjour', + 'bye', + 'ciao', + 'adios', + 'goodbye', + 'hey', + 'holla', + 'my name is', + 'salut', + 'welcome', + 'ПРИВЕТ', + 'tu tapelle' + ], + modifiable: true), + Emoji( + name: 'backhand index pointing left: medium-light skin tone', + char: '\u{1F448}\u{1F3FC}', + shortName: 'point_left_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.handSingleFinger, + keywords: [ + 'backhand', + 'finger', + 'hand', + 'index', + 'medium-light skin tone', + 'point', + 'uc8', + 'diversity', + 'body', + 'hands', + 'hi', + 'language', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'body part', + 'anatomy', + 'hand', + 'finger', + 'fingers', + 'hello', + 'greeting', + 'bonjour', + 'bye', + 'ciao', + 'adios', + 'goodbye', + 'hey', + 'holla', + 'my name is', + 'salut', + 'welcome', + 'ПРИВЕТ', + 'tu tapelle' + ], + modifiable: true), + Emoji( + name: 'backhand index pointing left: medium skin tone', + char: '\u{1F448}\u{1F3FD}', + shortName: 'point_left_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.handSingleFinger, + keywords: [ + 'backhand', + 'finger', + 'hand', + 'index', + 'medium skin tone', + 'point', + 'uc8', + 'diversity', + 'body', + 'hands', + 'hi', + 'language', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'body part', + 'anatomy', + 'hand', + 'finger', + 'fingers', + 'hello', + 'greeting', + 'bonjour', + 'bye', + 'ciao', + 'adios', + 'goodbye', + 'hey', + 'holla', + 'my name is', + 'salut', + 'welcome', + 'ПРИВЕТ', + 'tu tapelle' + ], + modifiable: true), + Emoji( + name: 'backhand index pointing left: medium-dark skin tone', + char: '\u{1F448}\u{1F3FE}', + shortName: 'point_left_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.handSingleFinger, + keywords: [ + 'backhand', + 'finger', + 'hand', + 'index', + 'medium-dark skin tone', + 'point', + 'uc8', + 'diversity', + 'body', + 'hands', + 'hi', + 'language', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'body part', + 'anatomy', + 'hand', + 'finger', + 'fingers', + 'hello', + 'greeting', + 'bonjour', + 'bye', + 'ciao', + 'adios', + 'goodbye', + 'hey', + 'holla', + 'my name is', + 'salut', + 'welcome', + 'ПРИВЕТ', + 'tu tapelle' + ], + modifiable: true), + Emoji( + name: 'backhand index pointing left: dark skin tone', + char: '\u{1F448}\u{1F3FF}', + shortName: 'point_left_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.handSingleFinger, + keywords: [ + 'backhand', + 'dark skin tone', + 'finger', + 'hand', + 'index', + 'point', + 'uc8', + 'diversity', + 'body', + 'hands', + 'hi', + 'language', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'body part', + 'anatomy', + 'hand', + 'finger', + 'fingers', + 'hello', + 'greeting', + 'bonjour', + 'bye', + 'ciao', + 'adios', + 'goodbye', + 'hey', + 'holla', + 'my name is', + 'salut', + 'welcome', + 'ПРИВЕТ', + 'tu tapelle' + ], + modifiable: true), + Emoji( + name: 'backhand index pointing right', + char: '\u{1F449}', + shortName: 'point_right', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.handSingleFinger, + keywords: [ + 'backhand', + 'finger', + 'hand', + 'index', + 'point', + 'uc6', + 'diversity', + 'body', + 'hands', + 'hi', + 'sex', + 'download', + 'porn', + 'language', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'body part', + 'anatomy', + 'hand', + 'finger', + 'fingers', + 'hello', + 'greeting', + 'bonjour', + 'bye', + 'ciao', + 'adios', + 'goodbye', + 'hey', + 'holla', + 'my name is', + 'salut', + 'welcome', + 'ПРИВЕТ', + 'tu tapelle', + 'fuck', + 'fucking', + 'horny', + 'humping' + ]), + Emoji( + name: 'backhand index pointing right: light skin tone', + char: '\u{1F449}\u{1F3FB}', + shortName: 'point_right_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.handSingleFinger, + keywords: [ + 'backhand', + 'finger', + 'hand', + 'index', + 'light skin tone', + 'point', + 'uc8', + 'diversity', + 'body', + 'hands', + 'hi', + 'sex', + 'download', + 'porn', + 'language', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'body part', + 'anatomy', + 'hand', + 'finger', + 'fingers', + 'hello', + 'greeting', + 'bonjour', + 'bye', + 'ciao', + 'adios', + 'goodbye', + 'hey', + 'holla', + 'my name is', + 'salut', + 'welcome', + 'ПРИВЕТ', + 'tu tapelle', + 'fuck', + 'fucking', + 'horny', + 'humping' + ], + modifiable: true), + Emoji( + name: 'backhand index pointing right: medium-light skin tone', + char: '\u{1F449}\u{1F3FC}', + shortName: 'point_right_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.handSingleFinger, + keywords: [ + 'backhand', + 'finger', + 'hand', + 'index', + 'medium-light skin tone', + 'point', + 'uc8', + 'diversity', + 'body', + 'hands', + 'hi', + 'sex', + 'download', + 'porn', + 'language', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'body part', + 'anatomy', + 'hand', + 'finger', + 'fingers', + 'hello', + 'greeting', + 'bonjour', + 'bye', + 'ciao', + 'adios', + 'goodbye', + 'hey', + 'holla', + 'my name is', + 'salut', + 'welcome', + 'ПРИВЕТ', + 'tu tapelle', + 'fuck', + 'fucking', + 'horny', + 'humping' + ], + modifiable: true), + Emoji( + name: 'backhand index pointing right: medium skin tone', + char: '\u{1F449}\u{1F3FD}', + shortName: 'point_right_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.handSingleFinger, + keywords: [ + 'backhand', + 'finger', + 'hand', + 'index', + 'medium skin tone', + 'point', + 'uc8', + 'diversity', + 'body', + 'hands', + 'hi', + 'sex', + 'download', + 'porn', + 'language', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'body part', + 'anatomy', + 'hand', + 'finger', + 'fingers', + 'hello', + 'greeting', + 'bonjour', + 'bye', + 'ciao', + 'adios', + 'goodbye', + 'hey', + 'holla', + 'my name is', + 'salut', + 'welcome', + 'ПРИВЕТ', + 'tu tapelle', + 'fuck', + 'fucking', + 'horny', + 'humping' + ], + modifiable: true), + Emoji( + name: 'backhand index pointing right: medium-dark skin tone', + char: '\u{1F449}\u{1F3FE}', + shortName: 'point_right_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.handSingleFinger, + keywords: [ + 'backhand', + 'finger', + 'hand', + 'index', + 'medium-dark skin tone', + 'point', + 'uc8', + 'diversity', + 'body', + 'hands', + 'hi', + 'sex', + 'download', + 'porn', + 'language', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'body part', + 'anatomy', + 'hand', + 'finger', + 'fingers', + 'hello', + 'greeting', + 'bonjour', + 'bye', + 'ciao', + 'adios', + 'goodbye', + 'hey', + 'holla', + 'my name is', + 'salut', + 'welcome', + 'ПРИВЕТ', + 'tu tapelle', + 'fuck', + 'fucking', + 'horny', + 'humping' + ], + modifiable: true), + Emoji( + name: 'backhand index pointing right: dark skin tone', + char: '\u{1F449}\u{1F3FF}', + shortName: 'point_right_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.handSingleFinger, + keywords: [ + 'backhand', + 'dark skin tone', + 'finger', + 'hand', + 'index', + 'point', + 'uc8', + 'diversity', + 'body', + 'hands', + 'hi', + 'sex', + 'download', + 'porn', + 'language', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'body part', + 'anatomy', + 'hand', + 'finger', + 'fingers', + 'hello', + 'greeting', + 'bonjour', + 'bye', + 'ciao', + 'adios', + 'goodbye', + 'hey', + 'holla', + 'my name is', + 'salut', + 'welcome', + 'ПРИВЕТ', + 'tu tapelle', + 'fuck', + 'fucking', + 'horny', + 'humping' + ], + modifiable: true), + Emoji( + name: 'backhand index pointing up', + char: '\u{1F446}', + shortName: 'point_up_2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.handSingleFinger, + keywords: [ + 'backhand', + 'finger', + 'hand', + 'index', + 'point', + 'up', + 'uc6', + 'diversity', + 'body', + 'hands', + 'language', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'body part', + 'anatomy', + 'hand', + 'finger', + 'fingers' + ]), + Emoji( + name: 'backhand index pointing up: light skin tone', + char: '\u{1F446}\u{1F3FB}', + shortName: 'point_up_2_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.handSingleFinger, + keywords: [ + 'backhand', + 'finger', + 'hand', + 'index', + 'light skin tone', + 'point', + 'up', + 'uc8', + 'diversity', + 'body', + 'hands', + 'language', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'body part', + 'anatomy', + 'hand', + 'finger', + 'fingers' + ], + modifiable: true), + Emoji( + name: 'backhand index pointing up: medium-light skin tone', + char: '\u{1F446}\u{1F3FC}', + shortName: 'point_up_2_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.handSingleFinger, + keywords: [ + 'backhand', + 'finger', + 'hand', + 'index', + 'medium-light skin tone', + 'point', + 'up', + 'uc8', + 'diversity', + 'body', + 'hands', + 'language', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'body part', + 'anatomy', + 'hand', + 'finger', + 'fingers' + ], + modifiable: true), + Emoji( + name: 'backhand index pointing up: medium skin tone', + char: '\u{1F446}\u{1F3FD}', + shortName: 'point_up_2_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.handSingleFinger, + keywords: [ + 'backhand', + 'finger', + 'hand', + 'index', + 'medium skin tone', + 'point', + 'up', + 'uc8', + 'diversity', + 'body', + 'hands', + 'language', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'body part', + 'anatomy', + 'hand', + 'finger', + 'fingers' + ], + modifiable: true), + Emoji( + name: 'backhand index pointing up: medium-dark skin tone', + char: '\u{1F446}\u{1F3FE}', + shortName: 'point_up_2_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.handSingleFinger, + keywords: [ + 'backhand', + 'finger', + 'hand', + 'index', + 'medium-dark skin tone', + 'point', + 'up', + 'uc8', + 'diversity', + 'body', + 'hands', + 'language', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'body part', + 'anatomy', + 'hand', + 'finger', + 'fingers' + ], + modifiable: true), + Emoji( + name: 'backhand index pointing up: dark skin tone', + char: '\u{1F446}\u{1F3FF}', + shortName: 'point_up_2_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.handSingleFinger, + keywords: [ + 'backhand', + 'dark skin tone', + 'finger', + 'hand', + 'index', + 'point', + 'up', + 'uc8', + 'diversity', + 'body', + 'hands', + 'language', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'body part', + 'anatomy', + 'hand', + 'finger', + 'fingers' + ], + modifiable: true), + Emoji( + name: 'backhand index pointing down', + char: '\u{1F447}', + shortName: 'point_down', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.handSingleFinger, + keywords: [ + 'backhand', + 'down', + 'finger', + 'hand', + 'index', + 'point', + 'uc6', + 'diversity', + 'body', + 'hands', + 'click', + 'language', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'body part', + 'anatomy', + 'hand', + 'finger', + 'fingers' + ]), + Emoji( + name: 'backhand index pointing down: light skin tone', + char: '\u{1F447}\u{1F3FB}', + shortName: 'point_down_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.handSingleFinger, + keywords: [ + 'backhand', + 'down', + 'finger', + 'hand', + 'index', + 'light skin tone', + 'point', + 'uc8', + 'diversity', + 'body', + 'hands', + 'click', + 'language', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'body part', + 'anatomy', + 'hand', + 'finger', + 'fingers' + ], + modifiable: true), + Emoji( + name: 'backhand index pointing down: medium-light skin tone', + char: '\u{1F447}\u{1F3FC}', + shortName: 'point_down_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.handSingleFinger, + keywords: [ + 'backhand', + 'down', + 'finger', + 'hand', + 'index', + 'medium-light skin tone', + 'point', + 'uc8', + 'diversity', + 'body', + 'hands', + 'click', + 'language', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'body part', + 'anatomy', + 'hand', + 'finger', + 'fingers' + ], + modifiable: true), + Emoji( + name: 'backhand index pointing down: medium skin tone', + char: '\u{1F447}\u{1F3FD}', + shortName: 'point_down_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.handSingleFinger, + keywords: [ + 'backhand', + 'down', + 'finger', + 'hand', + 'index', + 'medium skin tone', + 'point', + 'uc8', + 'diversity', + 'body', + 'hands', + 'click', + 'language', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'body part', + 'anatomy', + 'hand', + 'finger', + 'fingers' + ], + modifiable: true), + Emoji( + name: 'backhand index pointing down: medium-dark skin tone', + char: '\u{1F447}\u{1F3FE}', + shortName: 'point_down_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.handSingleFinger, + keywords: [ + 'backhand', + 'down', + 'finger', + 'hand', + 'index', + 'medium-dark skin tone', + 'point', + 'uc8', + 'diversity', + 'body', + 'hands', + 'click', + 'language', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'body part', + 'anatomy', + 'hand', + 'finger', + 'fingers' + ], + modifiable: true), + Emoji( + name: 'backhand index pointing down: dark skin tone', + char: '\u{1F447}\u{1F3FF}', + shortName: 'point_down_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.handSingleFinger, + keywords: [ + 'backhand', + 'dark skin tone', + 'down', + 'finger', + 'hand', + 'index', + 'point', + 'uc8', + 'diversity', + 'body', + 'hands', + 'click', + 'language', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'body part', + 'anatomy', + 'hand', + 'finger', + 'fingers' + ], + modifiable: true), + Emoji( + name: 'index pointing up', + char: '\u{261D}\u{FE0F}', + shortName: 'point_up', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.handSingleFinger, + keywords: [ + 'finger', + 'hand', + 'index', + 'point', + 'up', + 'uc1', + 'diversity', + 'body', + 'hands', + 'emojione', + 'porn', + 'important', + 'language', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'body part', + 'anatomy', + 'hand', + 'finger', + 'fingers', + 'emoji one' + ]), + Emoji( + name: 'index pointing up: light skin tone', + char: '\u{261D}\u{1F3FB}', + shortName: 'point_up_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.handSingleFinger, + keywords: [ + 'finger', + 'hand', + 'index', + 'light skin tone', + 'point', + 'up', + 'uc8', + 'diversity', + 'body', + 'hands', + 'emojione', + 'porn', + 'important', + 'language', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'body part', + 'anatomy', + 'hand', + 'finger', + 'fingers', + 'emoji one' + ], + modifiable: true), + Emoji( + name: 'index pointing up: medium-light skin tone', + char: '\u{261D}\u{1F3FC}', + shortName: 'point_up_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.handSingleFinger, + keywords: [ + 'finger', + 'hand', + 'index', + 'medium-light skin tone', + 'point', + 'up', + 'uc8', + 'diversity', + 'body', + 'hands', + 'emojione', + 'porn', + 'important', + 'language', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'body part', + 'anatomy', + 'hand', + 'finger', + 'fingers', + 'emoji one' + ], + modifiable: true), + Emoji( + name: 'index pointing up: medium skin tone', + char: '\u{261D}\u{1F3FD}', + shortName: 'point_up_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.handSingleFinger, + keywords: [ + 'finger', + 'hand', + 'index', + 'medium skin tone', + 'point', + 'up', + 'uc8', + 'diversity', + 'body', + 'hands', + 'emojione', + 'porn', + 'important', + 'language', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'body part', + 'anatomy', + 'hand', + 'finger', + 'fingers', + 'emoji one' + ], + modifiable: true), + Emoji( + name: 'index pointing up: medium-dark skin tone', + char: '\u{261D}\u{1F3FE}', + shortName: 'point_up_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.handSingleFinger, + keywords: [ + 'finger', + 'hand', + 'index', + 'medium-dark skin tone', + 'point', + 'up', + 'uc8', + 'diversity', + 'body', + 'hands', + 'emojione', + 'porn', + 'important', + 'language', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'body part', + 'anatomy', + 'hand', + 'finger', + 'fingers', + 'emoji one' + ], + modifiable: true), + Emoji( + name: 'index pointing up: dark skin tone', + char: '\u{261D}\u{1F3FF}', + shortName: 'point_up_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.handSingleFinger, + keywords: [ + 'dark skin tone', + 'finger', + 'hand', + 'index', + 'point', + 'up', + 'uc8', + 'diversity', + 'body', + 'hands', + 'emojione', + 'porn', + 'important', + 'language', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'body part', + 'anatomy', + 'hand', + 'finger', + 'fingers', + 'emoji one' + ], + modifiable: true), + Emoji( + name: 'raised hand', + char: '\u{270B}', + shortName: 'raised_hand', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.handFingersOpen, + keywords: [ + 'hand', + 'uc6', + 'diversity', + 'body', + 'hands', + 'hi', + 'girls night', + 'high five', + 'language', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'body part', + 'anatomy', + 'hand', + 'finger', + 'fingers', + 'hello', + 'greeting', + 'bonjour', + 'bye', + 'ciao', + 'adios', + 'goodbye', + 'hey', + 'holla', + 'my name is', + 'salut', + 'welcome', + 'ПРИВЕТ', + 'tu tapelle', + 'ladies night', + 'girls only', + 'girlfriend' + ]), + Emoji( + name: 'raised hand: light skin tone', + char: '\u{270B}\u{1F3FB}', + shortName: 'raised_hand_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.handFingersOpen, + keywords: [ + 'hand', + 'light skin tone', + 'uc8', + 'diversity', + 'body', + 'hands', + 'hi', + 'girls night', + 'high five', + 'language', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'body part', + 'anatomy', + 'hand', + 'finger', + 'fingers', + 'hello', + 'greeting', + 'bonjour', + 'bye', + 'ciao', + 'adios', + 'goodbye', + 'hey', + 'holla', + 'my name is', + 'salut', + 'welcome', + 'ПРИВЕТ', + 'tu tapelle', + 'ladies night', + 'girls only', + 'girlfriend' + ], + modifiable: true), + Emoji( + name: 'raised hand: medium-light skin tone', + char: '\u{270B}\u{1F3FC}', + shortName: 'raised_hand_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.handFingersOpen, + keywords: [ + 'hand', + 'medium-light skin tone', + 'uc8', + 'diversity', + 'body', + 'hands', + 'hi', + 'girls night', + 'high five', + 'language', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'body part', + 'anatomy', + 'hand', + 'finger', + 'fingers', + 'hello', + 'greeting', + 'bonjour', + 'bye', + 'ciao', + 'adios', + 'goodbye', + 'hey', + 'holla', + 'my name is', + 'salut', + 'welcome', + 'ПРИВЕТ', + 'tu tapelle', + 'ladies night', + 'girls only', + 'girlfriend' + ], + modifiable: true), + Emoji( + name: 'raised hand: medium skin tone', + char: '\u{270B}\u{1F3FD}', + shortName: 'raised_hand_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.handFingersOpen, + keywords: [ + 'hand', + 'medium skin tone', + 'uc8', + 'diversity', + 'body', + 'hands', + 'hi', + 'girls night', + 'high five', + 'language', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'body part', + 'anatomy', + 'hand', + 'finger', + 'fingers', + 'hello', + 'greeting', + 'bonjour', + 'bye', + 'ciao', + 'adios', + 'goodbye', + 'hey', + 'holla', + 'my name is', + 'salut', + 'welcome', + 'ПРИВЕТ', + 'tu tapelle', + 'ladies night', + 'girls only', + 'girlfriend' + ], + modifiable: true), + Emoji( + name: 'raised hand: medium-dark skin tone', + char: '\u{270B}\u{1F3FE}', + shortName: 'raised_hand_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.handFingersOpen, + keywords: [ + 'hand', + 'medium-dark skin tone', + 'uc8', + 'diversity', + 'body', + 'hands', + 'hi', + 'girls night', + 'high five', + 'language', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'body part', + 'anatomy', + 'hand', + 'finger', + 'fingers', + 'hello', + 'greeting', + 'bonjour', + 'bye', + 'ciao', + 'adios', + 'goodbye', + 'hey', + 'holla', + 'my name is', + 'salut', + 'welcome', + 'ПРИВЕТ', + 'tu tapelle', + 'ladies night', + 'girls only', + 'girlfriend' + ], + modifiable: true), + Emoji( + name: 'raised hand: dark skin tone', + char: '\u{270B}\u{1F3FF}', + shortName: 'raised_hand_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.handFingersOpen, + keywords: [ + 'dark skin tone', + 'hand', + 'uc8', + 'diversity', + 'body', + 'hands', + 'hi', + 'girls night', + 'high five', + 'language', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'body part', + 'anatomy', + 'hand', + 'finger', + 'fingers', + 'hello', + 'greeting', + 'bonjour', + 'bye', + 'ciao', + 'adios', + 'goodbye', + 'hey', + 'holla', + 'my name is', + 'salut', + 'welcome', + 'ПРИВЕТ', + 'tu tapelle', + 'ladies night', + 'girls only', + 'girlfriend' + ], + modifiable: true), + Emoji( + name: 'raised back of hand', + char: '\u{1F91A}', + shortName: 'raised_back_of_hand', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.handFingersOpen, + keywords: [ + 'backhand', + 'raised', + 'uc9', + 'diversity', + 'body', + 'hands', + 'award', + 'hi', + 'hate', + 'private', + 'language', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'body part', + 'anatomy', + 'hand', + 'finger', + 'fingers', + 'awards', + 'prize', + 'prizes', + 'trophy', + 'trophies', + 'spot', + 'best', + 'champion', + 'hero', + 'hello', + 'greeting', + 'bonjour', + 'bye', + 'ciao', + 'adios', + 'goodbye', + 'hey', + 'holla', + 'my name is', + 'salut', + 'welcome', + 'ПРИВЕТ', + 'tu tapelle', + 'i hate', + 'disgust', + 'stump', + 'shout', + 'dislike', + 'rude', + 'annoy', + 'grinch', + 'gross', + 'grumpy', + 'mean', + 'problem', + 'suck', + 'jerk', + 'asshole', + 'no', + 'прив', + 'privé', + 'privado', + 'reserved' + ]), + Emoji( + name: 'raised back of hand: light skin tone', + char: '\u{1F91A}\u{1F3FB}', + shortName: 'raised_back_of_hand_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.handFingersOpen, + keywords: [ + 'backhand', + 'light skin tone', + 'raised', + 'uc9', + 'diversity', + 'body', + 'hands', + 'award', + 'hi', + 'hate', + 'private', + 'language', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'body part', + 'anatomy', + 'hand', + 'finger', + 'fingers', + 'awards', + 'prize', + 'prizes', + 'trophy', + 'trophies', + 'spot', + 'best', + 'champion', + 'hero', + 'hello', + 'greeting', + 'bonjour', + 'bye', + 'ciao', + 'adios', + 'goodbye', + 'hey', + 'holla', + 'my name is', + 'salut', + 'welcome', + 'ПРИВЕТ', + 'tu tapelle', + 'i hate', + 'disgust', + 'stump', + 'shout', + 'dislike', + 'rude', + 'annoy', + 'grinch', + 'gross', + 'grumpy', + 'mean', + 'problem', + 'suck', + 'jerk', + 'asshole', + 'no', + 'прив', + 'privé', + 'privado', + 'reserved' + ], + modifiable: true), + Emoji( + name: 'raised back of hand: medium-light skin tone', + char: '\u{1F91A}\u{1F3FC}', + shortName: 'raised_back_of_hand_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.handFingersOpen, + keywords: [ + 'backhand', + 'medium-light skin tone', + 'raised', + 'uc9', + 'diversity', + 'body', + 'hands', + 'award', + 'hi', + 'hate', + 'private', + 'language', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'body part', + 'anatomy', + 'hand', + 'finger', + 'fingers', + 'awards', + 'prize', + 'prizes', + 'trophy', + 'trophies', + 'spot', + 'best', + 'champion', + 'hero', + 'hello', + 'greeting', + 'bonjour', + 'bye', + 'ciao', + 'adios', + 'goodbye', + 'hey', + 'holla', + 'my name is', + 'salut', + 'welcome', + 'ПРИВЕТ', + 'tu tapelle', + 'i hate', + 'disgust', + 'stump', + 'shout', + 'dislike', + 'rude', + 'annoy', + 'grinch', + 'gross', + 'grumpy', + 'mean', + 'problem', + 'suck', + 'jerk', + 'asshole', + 'no', + 'прив', + 'privé', + 'privado', + 'reserved' + ], + modifiable: true), + Emoji( + name: 'raised back of hand: medium skin tone', + char: '\u{1F91A}\u{1F3FD}', + shortName: 'raised_back_of_hand_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.handFingersOpen, + keywords: [ + 'backhand', + 'medium skin tone', + 'raised', + 'uc9', + 'diversity', + 'body', + 'hands', + 'award', + 'hi', + 'hate', + 'private', + 'language', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'body part', + 'anatomy', + 'hand', + 'finger', + 'fingers', + 'awards', + 'prize', + 'prizes', + 'trophy', + 'trophies', + 'spot', + 'best', + 'champion', + 'hero', + 'hello', + 'greeting', + 'bonjour', + 'bye', + 'ciao', + 'adios', + 'goodbye', + 'hey', + 'holla', + 'my name is', + 'salut', + 'welcome', + 'ПРИВЕТ', + 'tu tapelle', + 'i hate', + 'disgust', + 'stump', + 'shout', + 'dislike', + 'rude', + 'annoy', + 'grinch', + 'gross', + 'grumpy', + 'mean', + 'problem', + 'suck', + 'jerk', + 'asshole', + 'no', + 'прив', + 'privé', + 'privado', + 'reserved' + ], + modifiable: true), + Emoji( + name: 'raised back of hand: medium-dark skin tone', + char: '\u{1F91A}\u{1F3FE}', + shortName: 'raised_back_of_hand_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.handFingersOpen, + keywords: [ + 'backhand', + 'medium-dark skin tone', + 'raised', + 'uc9', + 'diversity', + 'body', + 'hands', + 'award', + 'hi', + 'hate', + 'private', + 'language', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'body part', + 'anatomy', + 'hand', + 'finger', + 'fingers', + 'awards', + 'prize', + 'prizes', + 'trophy', + 'trophies', + 'spot', + 'best', + 'champion', + 'hero', + 'hello', + 'greeting', + 'bonjour', + 'bye', + 'ciao', + 'adios', + 'goodbye', + 'hey', + 'holla', + 'my name is', + 'salut', + 'welcome', + 'ПРИВЕТ', + 'tu tapelle', + 'i hate', + 'disgust', + 'stump', + 'shout', + 'dislike', + 'rude', + 'annoy', + 'grinch', + 'gross', + 'grumpy', + 'mean', + 'problem', + 'suck', + 'jerk', + 'asshole', + 'no', + 'прив', + 'privé', + 'privado', + 'reserved' + ], + modifiable: true), + Emoji( + name: 'raised back of hand: dark skin tone', + char: '\u{1F91A}\u{1F3FF}', + shortName: 'raised_back_of_hand_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.handFingersOpen, + keywords: [ + 'backhand', + 'dark skin tone', + 'raised', + 'uc9', + 'diversity', + 'body', + 'hands', + 'award', + 'hi', + 'hate', + 'private', + 'language', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'body part', + 'anatomy', + 'hand', + 'finger', + 'fingers', + 'awards', + 'prize', + 'prizes', + 'trophy', + 'trophies', + 'spot', + 'best', + 'champion', + 'hero', + 'hello', + 'greeting', + 'bonjour', + 'bye', + 'ciao', + 'adios', + 'goodbye', + 'hey', + 'holla', + 'my name is', + 'salut', + 'welcome', + 'ПРИВЕТ', + 'tu tapelle', + 'i hate', + 'disgust', + 'stump', + 'shout', + 'dislike', + 'rude', + 'annoy', + 'grinch', + 'gross', + 'grumpy', + 'mean', + 'problem', + 'suck', + 'jerk', + 'asshole', + 'no', + 'прив', + 'privé', + 'privado', + 'reserved' + ], + modifiable: true), + Emoji( + name: 'hand with fingers splayed', + char: '\u{1F590}', + shortName: 'hand_splayed', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.handFingersOpen, + keywords: [ + 'finger', + 'hand', + 'splayed', + 'uc7', + 'diversity', + 'body', + 'hands', + 'hi', + 'gay pride', + 'high five', + 'proud', + 'language', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'body part', + 'anatomy', + 'hand', + 'finger', + 'fingers', + 'hello', + 'greeting', + 'bonjour', + 'bye', + 'ciao', + 'adios', + 'goodbye', + 'hey', + 'holla', + 'my name is', + 'salut', + 'welcome', + 'ПРИВЕТ', + 'tu tapelle' + ]), + Emoji( + name: 'hand with fingers splayed: light skin tone', + char: '\u{1F590}\u{1F3FB}', + shortName: 'hand_splayed_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.handFingersOpen, + keywords: [ + 'finger', + 'hand', + 'light skin tone', + 'splayed', + 'uc8', + 'diversity', + 'body', + 'hands', + 'hi', + 'gay pride', + 'high five', + 'proud', + 'language', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'body part', + 'anatomy', + 'hand', + 'finger', + 'fingers', + 'hello', + 'greeting', + 'bonjour', + 'bye', + 'ciao', + 'adios', + 'goodbye', + 'hey', + 'holla', + 'my name is', + 'salut', + 'welcome', + 'ПРИВЕТ', + 'tu tapelle' + ], + modifiable: true), + Emoji( + name: 'hand with fingers splayed: medium-light skin tone', + char: '\u{1F590}\u{1F3FC}', + shortName: 'hand_splayed_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.handFingersOpen, + keywords: [ + 'finger', + 'hand', + 'medium-light skin tone', + 'splayed', + 'uc8', + 'diversity', + 'body', + 'hands', + 'hi', + 'gay pride', + 'high five', + 'proud', + 'language', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'body part', + 'anatomy', + 'hand', + 'finger', + 'fingers', + 'hello', + 'greeting', + 'bonjour', + 'bye', + 'ciao', + 'adios', + 'goodbye', + 'hey', + 'holla', + 'my name is', + 'salut', + 'welcome', + 'ПРИВЕТ', + 'tu tapelle' + ], + modifiable: true), + Emoji( + name: 'hand with fingers splayed: medium skin tone', + char: '\u{1F590}\u{1F3FD}', + shortName: 'hand_splayed_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.handFingersOpen, + keywords: [ + 'finger', + 'hand', + 'medium skin tone', + 'splayed', + 'uc8', + 'diversity', + 'body', + 'hands', + 'hi', + 'gay pride', + 'high five', + 'proud', + 'language', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'body part', + 'anatomy', + 'hand', + 'finger', + 'fingers', + 'hello', + 'greeting', + 'bonjour', + 'bye', + 'ciao', + 'adios', + 'goodbye', + 'hey', + 'holla', + 'my name is', + 'salut', + 'welcome', + 'ПРИВЕТ', + 'tu tapelle' + ], + modifiable: true), + Emoji( + name: 'hand with fingers splayed: medium-dark skin tone', + char: '\u{1F590}\u{1F3FE}', + shortName: 'hand_splayed_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.handFingersOpen, + keywords: [ + 'finger', + 'hand', + 'medium-dark skin tone', + 'splayed', + 'uc8', + 'diversity', + 'body', + 'hands', + 'hi', + 'gay pride', + 'high five', + 'proud', + 'language', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'body part', + 'anatomy', + 'hand', + 'finger', + 'fingers', + 'hello', + 'greeting', + 'bonjour', + 'bye', + 'ciao', + 'adios', + 'goodbye', + 'hey', + 'holla', + 'my name is', + 'salut', + 'welcome', + 'ПРИВЕТ', + 'tu tapelle' + ], + modifiable: true), + Emoji( + name: 'hand with fingers splayed: dark skin tone', + char: '\u{1F590}\u{1F3FF}', + shortName: 'hand_splayed_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.handFingersOpen, + keywords: [ + 'dark skin tone', + 'finger', + 'hand', + 'splayed', + 'uc8', + 'diversity', + 'body', + 'hands', + 'hi', + 'gay pride', + 'high five', + 'proud', + 'language', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'body part', + 'anatomy', + 'hand', + 'finger', + 'fingers', + 'hello', + 'greeting', + 'bonjour', + 'bye', + 'ciao', + 'adios', + 'goodbye', + 'hey', + 'holla', + 'my name is', + 'salut', + 'welcome', + 'ПРИВЕТ', + 'tu tapelle' + ], + modifiable: true), + Emoji( + name: 'vulcan salute', + char: '\u{1F596}', + shortName: 'vulcan', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.handFingersOpen, + keywords: [ + 'finger', + 'hand', + 'spock', + 'vulcan', + 'uc7', + 'diversity', + 'body', + 'hands', + 'hi', + 'language', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'body part', + 'anatomy', + 'hand', + 'finger', + 'fingers', + 'hello', + 'greeting', + 'bonjour', + 'bye', + 'ciao', + 'adios', + 'goodbye', + 'hey', + 'holla', + 'my name is', + 'salut', + 'welcome', + 'ПРИВЕТ', + 'tu tapelle' + ]), + Emoji( + name: 'vulcan salute: light skin tone', + char: '\u{1F596}\u{1F3FB}', + shortName: 'vulcan_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.handFingersOpen, + keywords: [ + 'finger', + 'hand', + 'light skin tone', + 'spock', + 'vulcan', + 'uc8', + 'diversity', + 'body', + 'hands', + 'hi', + 'language', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'body part', + 'anatomy', + 'hand', + 'finger', + 'fingers', + 'hello', + 'greeting', + 'bonjour', + 'bye', + 'ciao', + 'adios', + 'goodbye', + 'hey', + 'holla', + 'my name is', + 'salut', + 'welcome', + 'ПРИВЕТ', + 'tu tapelle' + ], + modifiable: true), + Emoji( + name: 'vulcan salute: medium-light skin tone', + char: '\u{1F596}\u{1F3FC}', + shortName: 'vulcan_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.handFingersOpen, + keywords: [ + 'finger', + 'hand', + 'medium-light skin tone', + 'spock', + 'vulcan', + 'uc8', + 'diversity', + 'body', + 'hands', + 'hi', + 'language', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'body part', + 'anatomy', + 'hand', + 'finger', + 'fingers', + 'hello', + 'greeting', + 'bonjour', + 'bye', + 'ciao', + 'adios', + 'goodbye', + 'hey', + 'holla', + 'my name is', + 'salut', + 'welcome', + 'ПРИВЕТ', + 'tu tapelle' + ], + modifiable: true), + Emoji( + name: 'vulcan salute: medium skin tone', + char: '\u{1F596}\u{1F3FD}', + shortName: 'vulcan_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.handFingersOpen, + keywords: [ + 'finger', + 'hand', + 'medium skin tone', + 'spock', + 'vulcan', + 'uc8', + 'diversity', + 'body', + 'hands', + 'hi', + 'language', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'body part', + 'anatomy', + 'hand', + 'finger', + 'fingers', + 'hello', + 'greeting', + 'bonjour', + 'bye', + 'ciao', + 'adios', + 'goodbye', + 'hey', + 'holla', + 'my name is', + 'salut', + 'welcome', + 'ПРИВЕТ', + 'tu tapelle' + ], + modifiable: true), + Emoji( + name: 'vulcan salute: medium-dark skin tone', + char: '\u{1F596}\u{1F3FE}', + shortName: 'vulcan_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.handFingersOpen, + keywords: [ + 'finger', + 'hand', + 'medium-dark skin tone', + 'spock', + 'vulcan', + 'uc8', + 'diversity', + 'body', + 'hands', + 'hi', + 'language', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'body part', + 'anatomy', + 'hand', + 'finger', + 'fingers', + 'hello', + 'greeting', + 'bonjour', + 'bye', + 'ciao', + 'adios', + 'goodbye', + 'hey', + 'holla', + 'my name is', + 'salut', + 'welcome', + 'ПРИВЕТ', + 'tu tapelle' + ], + modifiable: true), + Emoji( + name: 'vulcan salute: dark skin tone', + char: '\u{1F596}\u{1F3FF}', + shortName: 'vulcan_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.handFingersOpen, + keywords: [ + 'dark skin tone', + 'finger', + 'hand', + 'spock', + 'vulcan', + 'uc8', + 'diversity', + 'body', + 'hands', + 'hi', + 'language', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'body part', + 'anatomy', + 'hand', + 'finger', + 'fingers', + 'hello', + 'greeting', + 'bonjour', + 'bye', + 'ciao', + 'adios', + 'goodbye', + 'hey', + 'holla', + 'my name is', + 'salut', + 'welcome', + 'ПРИВЕТ', + 'tu tapelle' + ], + modifiable: true), + Emoji( + name: 'waving hand', + char: '\u{1F44B}', + shortName: 'wave', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.handFingersOpen, + keywords: [ + 'hand', + 'wave', + 'waving', + 'uc6', + 'diversity', + 'body', + 'hands', + 'hi', + 'hola', + 'friend', + 'language', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'body part', + 'anatomy', + 'hand', + 'finger', + 'fingers', + 'hello', + 'greeting', + 'bonjour', + 'bye', + 'ciao', + 'adios', + 'goodbye', + 'hey', + 'holla', + 'my name is', + 'salut', + 'welcome', + 'ПРИВЕТ', + 'tu tapelle', + 'friends', + 'friendship', + 'best friends', + 'bestfriends', + 'ami', + 'amiga', + 'amigo' + ]), + Emoji( + name: 'waving hand: light skin tone', + char: '\u{1F44B}\u{1F3FB}', + shortName: 'wave_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.handFingersOpen, + keywords: [ + 'hand', + 'light skin tone', + 'wave', + 'waving', + 'uc8', + 'diversity', + 'body', + 'hands', + 'hi', + 'hola', + 'friend', + 'language', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'body part', + 'anatomy', + 'hand', + 'finger', + 'fingers', + 'hello', + 'greeting', + 'bonjour', + 'bye', + 'ciao', + 'adios', + 'goodbye', + 'hey', + 'holla', + 'my name is', + 'salut', + 'welcome', + 'ПРИВЕТ', + 'tu tapelle', + 'friends', + 'friendship', + 'best friends', + 'bestfriends', + 'ami', + 'amiga', + 'amigo' + ], + modifiable: true), + Emoji( + name: 'waving hand: medium-light skin tone', + char: '\u{1F44B}\u{1F3FC}', + shortName: 'wave_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.handFingersOpen, + keywords: [ + 'hand', + 'medium-light skin tone', + 'wave', + 'waving', + 'uc8', + 'diversity', + 'body', + 'hands', + 'hi', + 'hola', + 'friend', + 'language', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'body part', + 'anatomy', + 'hand', + 'finger', + 'fingers', + 'hello', + 'greeting', + 'bonjour', + 'bye', + 'ciao', + 'adios', + 'goodbye', + 'hey', + 'holla', + 'my name is', + 'salut', + 'welcome', + 'ПРИВЕТ', + 'tu tapelle', + 'friends', + 'friendship', + 'best friends', + 'bestfriends', + 'ami', + 'amiga', + 'amigo' + ], + modifiable: true), + Emoji( + name: 'waving hand: medium skin tone', + char: '\u{1F44B}\u{1F3FD}', + shortName: 'wave_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.handFingersOpen, + keywords: [ + 'hand', + 'medium skin tone', + 'wave', + 'waving', + 'uc8', + 'diversity', + 'body', + 'hands', + 'hi', + 'hola', + 'friend', + 'language', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'body part', + 'anatomy', + 'hand', + 'finger', + 'fingers', + 'hello', + 'greeting', + 'bonjour', + 'bye', + 'ciao', + 'adios', + 'goodbye', + 'hey', + 'holla', + 'my name is', + 'salut', + 'welcome', + 'ПРИВЕТ', + 'tu tapelle', + 'friends', + 'friendship', + 'best friends', + 'bestfriends', + 'ami', + 'amiga', + 'amigo' + ], + modifiable: true), + Emoji( + name: 'waving hand: medium-dark skin tone', + char: '\u{1F44B}\u{1F3FE}', + shortName: 'wave_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.handFingersOpen, + keywords: [ + 'hand', + 'medium-dark skin tone', + 'wave', + 'waving', + 'uc8', + 'diversity', + 'body', + 'hands', + 'hi', + 'hola', + 'friend', + 'language', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'body part', + 'anatomy', + 'hand', + 'finger', + 'fingers', + 'hello', + 'greeting', + 'bonjour', + 'bye', + 'ciao', + 'adios', + 'goodbye', + 'hey', + 'holla', + 'my name is', + 'salut', + 'welcome', + 'ПРИВЕТ', + 'tu tapelle', + 'friends', + 'friendship', + 'best friends', + 'bestfriends', + 'ami', + 'amiga', + 'amigo' + ], + modifiable: true), + Emoji( + name: 'waving hand: dark skin tone', + char: '\u{1F44B}\u{1F3FF}', + shortName: 'wave_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.handFingersOpen, + keywords: [ + 'dark skin tone', + 'hand', + 'wave', + 'waving', + 'uc8', + 'diversity', + 'body', + 'hands', + 'hi', + 'hola', + 'friend', + 'language', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'body part', + 'anatomy', + 'hand', + 'finger', + 'fingers', + 'hello', + 'greeting', + 'bonjour', + 'bye', + 'ciao', + 'adios', + 'goodbye', + 'hey', + 'holla', + 'my name is', + 'salut', + 'welcome', + 'ПРИВЕТ', + 'tu tapelle', + 'friends', + 'friendship', + 'best friends', + 'bestfriends', + 'ami', + 'amiga', + 'amigo' + ], + modifiable: true), + Emoji( + name: 'call me hand', + char: '\u{1F919}', + shortName: 'call_me', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.handFingersPartial, + keywords: [ + 'call', + 'hand', + 'uc9', + 'diversity', + 'body', + 'hands', + 'hi', + 'language', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'body part', + 'anatomy', + 'hand', + 'finger', + 'fingers', + 'hello', + 'greeting', + 'bonjour', + 'bye', + 'ciao', + 'adios', + 'goodbye', + 'hey', + 'holla', + 'my name is', + 'salut', + 'welcome', + 'ПРИВЕТ', + 'tu tapelle' + ]), + Emoji( + name: 'call me hand: light skin tone', + char: '\u{1F919}\u{1F3FB}', + shortName: 'call_me_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.handFingersPartial, + keywords: [ + 'call', + 'hand', + 'light skin tone', + 'uc9', + 'diversity', + 'body', + 'hands', + 'hi', + 'language', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'body part', + 'anatomy', + 'hand', + 'finger', + 'fingers', + 'hello', + 'greeting', + 'bonjour', + 'bye', + 'ciao', + 'adios', + 'goodbye', + 'hey', + 'holla', + 'my name is', + 'salut', + 'welcome', + 'ПРИВЕТ', + 'tu tapelle' + ], + modifiable: true), + Emoji( + name: 'call me hand: medium-light skin tone', + char: '\u{1F919}\u{1F3FC}', + shortName: 'call_me_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.handFingersPartial, + keywords: [ + 'call', + 'hand', + 'medium-light skin tone', + 'uc9', + 'diversity', + 'body', + 'hands', + 'hi', + 'language', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'body part', + 'anatomy', + 'hand', + 'finger', + 'fingers', + 'hello', + 'greeting', + 'bonjour', + 'bye', + 'ciao', + 'adios', + 'goodbye', + 'hey', + 'holla', + 'my name is', + 'salut', + 'welcome', + 'ПРИВЕТ', + 'tu tapelle' + ], + modifiable: true), + Emoji( + name: 'call me hand: medium skin tone', + char: '\u{1F919}\u{1F3FD}', + shortName: 'call_me_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.handFingersPartial, + keywords: [ + 'call', + 'hand', + 'medium skin tone', + 'uc9', + 'diversity', + 'body', + 'hands', + 'hi', + 'language', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'body part', + 'anatomy', + 'hand', + 'finger', + 'fingers', + 'hello', + 'greeting', + 'bonjour', + 'bye', + 'ciao', + 'adios', + 'goodbye', + 'hey', + 'holla', + 'my name is', + 'salut', + 'welcome', + 'ПРИВЕТ', + 'tu tapelle' + ], + modifiable: true), + Emoji( + name: 'call me hand: medium-dark skin tone', + char: '\u{1F919}\u{1F3FE}', + shortName: 'call_me_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.handFingersPartial, + keywords: [ + 'call', + 'hand', + 'medium-dark skin tone', + 'uc9', + 'diversity', + 'body', + 'hands', + 'hi', + 'language', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'body part', + 'anatomy', + 'hand', + 'finger', + 'fingers', + 'hello', + 'greeting', + 'bonjour', + 'bye', + 'ciao', + 'adios', + 'goodbye', + 'hey', + 'holla', + 'my name is', + 'salut', + 'welcome', + 'ПРИВЕТ', + 'tu tapelle' + ], + modifiable: true), + Emoji( + name: 'call me hand: dark skin tone', + char: '\u{1F919}\u{1F3FF}', + shortName: 'call_me_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.handFingersPartial, + keywords: [ + 'call', + 'dark skin tone', + 'hand', + 'uc9', + 'diversity', + 'body', + 'hands', + 'hi', + 'language', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'body part', + 'anatomy', + 'hand', + 'finger', + 'fingers', + 'hello', + 'greeting', + 'bonjour', + 'bye', + 'ciao', + 'adios', + 'goodbye', + 'hey', + 'holla', + 'my name is', + 'salut', + 'welcome', + 'ПРИВЕТ', + 'tu tapelle' + ], + modifiable: true), + Emoji( + name: 'flexed biceps', + char: '\u{1F4AA}', + shortName: 'muscle', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.bodyParts, + keywords: [ + 'biceps', + 'comic', + 'flex', + 'muscle', + 'uc6', + 'sport', + 'diversity', + 'body', + 'hands', + 'flex', + 'weight lifting', + 'win', + 'feminist', + 'boys night', + 'power', + 'handsome', + 'festivus', + 'protest', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'body part', + 'anatomy', + 'hand', + 'finger', + 'fingers', + 'strong', + 'weight lifter', + 'winning', + 'killing it', + 'crushing it', + 'victory', + 'victorious', + 'success', + 'successful', + 'winner', + 'feminism', + 'strong woman', + 'guys night', + 'stud', + 'blm', + 'demonstration' + ]), + Emoji( + name: 'flexed biceps: light skin tone', + char: '\u{1F4AA}\u{1F3FB}', + shortName: 'muscle_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.bodyParts, + keywords: [ + 'biceps', + 'comic', + 'flex', + 'light skin tone', + 'muscle', + 'uc8', + 'sport', + 'diversity', + 'body', + 'hands', + 'flex', + 'weight lifting', + 'win', + 'feminist', + 'boys night', + 'power', + 'handsome', + 'festivus', + 'protest', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'body part', + 'anatomy', + 'hand', + 'finger', + 'fingers', + 'strong', + 'weight lifter', + 'winning', + 'killing it', + 'crushing it', + 'victory', + 'victorious', + 'success', + 'successful', + 'winner', + 'feminism', + 'strong woman', + 'guys night', + 'stud', + 'blm', + 'demonstration' + ], + modifiable: true), + Emoji( + name: 'flexed biceps: medium-light skin tone', + char: '\u{1F4AA}\u{1F3FC}', + shortName: 'muscle_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.bodyParts, + keywords: [ + 'biceps', + 'comic', + 'flex', + 'medium-light skin tone', + 'muscle', + 'uc8', + 'sport', + 'diversity', + 'body', + 'hands', + 'flex', + 'weight lifting', + 'win', + 'feminist', + 'boys night', + 'power', + 'handsome', + 'festivus', + 'protest', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'body part', + 'anatomy', + 'hand', + 'finger', + 'fingers', + 'strong', + 'weight lifter', + 'winning', + 'killing it', + 'crushing it', + 'victory', + 'victorious', + 'success', + 'successful', + 'winner', + 'feminism', + 'strong woman', + 'guys night', + 'stud', + 'blm', + 'demonstration' + ], + modifiable: true), + Emoji( + name: 'flexed biceps: medium skin tone', + char: '\u{1F4AA}\u{1F3FD}', + shortName: 'muscle_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.bodyParts, + keywords: [ + 'biceps', + 'comic', + 'flex', + 'medium skin tone', + 'muscle', + 'uc8', + 'sport', + 'diversity', + 'body', + 'hands', + 'flex', + 'weight lifting', + 'win', + 'feminist', + 'boys night', + 'power', + 'handsome', + 'festivus', + 'protest', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'body part', + 'anatomy', + 'hand', + 'finger', + 'fingers', + 'strong', + 'weight lifter', + 'winning', + 'killing it', + 'crushing it', + 'victory', + 'victorious', + 'success', + 'successful', + 'winner', + 'feminism', + 'strong woman', + 'guys night', + 'stud', + 'blm', + 'demonstration' + ], + modifiable: true), + Emoji( + name: 'flexed biceps: medium-dark skin tone', + char: '\u{1F4AA}\u{1F3FE}', + shortName: 'muscle_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.bodyParts, + keywords: [ + 'biceps', + 'comic', + 'flex', + 'medium-dark skin tone', + 'muscle', + 'uc8', + 'sport', + 'diversity', + 'body', + 'hands', + 'flex', + 'weight lifting', + 'win', + 'feminist', + 'boys night', + 'power', + 'handsome', + 'festivus', + 'protest', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'body part', + 'anatomy', + 'hand', + 'finger', + 'fingers', + 'strong', + 'weight lifter', + 'winning', + 'killing it', + 'crushing it', + 'victory', + 'victorious', + 'success', + 'successful', + 'winner', + 'feminism', + 'strong woman', + 'guys night', + 'stud', + 'blm', + 'demonstration' + ], + modifiable: true), + Emoji( + name: 'flexed biceps: dark skin tone', + char: '\u{1F4AA}\u{1F3FF}', + shortName: 'muscle_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.bodyParts, + keywords: [ + 'biceps', + 'comic', + 'dark skin tone', + 'flex', + 'muscle', + 'uc8', + 'sport', + 'diversity', + 'body', + 'hands', + 'flex', + 'weight lifting', + 'win', + 'feminist', + 'boys night', + 'power', + 'handsome', + 'festivus', + 'protest', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'body part', + 'anatomy', + 'hand', + 'finger', + 'fingers', + 'strong', + 'weight lifter', + 'winning', + 'killing it', + 'crushing it', + 'victory', + 'victorious', + 'success', + 'successful', + 'winner', + 'feminism', + 'strong woman', + 'guys night', + 'stud', + 'blm', + 'demonstration' + ], + modifiable: true), + Emoji( + name: 'mechanical arm', + char: '\u{1F9BE}', + shortName: 'mechanical_arm', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.bodyParts, + keywords: [ + 'uc12', + 'body', + 'science', + 'handicap', + 'prosthetic', + 'fake arm', + 'accessibility', + 'body part', + 'anatomy', + 'lab', + 'disabled', + 'disability', + 'prosthetics', + 'robotic arm' + ]), + Emoji( + name: 'middle finger', + char: '\u{1F595}', + shortName: 'middle_finger', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.handSingleFinger, + keywords: [ + 'finger', + 'hand', + 'uc7', + 'diversity', + 'penis', + 'body', + 'hands', + 'angry', + 'middle finger', + 'sex', + 'language', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'dick', + 'body part', + 'anatomy', + 'hand', + 'finger', + 'fingers', + 'upset', + 'pissed', + 'pissed off', + 'unhappy', + 'frustrated', + 'anger', + 'rage', + 'frustration', + 'furious', + 'mad', + 'flipping off', + 'fu', + 'the finger', + 'fuck you', + 'fuck off', + 'fuck', + 'fucking', + 'horny', + 'humping' + ]), + Emoji( + name: 'middle finger: light skin tone', + char: '\u{1F595}\u{1F3FB}', + shortName: 'middle_finger_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.handSingleFinger, + keywords: [ + 'finger', + 'hand', + 'light skin tone', + 'uc8', + 'diversity', + 'penis', + 'body', + 'hands', + 'angry', + 'middle finger', + 'sex', + 'language', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'dick', + 'body part', + 'anatomy', + 'hand', + 'finger', + 'fingers', + 'upset', + 'pissed', + 'pissed off', + 'unhappy', + 'frustrated', + 'anger', + 'rage', + 'frustration', + 'furious', + 'mad', + 'flipping off', + 'fu', + 'the finger', + 'fuck you', + 'fuck off', + 'fuck', + 'fucking', + 'horny', + 'humping' + ], + modifiable: true), + Emoji( + name: 'middle finger: medium-light skin tone', + char: '\u{1F595}\u{1F3FC}', + shortName: 'middle_finger_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.handSingleFinger, + keywords: [ + 'finger', + 'hand', + 'medium-light skin tone', + 'uc8', + 'diversity', + 'penis', + 'body', + 'hands', + 'angry', + 'middle finger', + 'sex', + 'language', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'dick', + 'body part', + 'anatomy', + 'hand', + 'finger', + 'fingers', + 'upset', + 'pissed', + 'pissed off', + 'unhappy', + 'frustrated', + 'anger', + 'rage', + 'frustration', + 'furious', + 'mad', + 'flipping off', + 'fu', + 'the finger', + 'fuck you', + 'fuck off', + 'fuck', + 'fucking', + 'horny', + 'humping' + ], + modifiable: true), + Emoji( + name: 'middle finger: medium skin tone', + char: '\u{1F595}\u{1F3FD}', + shortName: 'middle_finger_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.handSingleFinger, + keywords: [ + 'finger', + 'hand', + 'medium skin tone', + 'uc8', + 'diversity', + 'penis', + 'body', + 'hands', + 'angry', + 'middle finger', + 'sex', + 'language', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'dick', + 'body part', + 'anatomy', + 'hand', + 'finger', + 'fingers', + 'upset', + 'pissed', + 'pissed off', + 'unhappy', + 'frustrated', + 'anger', + 'rage', + 'frustration', + 'furious', + 'mad', + 'flipping off', + 'fu', + 'the finger', + 'fuck you', + 'fuck off', + 'fuck', + 'fucking', + 'horny', + 'humping' + ], + modifiable: true), + Emoji( + name: 'middle finger: medium-dark skin tone', + char: '\u{1F595}\u{1F3FE}', + shortName: 'middle_finger_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.handSingleFinger, + keywords: [ + 'finger', + 'hand', + 'medium-dark skin tone', + 'uc8', + 'diversity', + 'penis', + 'body', + 'hands', + 'angry', + 'middle finger', + 'sex', + 'language', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'dick', + 'body part', + 'anatomy', + 'hand', + 'finger', + 'fingers', + 'upset', + 'pissed', + 'pissed off', + 'unhappy', + 'frustrated', + 'anger', + 'rage', + 'frustration', + 'furious', + 'mad', + 'flipping off', + 'fu', + 'the finger', + 'fuck you', + 'fuck off', + 'fuck', + 'fucking', + 'horny', + 'humping' + ], + modifiable: true), + Emoji( + name: 'middle finger: dark skin tone', + char: '\u{1F595}\u{1F3FF}', + shortName: 'middle_finger_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.handSingleFinger, + keywords: [ + 'dark skin tone', + 'finger', + 'hand', + 'uc8', + 'diversity', + 'penis', + 'body', + 'hands', + 'angry', + 'middle finger', + 'sex', + 'language', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'dick', + 'body part', + 'anatomy', + 'hand', + 'finger', + 'fingers', + 'upset', + 'pissed', + 'pissed off', + 'unhappy', + 'frustrated', + 'anger', + 'rage', + 'frustration', + 'furious', + 'mad', + 'flipping off', + 'fu', + 'the finger', + 'fuck you', + 'fuck off', + 'fuck', + 'fucking', + 'horny', + 'humping' + ], + modifiable: true), + Emoji( + name: 'writing hand', + char: '\u{270D}\u{FE0F}', + shortName: 'writing_hand', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.handProp, + keywords: [ + 'hand', + 'write', + 'uc1', + 'diversity', + 'body', + 'hands', + 'write', + 'color', + 'correct', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'body part', + 'anatomy', + 'hand', + 'finger', + 'fingers', + 'writing', + 'colour', + 'coloring', + 'colouring', + 'drawing', + 'marker', + 'sketch', + 'passing grade' + ]), + Emoji( + name: 'writing hand: light skin tone', + char: '\u{270D}\u{1F3FB}', + shortName: 'writing_hand_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.handProp, + keywords: [ + 'hand', + 'light skin tone', + 'write', + 'uc8', + 'diversity', + 'body', + 'hands', + 'write', + 'color', + 'correct', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'body part', + 'anatomy', + 'hand', + 'finger', + 'fingers', + 'writing', + 'colour', + 'coloring', + 'colouring', + 'drawing', + 'marker', + 'sketch', + 'passing grade' + ], + modifiable: true), + Emoji( + name: 'writing hand: medium-light skin tone', + char: '\u{270D}\u{1F3FC}', + shortName: 'writing_hand_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.handProp, + keywords: [ + 'hand', + 'medium-light skin tone', + 'write', + 'uc8', + 'diversity', + 'body', + 'hands', + 'write', + 'color', + 'correct', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'body part', + 'anatomy', + 'hand', + 'finger', + 'fingers', + 'writing', + 'colour', + 'coloring', + 'colouring', + 'drawing', + 'marker', + 'sketch', + 'passing grade' + ], + modifiable: true), + Emoji( + name: 'writing hand: medium skin tone', + char: '\u{270D}\u{1F3FD}', + shortName: 'writing_hand_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.handProp, + keywords: [ + 'hand', + 'medium skin tone', + 'write', + 'uc8', + 'diversity', + 'body', + 'hands', + 'write', + 'color', + 'correct', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'body part', + 'anatomy', + 'hand', + 'finger', + 'fingers', + 'writing', + 'colour', + 'coloring', + 'colouring', + 'drawing', + 'marker', + 'sketch', + 'passing grade' + ], + modifiable: true), + Emoji( + name: 'writing hand: medium-dark skin tone', + char: '\u{270D}\u{1F3FE}', + shortName: 'writing_hand_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.handProp, + keywords: [ + 'hand', + 'medium-dark skin tone', + 'write', + 'uc8', + 'diversity', + 'body', + 'hands', + 'write', + 'color', + 'correct', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'body part', + 'anatomy', + 'hand', + 'finger', + 'fingers', + 'writing', + 'colour', + 'coloring', + 'colouring', + 'drawing', + 'marker', + 'sketch', + 'passing grade' + ], + modifiable: true), + Emoji( + name: 'writing hand: dark skin tone', + char: '\u{270D}\u{1F3FF}', + shortName: 'writing_hand_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.handProp, + keywords: [ + 'dark skin tone', + 'hand', + 'write', + 'uc8', + 'diversity', + 'body', + 'hands', + 'write', + 'color', + 'correct', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'body part', + 'anatomy', + 'hand', + 'finger', + 'fingers', + 'writing', + 'colour', + 'coloring', + 'colouring', + 'drawing', + 'marker', + 'sketch', + 'passing grade' + ], + modifiable: true), + Emoji( + name: 'folded hands', + char: '\u{1F64F}', + shortName: 'pray', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.hands, + keywords: [ + 'ask', + 'bow', + 'folded', + 'gesture', + 'hand', + 'please', + 'pray', + 'thanks', + 'uc6', + 'diversity', + 'body', + 'hands', + 'hi', + 'luck', + 'thank you', + 'pray', + 'scientology', + 'jesus', + 'pleased', + 'yoga', + 'easter', + 'begging', + 'help', + 'hope', + 'soul', + 'language', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'body part', + 'anatomy', + 'hand', + 'finger', + 'fingers', + 'hello', + 'greeting', + 'bonjour', + 'bye', + 'ciao', + 'adios', + 'goodbye', + 'hey', + 'holla', + 'my name is', + 'salut', + 'welcome', + 'ПРИВЕТ', + 'tu tapelle', + 'good luck', + 'lucky', + 'thanks', + 'thankful', + 'praise', + 'gracias', + 'merci', + 'thankyou', + 'acceptable', + 'prayer', + 'praying', + 'prayers', + 'grateful', + 'sorry', + 'heaven', + 'bless', + 'faith', + 'holy', + 'spirit', + 'hopeful', + 'blessed', + 'preach', + 'offering', + 'scientologist', + 'please', + 'chill', + 'confident', + 'content', + 'meditation', + 'meditate', + 'zen', + 'om', + 'aum', + 'swear', + 'promise' + ]), + Emoji( + name: 'folded hands: light skin tone', + char: '\u{1F64F}\u{1F3FB}', + shortName: 'pray_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.hands, + keywords: [ + 'ask', + 'bow', + 'folded', + 'gesture', + 'hand', + 'light skin tone', + 'please', + 'pray', + 'thanks', + 'uc8', + 'diversity', + 'body', + 'hands', + 'hi', + 'luck', + 'thank you', + 'pray', + 'scientology', + 'jesus', + 'pleased', + 'yoga', + 'easter', + 'begging', + 'help', + 'hope', + 'soul', + 'language', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'body part', + 'anatomy', + 'hand', + 'finger', + 'fingers', + 'hello', + 'greeting', + 'bonjour', + 'bye', + 'ciao', + 'adios', + 'goodbye', + 'hey', + 'holla', + 'my name is', + 'salut', + 'welcome', + 'ПРИВЕТ', + 'tu tapelle', + 'good luck', + 'lucky', + 'thanks', + 'thankful', + 'praise', + 'gracias', + 'merci', + 'thankyou', + 'acceptable', + 'prayer', + 'praying', + 'prayers', + 'grateful', + 'sorry', + 'heaven', + 'bless', + 'faith', + 'holy', + 'spirit', + 'hopeful', + 'blessed', + 'preach', + 'offering', + 'scientologist', + 'please', + 'chill', + 'confident', + 'content', + 'meditation', + 'meditate', + 'zen', + 'om', + 'aum', + 'swear', + 'promise' + ], + modifiable: true), + Emoji( + name: 'folded hands: medium-light skin tone', + char: '\u{1F64F}\u{1F3FC}', + shortName: 'pray_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.hands, + keywords: [ + 'ask', + 'bow', + 'folded', + 'gesture', + 'hand', + 'medium-light skin tone', + 'please', + 'pray', + 'thanks', + 'uc8', + 'diversity', + 'body', + 'hands', + 'hi', + 'luck', + 'thank you', + 'pray', + 'scientology', + 'jesus', + 'pleased', + 'yoga', + 'easter', + 'begging', + 'help', + 'hope', + 'soul', + 'language', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'body part', + 'anatomy', + 'hand', + 'finger', + 'fingers', + 'hello', + 'greeting', + 'bonjour', + 'bye', + 'ciao', + 'adios', + 'goodbye', + 'hey', + 'holla', + 'my name is', + 'salut', + 'welcome', + 'ПРИВЕТ', + 'tu tapelle', + 'good luck', + 'lucky', + 'thanks', + 'thankful', + 'praise', + 'gracias', + 'merci', + 'thankyou', + 'acceptable', + 'prayer', + 'praying', + 'prayers', + 'grateful', + 'sorry', + 'heaven', + 'bless', + 'faith', + 'holy', + 'spirit', + 'hopeful', + 'blessed', + 'preach', + 'offering', + 'scientologist', + 'please', + 'chill', + 'confident', + 'content', + 'meditation', + 'meditate', + 'zen', + 'om', + 'aum', + 'swear', + 'promise' + ], + modifiable: true), + Emoji( + name: 'folded hands: medium skin tone', + char: '\u{1F64F}\u{1F3FD}', + shortName: 'pray_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.hands, + keywords: [ + 'ask', + 'bow', + 'folded', + 'gesture', + 'hand', + 'medium skin tone', + 'please', + 'pray', + 'thanks', + 'uc8', + 'diversity', + 'body', + 'hands', + 'hi', + 'luck', + 'thank you', + 'pray', + 'scientology', + 'jesus', + 'pleased', + 'yoga', + 'easter', + 'begging', + 'help', + 'hope', + 'soul', + 'language', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'body part', + 'anatomy', + 'hand', + 'finger', + 'fingers', + 'hello', + 'greeting', + 'bonjour', + 'bye', + 'ciao', + 'adios', + 'goodbye', + 'hey', + 'holla', + 'my name is', + 'salut', + 'welcome', + 'ПРИВЕТ', + 'tu tapelle', + 'good luck', + 'lucky', + 'thanks', + 'thankful', + 'praise', + 'gracias', + 'merci', + 'thankyou', + 'acceptable', + 'prayer', + 'praying', + 'prayers', + 'grateful', + 'sorry', + 'heaven', + 'bless', + 'faith', + 'holy', + 'spirit', + 'hopeful', + 'blessed', + 'preach', + 'offering', + 'scientologist', + 'please', + 'chill', + 'confident', + 'content', + 'meditation', + 'meditate', + 'zen', + 'om', + 'aum', + 'swear', + 'promise' + ], + modifiable: true), + Emoji( + name: 'folded hands: medium-dark skin tone', + char: '\u{1F64F}\u{1F3FE}', + shortName: 'pray_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.hands, + keywords: [ + 'ask', + 'bow', + 'folded', + 'gesture', + 'hand', + 'medium-dark skin tone', + 'please', + 'pray', + 'thanks', + 'uc8', + 'diversity', + 'body', + 'hands', + 'hi', + 'luck', + 'thank you', + 'pray', + 'scientology', + 'jesus', + 'pleased', + 'yoga', + 'easter', + 'begging', + 'help', + 'hope', + 'soul', + 'language', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'body part', + 'anatomy', + 'hand', + 'finger', + 'fingers', + 'hello', + 'greeting', + 'bonjour', + 'bye', + 'ciao', + 'adios', + 'goodbye', + 'hey', + 'holla', + 'my name is', + 'salut', + 'welcome', + 'ПРИВЕТ', + 'tu tapelle', + 'good luck', + 'lucky', + 'thanks', + 'thankful', + 'praise', + 'gracias', + 'merci', + 'thankyou', + 'acceptable', + 'prayer', + 'praying', + 'prayers', + 'grateful', + 'sorry', + 'heaven', + 'bless', + 'faith', + 'holy', + 'spirit', + 'hopeful', + 'blessed', + 'preach', + 'offering', + 'scientologist', + 'please', + 'chill', + 'confident', + 'content', + 'meditation', + 'meditate', + 'zen', + 'om', + 'aum', + 'swear', + 'promise' + ], + modifiable: true), + Emoji( + name: 'folded hands: dark skin tone', + char: '\u{1F64F}\u{1F3FF}', + shortName: 'pray_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.hands, + keywords: [ + 'ask', + 'bow', + 'dark skin tone', + 'folded', + 'gesture', + 'hand', + 'please', + 'pray', + 'thanks', + 'uc8', + 'diversity', + 'body', + 'hands', + 'hi', + 'luck', + 'thank you', + 'pray', + 'scientology', + 'jesus', + 'pleased', + 'yoga', + 'easter', + 'begging', + 'help', + 'hope', + 'soul', + 'language', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'body part', + 'anatomy', + 'hand', + 'finger', + 'fingers', + 'hello', + 'greeting', + 'bonjour', + 'bye', + 'ciao', + 'adios', + 'goodbye', + 'hey', + 'holla', + 'my name is', + 'salut', + 'welcome', + 'ПРИВЕТ', + 'tu tapelle', + 'good luck', + 'lucky', + 'thanks', + 'thankful', + 'praise', + 'gracias', + 'merci', + 'thankyou', + 'acceptable', + 'prayer', + 'praying', + 'prayers', + 'grateful', + 'sorry', + 'heaven', + 'bless', + 'faith', + 'holy', + 'spirit', + 'hopeful', + 'blessed', + 'preach', + 'offering', + 'scientologist', + 'please', + 'chill', + 'confident', + 'content', + 'meditation', + 'meditate', + 'zen', + 'om', + 'aum', + 'swear', + 'promise' + ], + modifiable: true), + Emoji( + name: 'foot', + char: '\u{1F9B6}', + shortName: 'foot', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.bodyParts, + keywords: [ + 'uc11', + 'diversity', + 'body', + 'feet', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'body part', + 'anatomy', + 'toes', + 'heel', + 'ankle' + ]), + Emoji( + name: 'foot: light skin tone', + char: '\u{1F9B6}\u{1F3FB}', + shortName: 'foot_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.bodyParts, + keywords: [ + 'uc11', + 'diversity', + 'body', + 'feet', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'body part', + 'anatomy', + 'toes', + 'heel', + 'ankle' + ], + modifiable: true), + Emoji( + name: 'foot: medium-light skin tone', + char: '\u{1F9B6}\u{1F3FC}', + shortName: 'foot_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.bodyParts, + keywords: [ + 'uc11', + 'diversity', + 'body', + 'feet', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'body part', + 'anatomy', + 'toes', + 'heel', + 'ankle' + ], + modifiable: true), + Emoji( + name: 'foot: medium skin tone', + char: '\u{1F9B6}\u{1F3FD}', + shortName: 'foot_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.bodyParts, + keywords: [ + 'uc11', + 'diversity', + 'body', + 'feet', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'body part', + 'anatomy', + 'toes', + 'heel', + 'ankle' + ], + modifiable: true), + Emoji( + name: 'foot: medium-dark skin tone', + char: '\u{1F9B6}\u{1F3FE}', + shortName: 'foot_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.bodyParts, + keywords: [ + 'uc11', + 'diversity', + 'body', + 'feet', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'body part', + 'anatomy', + 'toes', + 'heel', + 'ankle' + ], + modifiable: true), + Emoji( + name: 'foot: dark skin tone', + char: '\u{1F9B6}\u{1F3FF}', + shortName: 'foot_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.bodyParts, + keywords: [ + 'uc11', + 'diversity', + 'body', + 'feet', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'body part', + 'anatomy', + 'toes', + 'heel', + 'ankle' + ], + modifiable: true), + Emoji( + name: 'leg', + char: '\u{1F9B5}', + shortName: 'leg', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.bodyParts, + keywords: [ + 'uc11', + 'diversity', + 'body', + 'feet', + 'knee', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'body part', + 'anatomy', + 'toes', + 'heel', + 'ankle', + 'thigh', + 'calf' + ]), + Emoji( + name: 'leg: light skin tone', + char: '\u{1F9B5}\u{1F3FB}', + shortName: 'leg_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.bodyParts, + keywords: [ + 'uc11', + 'diversity', + 'body', + 'feet', + 'knee', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'body part', + 'anatomy', + 'toes', + 'heel', + 'ankle', + 'thigh', + 'calf' + ], + modifiable: true), + Emoji( + name: 'leg: medium-light skin tone', + char: '\u{1F9B5}\u{1F3FC}', + shortName: 'leg_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.bodyParts, + keywords: [ + 'uc11', + 'diversity', + 'body', + 'feet', + 'knee', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'body part', + 'anatomy', + 'toes', + 'heel', + 'ankle', + 'thigh', + 'calf' + ], + modifiable: true), + Emoji( + name: 'leg: medium skin tone', + char: '\u{1F9B5}\u{1F3FD}', + shortName: 'leg_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.bodyParts, + keywords: [ + 'uc11', + 'diversity', + 'body', + 'feet', + 'knee', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'body part', + 'anatomy', + 'toes', + 'heel', + 'ankle', + 'thigh', + 'calf' + ], + modifiable: true), + Emoji( + name: 'leg: medium-dark skin tone', + char: '\u{1F9B5}\u{1F3FE}', + shortName: 'leg_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.bodyParts, + keywords: [ + 'uc11', + 'diversity', + 'body', + 'feet', + 'knee', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'body part', + 'anatomy', + 'toes', + 'heel', + 'ankle', + 'thigh', + 'calf' + ], + modifiable: true), + Emoji( + name: 'leg: dark skin tone', + char: '\u{1F9B5}\u{1F3FF}', + shortName: 'leg_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.bodyParts, + keywords: [ + 'uc11', + 'diversity', + 'body', + 'feet', + 'knee', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'body part', + 'anatomy', + 'toes', + 'heel', + 'ankle', + 'thigh', + 'calf' + ], + modifiable: true), + Emoji( + name: 'mechanical leg', + char: '\u{1F9BF}', + shortName: 'mechanical_leg', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.bodyParts, + keywords: [ + 'uc12', + 'body', + 'science', + 'handicap', + 'feet', + 'knee', + 'prosthetic', + 'fake leg', + 'accessibility', + 'medical', + 'body part', + 'anatomy', + 'lab', + 'disabled', + 'disability', + 'toes', + 'heel', + 'ankle', + 'thigh', + 'calf', + 'prosthetics', + 'robotic leg' + ]), + Emoji( + name: 'lipstick', + char: '\u{1F484}', + shortName: 'lipstick', + emojiGroup: EmojiGroup.objects, + emojiSubgroup: EmojiSubgroup.clothing, + keywords: [ + 'cosmetics', + 'makeup', + 'uc6', + 'fashion', + 'women', + 'love', + 'sexy', + 'lipstick', + 'beautiful', + 'girls night', + 'color', + 'mirror', + 'clothes', + 'clothing', + 'style', + 'woman', + 'female', + 'i love you', + 'te amo', + "je t'aime", + 'anniversary', + 'lovin', + 'amour', + 'aimer', + 'amor', + 'valentines day', + 'enamour', + 'lovey', + 'mouth', + 'mouths', + 'makeup', + 'lip', + 'lips', + 'cute', + 'pretty', + 'adorable', + 'adore', + 'beauty', + 'cutie', + 'babe', + 'lovely', + 'ladies night', + 'girls only', + 'girlfriend', + 'colour', + 'coloring', + 'colouring', + 'drawing', + 'marker', + 'sketch' + ]), + Emoji( + name: 'kiss mark', + char: '\u{1F48B}', + shortName: 'kiss', + emojiGroup: EmojiGroup.smileysEmotion, + emojiSubgroup: EmojiSubgroup.emotion, + keywords: [ + 'kiss', + 'lips', + 'uc6', + 'women', + 'love', + 'sexy', + 'lipstick', + 'beautiful', + 'girls night', + 'pink', + 'kisses', + 'mirror', + 'porn', + 'woman', + 'female', + 'i love you', + 'te amo', + "je t'aime", + 'anniversary', + 'lovin', + 'amour', + 'aimer', + 'amor', + 'valentines day', + 'enamour', + 'lovey', + 'mouth', + 'mouths', + 'makeup', + 'lip', + 'lips', + 'cute', + 'pretty', + 'adorable', + 'adore', + 'beauty', + 'cutie', + 'babe', + 'lovely', + 'ladies night', + 'girls only', + 'girlfriend', + 'rose', + 'bisous', + 'beijos', + 'besos', + 'bise', + 'blowing kisses', + 'kissy' + ]), + Emoji( + name: 'mouth', + char: '\u{1F444}', + shortName: 'lips', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.bodyParts, + keywords: [ + 'lips', + 'uc6', + 'women', + 'body', + 'sexy', + 'lipstick', + 'beautiful', + 'porn', + 'woman', + 'female', + 'body part', + 'anatomy', + 'mouth', + 'mouths', + 'makeup', + 'lip', + 'lips', + 'cute', + 'pretty', + 'adorable', + 'adore', + 'beauty', + 'cutie', + 'babe', + 'lovely' + ]), + Emoji( + name: 'tooth', + char: '\u{1F9B7}', + shortName: 'tooth', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.bodyParts, + keywords: [ + 'uc11', + 'body', + 'teeth', + 'bite', + 'bones', + 'medical', + 'body part', + 'anatomy', + 'dentist', + 'Os', + 'hueso' + ]), + Emoji( + name: 'bone', + char: '\u{1F9B4}', + shortName: 'bone', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.bodyParts, + keywords: [ + 'uc11', + 'body', + 'science', + 'mystery', + 'bones', + 'medical', + 'body part', + 'anatomy', + 'lab', + 'Os', + 'hueso' + ]), + Emoji( + name: 'tongue', + char: '\u{1F445}', + shortName: 'tongue', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.bodyParts, + keywords: [ + 'body', + 'uc6', + 'body', + 'sexy', + 'sex', + 'lipstick', + 'pussy', + 'pink', + 'lick', + 'porn', + 'tongue', + 'medical', + 'body part', + 'anatomy', + 'fuck', + 'fucking', + 'horny', + 'humping', + 'mouth', + 'mouths', + 'makeup', + 'lip', + 'lips', + 'condom', + 'rose', + 'toung', + 'tounge' + ]), + Emoji( + name: 'ear', + char: '\u{1F442}', + shortName: 'ear', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.bodyParts, + keywords: [ + 'body', + 'uc6', + 'diversity', + 'body', + 'sound', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'body part', + 'anatomy', + 'volume', + 'speaker', + 'loud', + 'mic', + 'audio', + 'hear' + ]), + Emoji( + name: 'ear: light skin tone', + char: '\u{1F442}\u{1F3FB}', + shortName: 'ear_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.bodyParts, + keywords: [ + 'body', + 'light skin tone', + 'uc8', + 'diversity', + 'body', + 'sound', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'body part', + 'anatomy', + 'volume', + 'speaker', + 'loud', + 'mic', + 'audio', + 'hear' + ], + modifiable: true), + Emoji( + name: 'ear: medium-light skin tone', + char: '\u{1F442}\u{1F3FC}', + shortName: 'ear_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.bodyParts, + keywords: [ + 'body', + 'medium-light skin tone', + 'uc8', + 'diversity', + 'body', + 'sound', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'body part', + 'anatomy', + 'volume', + 'speaker', + 'loud', + 'mic', + 'audio', + 'hear' + ], + modifiable: true), + Emoji( + name: 'ear: medium skin tone', + char: '\u{1F442}\u{1F3FD}', + shortName: 'ear_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.bodyParts, + keywords: [ + 'body', + 'medium skin tone', + 'uc8', + 'diversity', + 'body', + 'sound', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'body part', + 'anatomy', + 'volume', + 'speaker', + 'loud', + 'mic', + 'audio', + 'hear' + ], + modifiable: true), + Emoji( + name: 'ear: medium-dark skin tone', + char: '\u{1F442}\u{1F3FE}', + shortName: 'ear_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.bodyParts, + keywords: [ + 'body', + 'medium-dark skin tone', + 'uc8', + 'diversity', + 'body', + 'sound', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'body part', + 'anatomy', + 'volume', + 'speaker', + 'loud', + 'mic', + 'audio', + 'hear' + ], + modifiable: true), + Emoji( + name: 'ear: dark skin tone', + char: '\u{1F442}\u{1F3FF}', + shortName: 'ear_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.bodyParts, + keywords: [ + 'body', + 'dark skin tone', + 'uc8', + 'diversity', + 'body', + 'sound', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'body part', + 'anatomy', + 'volume', + 'speaker', + 'loud', + 'mic', + 'audio', + 'hear' + ], + modifiable: true), + Emoji( + name: 'ear with hearing aid', + char: '\u{1F9BB}', + shortName: 'ear_with_hearing_aid', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.bodyParts, + keywords: [ + 'uc12', + 'old people', + 'diversity', + 'earphone', + 'sound', + 'deaf', + 'accessibility', + 'medical', + 'grandparents', + 'elderly', + 'grandma', + 'grandpa', + 'grandmother', + 'grandfather', + 'mamie', + 'papy', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'earbud', + 'volume', + 'speaker', + 'loud', + 'mic', + 'audio', + 'hear', + 'hard of hearing' + ]), + Emoji( + name: 'ear with hearing aid: light skin tone', + char: '\u{1F9BB}\u{1F3FB}', + shortName: 'ear_with_hearing_aid_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.bodyParts, + keywords: [ + 'uc12', + 'old people', + 'diversity', + 'earphone', + 'sound', + 'deaf', + 'accessibility', + 'medical', + 'grandparents', + 'elderly', + 'grandma', + 'grandpa', + 'grandmother', + 'grandfather', + 'mamie', + 'papy', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'earbud', + 'volume', + 'speaker', + 'loud', + 'mic', + 'audio', + 'hear', + 'hard of hearing' + ], + modifiable: true), + Emoji( + name: 'ear with hearing aid: medium-light skin tone', + char: '\u{1F9BB}\u{1F3FC}', + shortName: 'ear_with_hearing_aid_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.bodyParts, + keywords: [ + 'uc12', + 'old people', + 'diversity', + 'earphone', + 'sound', + 'deaf', + 'accessibility', + 'medical', + 'grandparents', + 'elderly', + 'grandma', + 'grandpa', + 'grandmother', + 'grandfather', + 'mamie', + 'papy', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'earbud', + 'volume', + 'speaker', + 'loud', + 'mic', + 'audio', + 'hear', + 'hard of hearing' + ], + modifiable: true), + Emoji( + name: 'ear with hearing aid: medium skin tone', + char: '\u{1F9BB}\u{1F3FD}', + shortName: 'ear_with_hearing_aid_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.bodyParts, + keywords: [ + 'uc12', + 'old people', + 'diversity', + 'earphone', + 'sound', + 'deaf', + 'accessibility', + 'medical', + 'grandparents', + 'elderly', + 'grandma', + 'grandpa', + 'grandmother', + 'grandfather', + 'mamie', + 'papy', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'earbud', + 'volume', + 'speaker', + 'loud', + 'mic', + 'audio', + 'hear', + 'hard of hearing' + ], + modifiable: true), + Emoji( + name: 'ear with hearing aid: medium-dark skin tone', + char: '\u{1F9BB}\u{1F3FE}', + shortName: 'ear_with_hearing_aid_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.bodyParts, + keywords: [ + 'uc12', + 'old people', + 'diversity', + 'earphone', + 'sound', + 'deaf', + 'accessibility', + 'medical', + 'grandparents', + 'elderly', + 'grandma', + 'grandpa', + 'grandmother', + 'grandfather', + 'mamie', + 'papy', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'earbud', + 'volume', + 'speaker', + 'loud', + 'mic', + 'audio', + 'hear', + 'hard of hearing' + ], + modifiable: true), + Emoji( + name: 'ear with hearing aid: dark skin tone', + char: '\u{1F9BB}\u{1F3FF}', + shortName: 'ear_with_hearing_aid_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.bodyParts, + keywords: [ + 'uc12', + 'old people', + 'diversity', + 'earphone', + 'sound', + 'deaf', + 'accessibility', + 'medical', + 'grandparents', + 'elderly', + 'grandma', + 'grandpa', + 'grandmother', + 'grandfather', + 'mamie', + 'papy', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'earbud', + 'volume', + 'speaker', + 'loud', + 'mic', + 'audio', + 'hear', + 'hard of hearing' + ], + modifiable: true), + Emoji( + name: 'nose', + char: '\u{1F443}', + shortName: 'nose', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.bodyParts, + keywords: [ + 'body', + 'uc6', + 'diversity', + 'body', + 'stinky', + 'booger', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'body part', + 'anatomy', + 'smell', + 'stink', + 'odor' + ]), + Emoji( + name: 'nose: light skin tone', + char: '\u{1F443}\u{1F3FB}', + shortName: 'nose_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.bodyParts, + keywords: [ + 'body', + 'light skin tone', + 'uc8', + 'diversity', + 'body', + 'stinky', + 'booger', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'body part', + 'anatomy', + 'smell', + 'stink', + 'odor' + ], + modifiable: true), + Emoji( + name: 'nose: medium-light skin tone', + char: '\u{1F443}\u{1F3FC}', + shortName: 'nose_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.bodyParts, + keywords: [ + 'body', + 'medium-light skin tone', + 'uc8', + 'diversity', + 'body', + 'stinky', + 'booger', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'body part', + 'anatomy', + 'smell', + 'stink', + 'odor' + ], + modifiable: true), + Emoji( + name: 'nose: medium skin tone', + char: '\u{1F443}\u{1F3FD}', + shortName: 'nose_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.bodyParts, + keywords: [ + 'body', + 'medium skin tone', + 'uc8', + 'diversity', + 'body', + 'stinky', + 'booger', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'body part', + 'anatomy', + 'smell', + 'stink', + 'odor' + ], + modifiable: true), + Emoji( + name: 'nose: medium-dark skin tone', + char: '\u{1F443}\u{1F3FE}', + shortName: 'nose_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.bodyParts, + keywords: [ + 'body', + 'medium-dark skin tone', + 'uc8', + 'diversity', + 'body', + 'stinky', + 'booger', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'body part', + 'anatomy', + 'smell', + 'stink', + 'odor' + ], + modifiable: true), + Emoji( + name: 'nose: dark skin tone', + char: '\u{1F443}\u{1F3FF}', + shortName: 'nose_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.bodyParts, + keywords: [ + 'body', + 'dark skin tone', + 'uc8', + 'diversity', + 'body', + 'stinky', + 'booger', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'body part', + 'anatomy', + 'smell', + 'stink', + 'odor' + ], + modifiable: true), + Emoji( + name: 'footprints', + char: '\u{1F463}', + shortName: 'footprints', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personSymbol, + keywords: [ + 'clothing', + 'footprint', + 'print', + 'uc6', + 'baby', + 'paws', + 'feet', + 'kid', + 'babies', + 'infant', + 'infants', + 'crying kid', + 'bebe', + 'little', + 'petite', + 'bambino', + 'toes', + 'heel', + 'ankle' + ]), + Emoji( + name: 'eye', + char: '\u{1F441}\u{FE0F}', + shortName: 'eye', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.bodyParts, + keywords: [ + 'body', + 'uc7', + 'body', + 'eyes', + 'search', + 'medical', + 'body part', + 'anatomy', + 'eye', + 'eyebrow', + 'look', + 'find', + 'looking', + 'see' + ]), + Emoji( + name: 'eyes', + char: '\u{1F440}', + shortName: 'eyes', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.bodyParts, + keywords: [ + 'eye', + 'face', + 'uc6', + 'halloween', + 'body', + 'rolling eyes', + 'eyes', + 'google', + 'brain', + 'disney', + 'search', + 'eyeroll', + 'porn', + 'samhain', + 'body part', + 'anatomy', + 'eye roll', + 'side eye', + 'eye', + 'eyebrow', + 'mind', + 'memory', + 'thought', + 'conscience', + 'cartoon', + 'look', + 'find', + 'looking', + 'see' + ]), + Emoji( + name: 'brain', + char: '\u{1F9E0}', + shortName: 'brain', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.bodyParts, + keywords: [ + 'intelligent', + 'uc10', + 'halloween', + 'body', + 'science', + 'nerd', + 'brain', + 'medical', + 'samhain', + 'body part', + 'anatomy', + 'lab', + 'smart', + 'geek', + 'serious', + 'mind', + 'memory', + 'thought', + 'conscience' + ]), + Emoji( + name: 'anatomical heart', + char: '\u{1FAC0}', + shortName: 'anatomical_heart', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.bodyParts, + keywords: [ + 'uc13', + 'body', + 'science', + 'heart', + 'covid', + 'medical', + 'body part', + 'anatomy', + 'lab', + 'hearts', + 'serce', + 'corazón', + 'coração', + 'coeur' + ]), + Emoji( + name: 'lungs', + char: '\u{1FAC1}', + shortName: 'lungs', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.bodyParts, + keywords: [ + 'uc13', + 'body', + 'smoking', + 'science', + 'breathe', + 'covid', + 'medical', + 'body part', + 'anatomy', + 'smoke', + 'cigarette', + 'puff', + 'lab', + 'sigh', + 'inhale' + ]), + Emoji( + name: 'speaking head', + char: '\u{1F5E3}\u{FE0F}', + shortName: 'speaking_head', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personSymbol, + keywords: [ + 'face', + 'head', + 'silhouette', + 'speak', + 'speaking', + 'uc7', + 'talk', + 'sound', + 'language', + 'talking', + 'speech', + 'social', + 'chat', + 'voice', + 'speechless', + 'speak', + 'volume', + 'speaker', + 'loud', + 'mic', + 'audio', + 'hear' + ]), + Emoji( + name: 'bust in silhouette', + char: '\u{1F464}', + shortName: 'bust_in_silhouette', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personSymbol, + keywords: [ + 'bust', + 'silhouette', + 'uc6', + 'facebook', + 'fame', + 'famous', + 'celebrity' + ]), + Emoji( + name: 'busts in silhouette', + char: '\u{1F465}', + shortName: 'busts_in_silhouette', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personSymbol, + keywords: ['bust', 'silhouette', 'uc6', 'magnet', 'facebook', 'network']), + Emoji( + name: 'people hugging', + char: '\u{1FAC2}', + shortName: 'people_hugging', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personSymbol, + keywords: ['uc13', 'hug', 'embrace', 'hugs']), + Emoji( + name: 'baby', + char: '\u{1F476}', + shortName: 'baby', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.person, + keywords: [ + 'baby', + 'young', + 'uc6', + 'diversity', + 'baby', + 'christmas', + 'human', + 'child', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'kid', + 'babies', + 'infant', + 'infants', + 'crying kid', + 'bebe', + 'little', + 'petite', + 'bambino', + 'navidad', + 'xmas', + 'noel', + 'merry christmas', + 'gender', + 'people', + 'children', + 'girl', + 'boy', + 'kids', + 'niño', + 'enfant' + ]), + Emoji( + name: 'baby: light skin tone', + char: '\u{1F476}\u{1F3FB}', + shortName: 'baby_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.person, + keywords: [ + 'baby', + 'light skin tone', + 'young', + 'uc8', + 'diversity', + 'baby', + 'christmas', + 'human', + 'child', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'kid', + 'babies', + 'infant', + 'infants', + 'crying kid', + 'bebe', + 'little', + 'petite', + 'bambino', + 'navidad', + 'xmas', + 'noel', + 'merry christmas', + 'gender', + 'people', + 'children', + 'girl', + 'boy', + 'kids', + 'niño', + 'enfant' + ], + modifiable: true), + Emoji( + name: 'baby: medium-light skin tone', + char: '\u{1F476}\u{1F3FC}', + shortName: 'baby_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.person, + keywords: [ + 'baby', + 'medium-light skin tone', + 'young', + 'uc8', + 'diversity', + 'baby', + 'christmas', + 'human', + 'child', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'kid', + 'babies', + 'infant', + 'infants', + 'crying kid', + 'bebe', + 'little', + 'petite', + 'bambino', + 'navidad', + 'xmas', + 'noel', + 'merry christmas', + 'gender', + 'people', + 'children', + 'girl', + 'boy', + 'kids', + 'niño', + 'enfant' + ], + modifiable: true), + Emoji( + name: 'baby: medium skin tone', + char: '\u{1F476}\u{1F3FD}', + shortName: 'baby_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.person, + keywords: [ + 'baby', + 'medium skin tone', + 'young', + 'uc8', + 'diversity', + 'baby', + 'christmas', + 'human', + 'child', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'kid', + 'babies', + 'infant', + 'infants', + 'crying kid', + 'bebe', + 'little', + 'petite', + 'bambino', + 'navidad', + 'xmas', + 'noel', + 'merry christmas', + 'gender', + 'people', + 'children', + 'girl', + 'boy', + 'kids', + 'niño', + 'enfant' + ], + modifiable: true), + Emoji( + name: 'baby: medium-dark skin tone', + char: '\u{1F476}\u{1F3FE}', + shortName: 'baby_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.person, + keywords: [ + 'baby', + 'medium-dark skin tone', + 'young', + 'uc8', + 'diversity', + 'baby', + 'christmas', + 'human', + 'child', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'kid', + 'babies', + 'infant', + 'infants', + 'crying kid', + 'bebe', + 'little', + 'petite', + 'bambino', + 'navidad', + 'xmas', + 'noel', + 'merry christmas', + 'gender', + 'people', + 'children', + 'girl', + 'boy', + 'kids', + 'niño', + 'enfant' + ], + modifiable: true), + Emoji( + name: 'baby: dark skin tone', + char: '\u{1F476}\u{1F3FF}', + shortName: 'baby_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.person, + keywords: [ + 'baby', + 'dark skin tone', + 'young', + 'uc8', + 'diversity', + 'baby', + 'christmas', + 'human', + 'child', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'kid', + 'babies', + 'infant', + 'infants', + 'crying kid', + 'bebe', + 'little', + 'petite', + 'bambino', + 'navidad', + 'xmas', + 'noel', + 'merry christmas', + 'gender', + 'people', + 'children', + 'girl', + 'boy', + 'kids', + 'niño', + 'enfant' + ], + modifiable: true), + Emoji( + name: 'girl', + char: '\u{1F467}', + shortName: 'girl', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.person, + keywords: [ + 'Virgo', + 'young', + 'zodiac', + 'uc6', + 'diversity', + 'women', + 'beautiful', + 'human', + 'wife', + 'child', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'woman', + 'female', + 'cute', + 'pretty', + 'adorable', + 'adore', + 'beauty', + 'cutie', + 'babe', + 'lovely', + 'gender', + 'people', + 'children', + 'girl', + 'boy', + 'kids', + 'niño', + 'enfant' + ]), + Emoji( + name: 'girl: light skin tone', + char: '\u{1F467}\u{1F3FB}', + shortName: 'girl_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.person, + keywords: [ + 'Virgo', + 'light skin tone', + 'young', + 'zodiac', + 'uc8', + 'diversity', + 'women', + 'beautiful', + 'human', + 'wife', + 'child', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'woman', + 'female', + 'cute', + 'pretty', + 'adorable', + 'adore', + 'beauty', + 'cutie', + 'babe', + 'lovely', + 'gender', + 'people', + 'children', + 'girl', + 'boy', + 'kids', + 'niño', + 'enfant' + ], + modifiable: true), + Emoji( + name: 'girl: medium-light skin tone', + char: '\u{1F467}\u{1F3FC}', + shortName: 'girl_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.person, + keywords: [ + 'Virgo', + 'medium-light skin tone', + 'young', + 'zodiac', + 'uc8', + 'diversity', + 'women', + 'beautiful', + 'human', + 'wife', + 'child', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'woman', + 'female', + 'cute', + 'pretty', + 'adorable', + 'adore', + 'beauty', + 'cutie', + 'babe', + 'lovely', + 'gender', + 'people', + 'children', + 'girl', + 'boy', + 'kids', + 'niño', + 'enfant' + ], + modifiable: true), + Emoji( + name: 'girl: medium skin tone', + char: '\u{1F467}\u{1F3FD}', + shortName: 'girl_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.person, + keywords: [ + 'Virgo', + 'medium skin tone', + 'young', + 'zodiac', + 'uc8', + 'diversity', + 'women', + 'beautiful', + 'human', + 'wife', + 'child', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'woman', + 'female', + 'cute', + 'pretty', + 'adorable', + 'adore', + 'beauty', + 'cutie', + 'babe', + 'lovely', + 'gender', + 'people', + 'children', + 'girl', + 'boy', + 'kids', + 'niño', + 'enfant' + ], + modifiable: true), + Emoji( + name: 'girl: medium-dark skin tone', + char: '\u{1F467}\u{1F3FE}', + shortName: 'girl_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.person, + keywords: [ + 'Virgo', + 'medium-dark skin tone', + 'young', + 'zodiac', + 'uc8', + 'diversity', + 'women', + 'beautiful', + 'human', + 'wife', + 'child', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'woman', + 'female', + 'cute', + 'pretty', + 'adorable', + 'adore', + 'beauty', + 'cutie', + 'babe', + 'lovely', + 'gender', + 'people', + 'children', + 'girl', + 'boy', + 'kids', + 'niño', + 'enfant' + ], + modifiable: true), + Emoji( + name: 'girl: dark skin tone', + char: '\u{1F467}\u{1F3FF}', + shortName: 'girl_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.person, + keywords: [ + 'Virgo', + 'dark skin tone', + 'young', + 'zodiac', + 'uc8', + 'diversity', + 'women', + 'beautiful', + 'human', + 'wife', + 'child', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'woman', + 'female', + 'cute', + 'pretty', + 'adorable', + 'adore', + 'beauty', + 'cutie', + 'babe', + 'lovely', + 'gender', + 'people', + 'children', + 'girl', + 'boy', + 'kids', + 'niño', + 'enfant' + ], + modifiable: true), + Emoji( + name: 'child', + char: '\u{1F9D2}', + shortName: 'child', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.person, + keywords: [ + 'uc10', + 'men', + 'feminist', + 'beautiful', + 'human', + 'child', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'feminism', + 'strong woman', + 'cute', + 'pretty', + 'adorable', + 'adore', + 'beauty', + 'cutie', + 'babe', + 'lovely', + 'gender', + 'people', + 'children', + 'girl', + 'boy', + 'kids', + 'niño', + 'enfant' + ]), + Emoji( + name: 'child: light skin tone', + char: '\u{1F9D2}\u{1F3FB}', + shortName: 'child_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.person, + keywords: [ + 'gender-neutral', + 'light skin tone', + 'young', + 'uc10', + 'men', + 'feminist', + 'beautiful', + 'human', + 'child', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'feminism', + 'strong woman', + 'cute', + 'pretty', + 'adorable', + 'adore', + 'beauty', + 'cutie', + 'babe', + 'lovely', + 'gender', + 'people', + 'children', + 'girl', + 'boy', + 'kids', + 'niño', + 'enfant' + ], + modifiable: true), + Emoji( + name: 'child: medium-light skin tone', + char: '\u{1F9D2}\u{1F3FC}', + shortName: 'child_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.person, + keywords: [ + 'gender-neutral', + 'medium-light skin tone', + 'young', + 'uc10', + 'men', + 'feminist', + 'beautiful', + 'human', + 'child', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'feminism', + 'strong woman', + 'cute', + 'pretty', + 'adorable', + 'adore', + 'beauty', + 'cutie', + 'babe', + 'lovely', + 'gender', + 'people', + 'children', + 'girl', + 'boy', + 'kids', + 'niño', + 'enfant' + ], + modifiable: true), + Emoji( + name: 'child: medium skin tone', + char: '\u{1F9D2}\u{1F3FD}', + shortName: 'child_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.person, + keywords: [ + 'gender-neutral', + 'medium skin tone', + 'young', + 'uc10', + 'men', + 'feminist', + 'beautiful', + 'human', + 'child', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'feminism', + 'strong woman', + 'cute', + 'pretty', + 'adorable', + 'adore', + 'beauty', + 'cutie', + 'babe', + 'lovely', + 'gender', + 'people', + 'children', + 'girl', + 'boy', + 'kids', + 'niño', + 'enfant' + ], + modifiable: true), + Emoji( + name: 'child: medium-dark skin tone', + char: '\u{1F9D2}\u{1F3FE}', + shortName: 'child_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.person, + keywords: [ + 'gender-neutral', + 'medium-dark skin tone', + 'young', + 'uc10', + 'men', + 'feminist', + 'beautiful', + 'human', + 'child', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'feminism', + 'strong woman', + 'cute', + 'pretty', + 'adorable', + 'adore', + 'beauty', + 'cutie', + 'babe', + 'lovely', + 'gender', + 'people', + 'children', + 'girl', + 'boy', + 'kids', + 'niño', + 'enfant' + ], + modifiable: true), + Emoji( + name: 'child: dark skin tone', + char: '\u{1F9D2}\u{1F3FF}', + shortName: 'child_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.person, + keywords: [ + 'dark skin tone', + 'gender-neutral', + 'young', + 'uc10', + 'men', + 'feminist', + 'beautiful', + 'human', + 'child', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'feminism', + 'strong woman', + 'cute', + 'pretty', + 'adorable', + 'adore', + 'beauty', + 'cutie', + 'babe', + 'lovely', + 'gender', + 'people', + 'children', + 'girl', + 'boy', + 'kids', + 'niño', + 'enfant' + ], + modifiable: true), + Emoji( + name: 'boy', + char: '\u{1F466}', + shortName: 'boy', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.person, + keywords: [ + 'boy', + 'young', + 'uc6', + 'diversity', + 'men', + 'human', + 'handsome', + 'child', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'gender', + 'people', + 'stud', + 'children', + 'girl', + 'boy', + 'kids', + 'niño', + 'enfant' + ]), + Emoji( + name: 'boy: light skin tone', + char: '\u{1F466}\u{1F3FB}', + shortName: 'boy_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.person, + keywords: [ + 'boy', + 'light skin tone', + 'young', + 'uc8', + 'diversity', + 'men', + 'human', + 'handsome', + 'child', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'gender', + 'people', + 'stud', + 'children', + 'girl', + 'boy', + 'kids', + 'niño', + 'enfant' + ], + modifiable: true), + Emoji( + name: 'boy: medium-light skin tone', + char: '\u{1F466}\u{1F3FC}', + shortName: 'boy_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.person, + keywords: [ + 'boy', + 'medium-light skin tone', + 'young', + 'uc8', + 'diversity', + 'men', + 'human', + 'handsome', + 'child', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'gender', + 'people', + 'stud', + 'children', + 'girl', + 'boy', + 'kids', + 'niño', + 'enfant' + ], + modifiable: true), + Emoji( + name: 'boy: medium skin tone', + char: '\u{1F466}\u{1F3FD}', + shortName: 'boy_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.person, + keywords: [ + 'boy', + 'medium skin tone', + 'young', + 'uc8', + 'diversity', + 'men', + 'human', + 'handsome', + 'child', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'gender', + 'people', + 'stud', + 'children', + 'girl', + 'boy', + 'kids', + 'niño', + 'enfant' + ], + modifiable: true), + Emoji( + name: 'boy: medium-dark skin tone', + char: '\u{1F466}\u{1F3FE}', + shortName: 'boy_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.person, + keywords: [ + 'boy', + 'medium-dark skin tone', + 'young', + 'uc8', + 'diversity', + 'men', + 'human', + 'handsome', + 'child', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'gender', + 'people', + 'stud', + 'children', + 'girl', + 'boy', + 'kids', + 'niño', + 'enfant' + ], + modifiable: true), + Emoji( + name: 'boy: dark skin tone', + char: '\u{1F466}\u{1F3FF}', + shortName: 'boy_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.person, + keywords: [ + 'boy', + 'dark skin tone', + 'young', + 'uc8', + 'diversity', + 'men', + 'human', + 'handsome', + 'child', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'gender', + 'people', + 'stud', + 'children', + 'girl', + 'boy', + 'kids', + 'niño', + 'enfant' + ], + modifiable: true), + Emoji( + name: 'woman', + char: '\u{1F469}', + shortName: 'woman', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.person, + keywords: [ + 'woman', + 'uc6', + 'diversity', + 'lesbian', + 'women', + 'feminist', + 'beautiful', + 'girls night', + 'human', + 'parent', + 'wife', + 'mom', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'woman', + 'female', + 'feminism', + 'strong woman', + 'cute', + 'pretty', + 'adorable', + 'adore', + 'beauty', + 'cutie', + 'babe', + 'lovely', + 'ladies night', + 'girls only', + 'girlfriend', + 'gender', + 'people', + 'parents', + 'adult', + 'maman', + 'mommy', + 'mama', + 'mother' + ]), + Emoji( + name: 'woman: light skin tone', + char: '\u{1F469}\u{1F3FB}', + shortName: 'woman_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.person, + keywords: [ + 'light skin tone', + 'woman', + 'uc8', + 'diversity', + 'lesbian', + 'women', + 'feminist', + 'beautiful', + 'girls night', + 'human', + 'parent', + 'wife', + 'mom', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'woman', + 'female', + 'feminism', + 'strong woman', + 'cute', + 'pretty', + 'adorable', + 'adore', + 'beauty', + 'cutie', + 'babe', + 'lovely', + 'ladies night', + 'girls only', + 'girlfriend', + 'gender', + 'people', + 'parents', + 'adult', + 'maman', + 'mommy', + 'mama', + 'mother' + ], + modifiable: true), + Emoji( + name: 'woman: medium-light skin tone', + char: '\u{1F469}\u{1F3FC}', + shortName: 'woman_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.person, + keywords: [ + 'medium-light skin tone', + 'woman', + 'uc8', + 'diversity', + 'lesbian', + 'women', + 'feminist', + 'beautiful', + 'girls night', + 'human', + 'parent', + 'wife', + 'mom', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'woman', + 'female', + 'feminism', + 'strong woman', + 'cute', + 'pretty', + 'adorable', + 'adore', + 'beauty', + 'cutie', + 'babe', + 'lovely', + 'ladies night', + 'girls only', + 'girlfriend', + 'gender', + 'people', + 'parents', + 'adult', + 'maman', + 'mommy', + 'mama', + 'mother' + ], + modifiable: true), + Emoji( + name: 'woman: medium skin tone', + char: '\u{1F469}\u{1F3FD}', + shortName: 'woman_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.person, + keywords: [ + 'medium skin tone', + 'woman', + 'uc8', + 'diversity', + 'lesbian', + 'women', + 'feminist', + 'beautiful', + 'girls night', + 'human', + 'parent', + 'wife', + 'mom', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'woman', + 'female', + 'feminism', + 'strong woman', + 'cute', + 'pretty', + 'adorable', + 'adore', + 'beauty', + 'cutie', + 'babe', + 'lovely', + 'ladies night', + 'girls only', + 'girlfriend', + 'gender', + 'people', + 'parents', + 'adult', + 'maman', + 'mommy', + 'mama', + 'mother' + ], + modifiable: true), + Emoji( + name: 'woman: medium-dark skin tone', + char: '\u{1F469}\u{1F3FE}', + shortName: 'woman_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.person, + keywords: [ + 'medium-dark skin tone', + 'woman', + 'uc8', + 'diversity', + 'lesbian', + 'women', + 'feminist', + 'beautiful', + 'girls night', + 'human', + 'parent', + 'wife', + 'mom', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'woman', + 'female', + 'feminism', + 'strong woman', + 'cute', + 'pretty', + 'adorable', + 'adore', + 'beauty', + 'cutie', + 'babe', + 'lovely', + 'ladies night', + 'girls only', + 'girlfriend', + 'gender', + 'people', + 'parents', + 'adult', + 'maman', + 'mommy', + 'mama', + 'mother' + ], + modifiable: true), + Emoji( + name: 'woman: dark skin tone', + char: '\u{1F469}\u{1F3FF}', + shortName: 'woman_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.person, + keywords: [ + 'dark skin tone', + 'woman', + 'uc8', + 'diversity', + 'lesbian', + 'women', + 'feminist', + 'beautiful', + 'girls night', + 'human', + 'parent', + 'wife', + 'mom', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'woman', + 'female', + 'feminism', + 'strong woman', + 'cute', + 'pretty', + 'adorable', + 'adore', + 'beauty', + 'cutie', + 'babe', + 'lovely', + 'ladies night', + 'girls only', + 'girlfriend', + 'gender', + 'people', + 'parents', + 'adult', + 'maman', + 'mommy', + 'mama', + 'mother' + ], + modifiable: true), + Emoji( + name: 'person', + char: '\u{1F9D1}', + shortName: 'adult', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.person, + keywords: [ + 'uc10', + 'diversity', + 'lesbian', + 'men', + 'feminist', + 'girls night', + 'boys night', + 'human', + 'parent', + 'handsome', + 'wife', + 'husband', + 'mom', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'feminism', + 'strong woman', + 'ladies night', + 'girls only', + 'girlfriend', + 'guys night', + 'gender', + 'people', + 'parents', + 'adult', + 'stud', + 'maman', + 'mommy', + 'mama', + 'mother' + ]), + Emoji( + name: 'person: light skin tone', + char: '\u{1F9D1}\u{1F3FB}', + shortName: 'adult_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.person, + keywords: [ + 'gender-neutral', + 'light skin tone', + 'uc10', + 'diversity', + 'lesbian', + 'men', + 'feminist', + 'girls night', + 'boys night', + 'human', + 'parent', + 'handsome', + 'wife', + 'husband', + 'mom', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'feminism', + 'strong woman', + 'ladies night', + 'girls only', + 'girlfriend', + 'guys night', + 'gender', + 'people', + 'parents', + 'adult', + 'stud', + 'maman', + 'mommy', + 'mama', + 'mother' + ], + modifiable: true), + Emoji( + name: 'person: medium-light skin tone', + char: '\u{1F9D1}\u{1F3FC}', + shortName: 'adult_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.person, + keywords: [ + 'gender-neutral', + 'medium-light skin tone', + 'uc10', + 'diversity', + 'lesbian', + 'men', + 'feminist', + 'girls night', + 'boys night', + 'human', + 'parent', + 'handsome', + 'wife', + 'husband', + 'mom', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'feminism', + 'strong woman', + 'ladies night', + 'girls only', + 'girlfriend', + 'guys night', + 'gender', + 'people', + 'parents', + 'adult', + 'stud', + 'maman', + 'mommy', + 'mama', + 'mother' + ], + modifiable: true), + Emoji( + name: 'person: medium skin tone', + char: '\u{1F9D1}\u{1F3FD}', + shortName: 'adult_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.person, + keywords: [ + 'gender-neutral', + 'medium skin tone', + 'uc10', + 'diversity', + 'lesbian', + 'men', + 'feminist', + 'girls night', + 'boys night', + 'human', + 'parent', + 'handsome', + 'wife', + 'husband', + 'mom', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'feminism', + 'strong woman', + 'ladies night', + 'girls only', + 'girlfriend', + 'guys night', + 'gender', + 'people', + 'parents', + 'adult', + 'stud', + 'maman', + 'mommy', + 'mama', + 'mother' + ], + modifiable: true), + Emoji( + name: 'person: medium-dark skin tone', + char: '\u{1F9D1}\u{1F3FE}', + shortName: 'adult_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.person, + keywords: [ + 'gender-neutral', + 'medium-dark skin tone', + 'uc10', + 'diversity', + 'lesbian', + 'men', + 'feminist', + 'girls night', + 'boys night', + 'human', + 'parent', + 'handsome', + 'wife', + 'husband', + 'mom', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'feminism', + 'strong woman', + 'ladies night', + 'girls only', + 'girlfriend', + 'guys night', + 'gender', + 'people', + 'parents', + 'adult', + 'stud', + 'maman', + 'mommy', + 'mama', + 'mother' + ], + modifiable: true), + Emoji( + name: 'person: dark skin tone', + char: '\u{1F9D1}\u{1F3FF}', + shortName: 'adult_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.person, + keywords: [ + 'dark skin tone', + 'gender-neutral', + 'uc10', + 'diversity', + 'lesbian', + 'men', + 'feminist', + 'girls night', + 'boys night', + 'human', + 'parent', + 'handsome', + 'wife', + 'husband', + 'mom', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'feminism', + 'strong woman', + 'ladies night', + 'girls only', + 'girlfriend', + 'guys night', + 'gender', + 'people', + 'parents', + 'adult', + 'stud', + 'maman', + 'mommy', + 'mama', + 'mother' + ], + modifiable: true), + Emoji( + name: 'man', + char: '\u{1F468}', + shortName: 'man', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.person, + keywords: [ + 'man', + 'uc6', + 'diversity', + 'men', + 'boys night', + 'human', + 'daddy', + 'parent', + 'handsome', + 'husband', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'guys night', + 'gender', + 'people', + 'dad', + 'papa', + 'pere', + 'father', + 'parents', + 'adult', + 'stud' + ]), + Emoji( + name: 'man: light skin tone', + char: '\u{1F468}\u{1F3FB}', + shortName: 'man_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.person, + keywords: [ + 'light skin tone', + 'man', + 'uc8', + 'diversity', + 'men', + 'boys night', + 'human', + 'daddy', + 'parent', + 'handsome', + 'husband', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'guys night', + 'gender', + 'people', + 'dad', + 'papa', + 'pere', + 'father', + 'parents', + 'adult', + 'stud' + ], + modifiable: true), + Emoji( + name: 'man: medium-light skin tone', + char: '\u{1F468}\u{1F3FC}', + shortName: 'man_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.person, + keywords: [ + 'man', + 'medium-light skin tone', + 'uc8', + 'diversity', + 'men', + 'boys night', + 'human', + 'daddy', + 'parent', + 'handsome', + 'husband', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'guys night', + 'gender', + 'people', + 'dad', + 'papa', + 'pere', + 'father', + 'parents', + 'adult', + 'stud' + ], + modifiable: true), + Emoji( + name: 'man: medium skin tone', + char: '\u{1F468}\u{1F3FD}', + shortName: 'man_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.person, + keywords: [ + 'man', + 'medium skin tone', + 'uc8', + 'diversity', + 'men', + 'boys night', + 'human', + 'daddy', + 'parent', + 'handsome', + 'husband', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'guys night', + 'gender', + 'people', + 'dad', + 'papa', + 'pere', + 'father', + 'parents', + 'adult', + 'stud' + ], + modifiable: true), + Emoji( + name: 'man: medium-dark skin tone', + char: '\u{1F468}\u{1F3FE}', + shortName: 'man_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.person, + keywords: [ + 'man', + 'medium-dark skin tone', + 'uc8', + 'diversity', + 'men', + 'boys night', + 'human', + 'daddy', + 'parent', + 'handsome', + 'husband', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'guys night', + 'gender', + 'people', + 'dad', + 'papa', + 'pere', + 'father', + 'parents', + 'adult', + 'stud' + ], + modifiable: true), + Emoji( + name: 'man: dark skin tone', + char: '\u{1F468}\u{1F3FF}', + shortName: 'man_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.person, + keywords: [ + 'dark skin tone', + 'man', + 'uc8', + 'diversity', + 'men', + 'boys night', + 'human', + 'daddy', + 'parent', + 'handsome', + 'husband', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'guys night', + 'gender', + 'people', + 'dad', + 'papa', + 'pere', + 'father', + 'parents', + 'adult', + 'stud' + ], + modifiable: true), + Emoji( + name: 'person: curly hair', + char: '\u{1F9D1}\u{200D}\u{1F9B1}', + shortName: 'person_curly_hair', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.person, + keywords: [ + 'uc12', + 'men', + 'human', + 'daddy', + 'parent', + 'handsome', + 'husband', + 'afro', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'gender', + 'people', + 'dad', + 'papa', + 'pere', + 'father', + 'parents', + 'adult', + 'stud', + "'fro", + 'curls', + 'frizzy', + 'perm' + ]), + Emoji( + name: 'person: light skin tone, curly hair', + char: '\u{1F9D1}\u{1F3FB}\u{200D}\u{1F9B1}', + shortName: 'person_tone1_curly_hair', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.person, + keywords: [ + 'uc12', + 'men', + 'human', + 'daddy', + 'parent', + 'handsome', + 'husband', + 'afro', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'gender', + 'people', + 'dad', + 'papa', + 'pere', + 'father', + 'parents', + 'adult', + 'stud', + "'fro", + 'curls', + 'frizzy', + 'perm' + ], + modifiable: true), + Emoji( + name: 'person: medium-light skin tone, curly hair', + char: '\u{1F9D1}\u{1F3FC}\u{200D}\u{1F9B1}', + shortName: 'person_tone2_curly_hair', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.person, + keywords: [ + 'uc12', + 'men', + 'human', + 'daddy', + 'parent', + 'handsome', + 'husband', + 'afro', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'gender', + 'people', + 'dad', + 'papa', + 'pere', + 'father', + 'parents', + 'adult', + 'stud', + "'fro", + 'curls', + 'frizzy', + 'perm' + ], + modifiable: true), + Emoji( + name: 'person: medium skin tone, curly hair', + char: '\u{1F9D1}\u{1F3FD}\u{200D}\u{1F9B1}', + shortName: 'person_tone3_curly_hair', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.person, + keywords: [ + 'uc12', + 'men', + 'human', + 'daddy', + 'parent', + 'handsome', + 'husband', + 'afro', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'gender', + 'people', + 'dad', + 'papa', + 'pere', + 'father', + 'parents', + 'adult', + 'stud', + "'fro", + 'curls', + 'frizzy', + 'perm' + ], + modifiable: true), + Emoji( + name: 'person: medium-dark skin tone, curly hair', + char: '\u{1F9D1}\u{1F3FE}\u{200D}\u{1F9B1}', + shortName: 'person_tone4_curly_hair', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.person, + keywords: [ + 'uc12', + 'men', + 'human', + 'daddy', + 'parent', + 'handsome', + 'husband', + 'afro', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'gender', + 'people', + 'dad', + 'papa', + 'pere', + 'father', + 'parents', + 'adult', + 'stud', + "'fro", + 'curls', + 'frizzy', + 'perm' + ], + modifiable: true), + Emoji( + name: 'person: dark skin tone, curly hair', + char: '\u{1F9D1}\u{1F3FF}\u{200D}\u{1F9B1}', + shortName: 'person_tone5_curly_hair', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.person, + keywords: [ + 'uc12', + 'men', + 'human', + 'daddy', + 'parent', + 'handsome', + 'husband', + 'afro', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'gender', + 'people', + 'dad', + 'papa', + 'pere', + 'father', + 'parents', + 'adult', + 'stud', + "'fro", + 'curls', + 'frizzy', + 'perm' + ], + modifiable: true), + Emoji( + name: 'woman: curly hair', + char: '\u{1F469}\u{200D}\u{1F9B1}', + shortName: 'woman_curly_haired', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.person, + keywords: [ + 'uc11', + 'diversity', + 'lesbian', + 'women', + 'beautiful', + 'girls night', + 'human', + 'parent', + 'wife', + 'mom', + 'afro', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'woman', + 'female', + 'cute', + 'pretty', + 'adorable', + 'adore', + 'beauty', + 'cutie', + 'babe', + 'lovely', + 'ladies night', + 'girls only', + 'girlfriend', + 'gender', + 'people', + 'parents', + 'adult', + 'maman', + 'mommy', + 'mama', + 'mother', + "'fro", + 'curls', + 'frizzy', + 'perm' + ]), + Emoji( + name: 'woman: light skin tone, curly hair', + char: '\u{1F469}\u{1F3FB}\u{200D}\u{1F9B1}', + shortName: 'woman_curly_haired_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.person, + keywords: [ + 'uc11', + 'diversity', + 'lesbian', + 'women', + 'beautiful', + 'girls night', + 'human', + 'parent', + 'wife', + 'mom', + 'afro', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'woman', + 'female', + 'cute', + 'pretty', + 'adorable', + 'adore', + 'beauty', + 'cutie', + 'babe', + 'lovely', + 'ladies night', + 'girls only', + 'girlfriend', + 'gender', + 'people', + 'parents', + 'adult', + 'maman', + 'mommy', + 'mama', + 'mother', + "'fro", + 'curls', + 'frizzy', + 'perm' + ], + modifiable: true), + Emoji( + name: 'woman: medium-light skin tone, curly hair', + char: '\u{1F469}\u{1F3FC}\u{200D}\u{1F9B1}', + shortName: 'woman_curly_haired_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.person, + keywords: [ + 'uc11', + 'diversity', + 'lesbian', + 'women', + 'beautiful', + 'girls night', + 'human', + 'parent', + 'wife', + 'mom', + 'afro', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'woman', + 'female', + 'cute', + 'pretty', + 'adorable', + 'adore', + 'beauty', + 'cutie', + 'babe', + 'lovely', + 'ladies night', + 'girls only', + 'girlfriend', + 'gender', + 'people', + 'parents', + 'adult', + 'maman', + 'mommy', + 'mama', + 'mother', + "'fro", + 'curls', + 'frizzy', + 'perm' + ], + modifiable: true), + Emoji( + name: 'woman: medium skin tone, curly hair', + char: '\u{1F469}\u{1F3FD}\u{200D}\u{1F9B1}', + shortName: 'woman_curly_haired_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.person, + keywords: [ + 'uc11', + 'diversity', + 'lesbian', + 'women', + 'beautiful', + 'girls night', + 'human', + 'parent', + 'wife', + 'mom', + 'afro', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'woman', + 'female', + 'cute', + 'pretty', + 'adorable', + 'adore', + 'beauty', + 'cutie', + 'babe', + 'lovely', + 'ladies night', + 'girls only', + 'girlfriend', + 'gender', + 'people', + 'parents', + 'adult', + 'maman', + 'mommy', + 'mama', + 'mother', + "'fro", + 'curls', + 'frizzy', + 'perm' + ], + modifiable: true), + Emoji( + name: 'woman: medium-dark skin tone, curly hair', + char: '\u{1F469}\u{1F3FE}\u{200D}\u{1F9B1}', + shortName: 'woman_curly_haired_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.person, + keywords: [ + 'uc11', + 'diversity', + 'lesbian', + 'women', + 'beautiful', + 'girls night', + 'human', + 'parent', + 'wife', + 'mom', + 'afro', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'woman', + 'female', + 'cute', + 'pretty', + 'adorable', + 'adore', + 'beauty', + 'cutie', + 'babe', + 'lovely', + 'ladies night', + 'girls only', + 'girlfriend', + 'gender', + 'people', + 'parents', + 'adult', + 'maman', + 'mommy', + 'mama', + 'mother', + "'fro", + 'curls', + 'frizzy', + 'perm' + ], + modifiable: true), + Emoji( + name: 'woman: dark skin tone, curly hair', + char: '\u{1F469}\u{1F3FF}\u{200D}\u{1F9B1}', + shortName: 'woman_curly_haired_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.person, + keywords: [ + 'uc11', + 'diversity', + 'lesbian', + 'women', + 'beautiful', + 'girls night', + 'human', + 'parent', + 'wife', + 'mom', + 'afro', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'woman', + 'female', + 'cute', + 'pretty', + 'adorable', + 'adore', + 'beauty', + 'cutie', + 'babe', + 'lovely', + 'ladies night', + 'girls only', + 'girlfriend', + 'gender', + 'people', + 'parents', + 'adult', + 'maman', + 'mommy', + 'mama', + 'mother', + "'fro", + 'curls', + 'frizzy', + 'perm' + ], + modifiable: true), + Emoji( + name: 'man: curly hair', + char: '\u{1F468}\u{200D}\u{1F9B1}', + shortName: 'man_curly_haired', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.person, + keywords: [ + 'uc11', + 'diversity', + 'men', + 'human', + 'daddy', + 'parent', + 'handsome', + 'husband', + 'afro', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'gender', + 'people', + 'dad', + 'papa', + 'pere', + 'father', + 'parents', + 'adult', + 'stud', + "'fro", + 'curls', + 'frizzy', + 'perm' + ]), + Emoji( + name: 'man: light skin tone, curly hair', + char: '\u{1F468}\u{1F3FB}\u{200D}\u{1F9B1}', + shortName: 'man_curly_haired_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.person, + keywords: [ + 'uc11', + 'diversity', + 'men', + 'human', + 'daddy', + 'parent', + 'handsome', + 'husband', + 'afro', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'gender', + 'people', + 'dad', + 'papa', + 'pere', + 'father', + 'parents', + 'adult', + 'stud', + "'fro", + 'curls', + 'frizzy', + 'perm' + ], + modifiable: true), + Emoji( + name: 'man: medium-light skin tone, curly hair', + char: '\u{1F468}\u{1F3FC}\u{200D}\u{1F9B1}', + shortName: 'man_curly_haired_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.person, + keywords: [ + 'uc11', + 'diversity', + 'men', + 'human', + 'daddy', + 'parent', + 'handsome', + 'husband', + 'afro', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'gender', + 'people', + 'dad', + 'papa', + 'pere', + 'father', + 'parents', + 'adult', + 'stud', + "'fro", + 'curls', + 'frizzy', + 'perm' + ], + modifiable: true), + Emoji( + name: 'man: medium skin tone, curly hair', + char: '\u{1F468}\u{1F3FD}\u{200D}\u{1F9B1}', + shortName: 'man_curly_haired_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.person, + keywords: [ + 'uc11', + 'diversity', + 'men', + 'human', + 'daddy', + 'parent', + 'handsome', + 'husband', + 'afro', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'gender', + 'people', + 'dad', + 'papa', + 'pere', + 'father', + 'parents', + 'adult', + 'stud', + "'fro", + 'curls', + 'frizzy', + 'perm' + ], + modifiable: true), + Emoji( + name: 'man: medium-dark skin tone, curly hair', + char: '\u{1F468}\u{1F3FE}\u{200D}\u{1F9B1}', + shortName: 'man_curly_haired_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.person, + keywords: [ + 'uc11', + 'diversity', + 'men', + 'human', + 'daddy', + 'parent', + 'handsome', + 'husband', + 'afro', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'gender', + 'people', + 'dad', + 'papa', + 'pere', + 'father', + 'parents', + 'adult', + 'stud', + "'fro", + 'curls', + 'frizzy', + 'perm' + ], + modifiable: true), + Emoji( + name: 'man: dark skin tone, curly hair', + char: '\u{1F468}\u{1F3FF}\u{200D}\u{1F9B1}', + shortName: 'man_curly_haired_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.person, + keywords: [ + 'uc11', + 'diversity', + 'men', + 'human', + 'daddy', + 'parent', + 'handsome', + 'husband', + 'afro', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'gender', + 'people', + 'dad', + 'papa', + 'pere', + 'father', + 'parents', + 'adult', + 'stud', + "'fro", + 'curls', + 'frizzy', + 'perm' + ], + modifiable: true), + Emoji( + name: 'person: red hair', + char: '\u{1F9D1}\u{200D}\u{1F9B0}', + shortName: 'person_red_hair', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.person, + keywords: [ + 'uc12', + 'lesbian', + 'men', + 'girls night', + 'human', + 'daddy', + 'parent', + 'handsome', + 'wife', + 'husband', + 'mom', + 'ginger', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'ladies night', + 'girls only', + 'girlfriend', + 'gender', + 'people', + 'dad', + 'papa', + 'pere', + 'father', + 'parents', + 'adult', + 'stud', + 'maman', + 'mommy', + 'mama', + 'mother' + ]), + Emoji( + name: 'person: light skin tone, red hair', + char: '\u{1F9D1}\u{1F3FB}\u{200D}\u{1F9B0}', + shortName: 'person_tone1_red_hair', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.person, + keywords: [ + 'uc12', + 'lesbian', + 'men', + 'girls night', + 'human', + 'daddy', + 'parent', + 'handsome', + 'wife', + 'husband', + 'mom', + 'ginger', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'ladies night', + 'girls only', + 'girlfriend', + 'gender', + 'people', + 'dad', + 'papa', + 'pere', + 'father', + 'parents', + 'adult', + 'stud', + 'maman', + 'mommy', + 'mama', + 'mother' + ], + modifiable: true), + Emoji( + name: 'person: medium-light skin tone, red hair', + char: '\u{1F9D1}\u{1F3FC}\u{200D}\u{1F9B0}', + shortName: 'person_tone2_red_hair', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.person, + keywords: [ + 'uc12', + 'lesbian', + 'men', + 'girls night', + 'human', + 'daddy', + 'parent', + 'handsome', + 'wife', + 'husband', + 'mom', + 'ginger', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'ladies night', + 'girls only', + 'girlfriend', + 'gender', + 'people', + 'dad', + 'papa', + 'pere', + 'father', + 'parents', + 'adult', + 'stud', + 'maman', + 'mommy', + 'mama', + 'mother' + ], + modifiable: true), + Emoji( + name: 'person: medium skin tone, red hair', + char: '\u{1F9D1}\u{1F3FD}\u{200D}\u{1F9B0}', + shortName: 'person_tone3_red_hair', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.person, + keywords: [ + 'uc12', + 'lesbian', + 'men', + 'girls night', + 'human', + 'daddy', + 'parent', + 'handsome', + 'wife', + 'husband', + 'mom', + 'ginger', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'ladies night', + 'girls only', + 'girlfriend', + 'gender', + 'people', + 'dad', + 'papa', + 'pere', + 'father', + 'parents', + 'adult', + 'stud', + 'maman', + 'mommy', + 'mama', + 'mother' + ], + modifiable: true), + Emoji( + name: 'person: medium-dark skin tone, red hair', + char: '\u{1F9D1}\u{1F3FE}\u{200D}\u{1F9B0}', + shortName: 'person_tone4_red_hair', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.person, + keywords: [ + 'uc12', + 'lesbian', + 'men', + 'girls night', + 'human', + 'daddy', + 'parent', + 'handsome', + 'wife', + 'husband', + 'mom', + 'ginger', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'ladies night', + 'girls only', + 'girlfriend', + 'gender', + 'people', + 'dad', + 'papa', + 'pere', + 'father', + 'parents', + 'adult', + 'stud', + 'maman', + 'mommy', + 'mama', + 'mother' + ], + modifiable: true), + Emoji( + name: 'person: dark skin tone, red hair', + char: '\u{1F9D1}\u{1F3FF}\u{200D}\u{1F9B0}', + shortName: 'person_tone5_red_hair', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.person, + keywords: [ + 'uc12', + 'lesbian', + 'men', + 'girls night', + 'human', + 'daddy', + 'parent', + 'handsome', + 'wife', + 'husband', + 'mom', + 'ginger', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'ladies night', + 'girls only', + 'girlfriend', + 'gender', + 'people', + 'dad', + 'papa', + 'pere', + 'father', + 'parents', + 'adult', + 'stud', + 'maman', + 'mommy', + 'mama', + 'mother' + ], + modifiable: true), + Emoji( + name: 'woman: red hair', + char: '\u{1F469}\u{200D}\u{1F9B0}', + shortName: 'woman_red_haired', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.person, + keywords: [ + 'uc11', + 'diversity', + 'lesbian', + 'women', + 'beautiful', + 'human', + 'parent', + 'wife', + 'mom', + 'ginger', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'woman', + 'female', + 'cute', + 'pretty', + 'adorable', + 'adore', + 'beauty', + 'cutie', + 'babe', + 'lovely', + 'gender', + 'people', + 'parents', + 'adult', + 'maman', + 'mommy', + 'mama', + 'mother' + ]), + Emoji( + name: 'woman: light skin tone, red hair', + char: '\u{1F469}\u{1F3FB}\u{200D}\u{1F9B0}', + shortName: 'woman_red_haired_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.person, + keywords: [ + 'uc11', + 'diversity', + 'lesbian', + 'women', + 'beautiful', + 'human', + 'parent', + 'wife', + 'mom', + 'ginger', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'woman', + 'female', + 'cute', + 'pretty', + 'adorable', + 'adore', + 'beauty', + 'cutie', + 'babe', + 'lovely', + 'gender', + 'people', + 'parents', + 'adult', + 'maman', + 'mommy', + 'mama', + 'mother' + ], + modifiable: true), + Emoji( + name: 'woman: medium-light skin tone, red hair', + char: '\u{1F469}\u{1F3FC}\u{200D}\u{1F9B0}', + shortName: 'woman_red_haired_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.person, + keywords: [ + 'uc11', + 'diversity', + 'lesbian', + 'women', + 'beautiful', + 'human', + 'parent', + 'wife', + 'mom', + 'ginger', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'woman', + 'female', + 'cute', + 'pretty', + 'adorable', + 'adore', + 'beauty', + 'cutie', + 'babe', + 'lovely', + 'gender', + 'people', + 'parents', + 'adult', + 'maman', + 'mommy', + 'mama', + 'mother' + ], + modifiable: true), + Emoji( + name: 'woman: medium skin tone, red hair', + char: '\u{1F469}\u{1F3FD}\u{200D}\u{1F9B0}', + shortName: 'woman_red_haired_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.person, + keywords: [ + 'uc11', + 'diversity', + 'lesbian', + 'women', + 'beautiful', + 'human', + 'parent', + 'wife', + 'mom', + 'ginger', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'woman', + 'female', + 'cute', + 'pretty', + 'adorable', + 'adore', + 'beauty', + 'cutie', + 'babe', + 'lovely', + 'gender', + 'people', + 'parents', + 'adult', + 'maman', + 'mommy', + 'mama', + 'mother' + ], + modifiable: true), + Emoji( + name: 'woman: medium-dark skin tone, red hair', + char: '\u{1F469}\u{1F3FE}\u{200D}\u{1F9B0}', + shortName: 'woman_red_haired_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.person, + keywords: [ + 'uc11', + 'diversity', + 'lesbian', + 'women', + 'beautiful', + 'human', + 'parent', + 'wife', + 'mom', + 'ginger', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'woman', + 'female', + 'cute', + 'pretty', + 'adorable', + 'adore', + 'beauty', + 'cutie', + 'babe', + 'lovely', + 'gender', + 'people', + 'parents', + 'adult', + 'maman', + 'mommy', + 'mama', + 'mother' + ], + modifiable: true), + Emoji( + name: 'woman: dark skin tone, red hair', + char: '\u{1F469}\u{1F3FF}\u{200D}\u{1F9B0}', + shortName: 'woman_red_haired_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.person, + keywords: [ + 'uc11', + 'diversity', + 'lesbian', + 'women', + 'beautiful', + 'human', + 'parent', + 'wife', + 'mom', + 'ginger', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'woman', + 'female', + 'cute', + 'pretty', + 'adorable', + 'adore', + 'beauty', + 'cutie', + 'babe', + 'lovely', + 'gender', + 'people', + 'parents', + 'adult', + 'maman', + 'mommy', + 'mama', + 'mother' + ], + modifiable: true), + Emoji( + name: 'man: red hair', + char: '\u{1F468}\u{200D}\u{1F9B0}', + shortName: 'man_red_haired', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.person, + keywords: [ + 'uc11', + 'diversity', + 'men', + 'human', + 'daddy', + 'parent', + 'handsome', + 'husband', + 'ginger', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'gender', + 'people', + 'dad', + 'papa', + 'pere', + 'father', + 'parents', + 'adult', + 'stud' + ]), + Emoji( + name: 'man: light skin tone, red hair', + char: '\u{1F468}\u{1F3FB}\u{200D}\u{1F9B0}', + shortName: 'man_red_haired_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.person, + keywords: [ + 'uc11', + 'diversity', + 'men', + 'human', + 'daddy', + 'parent', + 'handsome', + 'husband', + 'ginger', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'gender', + 'people', + 'dad', + 'papa', + 'pere', + 'father', + 'parents', + 'adult', + 'stud' + ], + modifiable: true), + Emoji( + name: 'man: medium-light skin tone, red hair', + char: '\u{1F468}\u{1F3FC}\u{200D}\u{1F9B0}', + shortName: 'man_red_haired_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.person, + keywords: [ + 'uc11', + 'diversity', + 'men', + 'human', + 'daddy', + 'parent', + 'handsome', + 'husband', + 'ginger', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'gender', + 'people', + 'dad', + 'papa', + 'pere', + 'father', + 'parents', + 'adult', + 'stud' + ], + modifiable: true), + Emoji( + name: 'man: medium skin tone, red hair', + char: '\u{1F468}\u{1F3FD}\u{200D}\u{1F9B0}', + shortName: 'man_red_haired_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.person, + keywords: [ + 'uc11', + 'diversity', + 'men', + 'human', + 'daddy', + 'parent', + 'handsome', + 'husband', + 'ginger', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'gender', + 'people', + 'dad', + 'papa', + 'pere', + 'father', + 'parents', + 'adult', + 'stud' + ], + modifiable: true), + Emoji( + name: 'man: medium-dark skin tone, red hair', + char: '\u{1F468}\u{1F3FE}\u{200D}\u{1F9B0}', + shortName: 'man_red_haired_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.person, + keywords: [ + 'uc11', + 'diversity', + 'men', + 'human', + 'daddy', + 'parent', + 'handsome', + 'husband', + 'ginger', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'gender', + 'people', + 'dad', + 'papa', + 'pere', + 'father', + 'parents', + 'adult', + 'stud' + ], + modifiable: true), + Emoji( + name: 'man: dark skin tone, red hair', + char: '\u{1F468}\u{1F3FF}\u{200D}\u{1F9B0}', + shortName: 'man_red_haired_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.person, + keywords: [ + 'uc11', + 'diversity', + 'men', + 'human', + 'daddy', + 'parent', + 'handsome', + 'husband', + 'ginger', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'gender', + 'people', + 'dad', + 'papa', + 'pere', + 'father', + 'parents', + 'adult', + 'stud' + ], + modifiable: true), + Emoji( + name: 'woman: blond hair', + char: '\u{1F471}\u{200D}\u{2640}\u{FE0F}', + shortName: 'blond-haired_woman', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.person, + keywords: [ + 'blonde', + 'woman', + 'uc6', + 'diversity', + 'lesbian', + 'women', + 'feminist', + 'beautiful', + 'girls night', + 'human', + 'parent', + 'wife', + 'mom', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'woman', + 'female', + 'feminism', + 'strong woman', + 'cute', + 'pretty', + 'adorable', + 'adore', + 'beauty', + 'cutie', + 'babe', + 'lovely', + 'ladies night', + 'girls only', + 'girlfriend', + 'gender', + 'people', + 'parents', + 'adult', + 'maman', + 'mommy', + 'mama', + 'mother' + ]), + Emoji( + name: 'woman: light skin tone, blond hair', + char: '\u{1F471}\u{1F3FB}\u{200D}\u{2640}\u{FE0F}', + shortName: 'blond-haired_woman_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.person, + keywords: [ + 'blonde', + 'light skin tone', + 'woman', + 'uc8', + 'diversity', + 'lesbian', + 'women', + 'feminist', + 'beautiful', + 'girls night', + 'human', + 'parent', + 'wife', + 'mom', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'woman', + 'female', + 'feminism', + 'strong woman', + 'cute', + 'pretty', + 'adorable', + 'adore', + 'beauty', + 'cutie', + 'babe', + 'lovely', + 'ladies night', + 'girls only', + 'girlfriend', + 'gender', + 'people', + 'parents', + 'adult', + 'maman', + 'mommy', + 'mama', + 'mother' + ], + modifiable: true), + Emoji( + name: 'woman: medium-light skin tone, blond hair', + char: '\u{1F471}\u{1F3FC}\u{200D}\u{2640}\u{FE0F}', + shortName: 'blond-haired_woman_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.person, + keywords: [ + 'blonde', + 'medium-light skin tone', + 'woman', + 'uc8', + 'diversity', + 'lesbian', + 'women', + 'feminist', + 'beautiful', + 'girls night', + 'human', + 'parent', + 'wife', + 'mom', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'woman', + 'female', + 'feminism', + 'strong woman', + 'cute', + 'pretty', + 'adorable', + 'adore', + 'beauty', + 'cutie', + 'babe', + 'lovely', + 'ladies night', + 'girls only', + 'girlfriend', + 'gender', + 'people', + 'parents', + 'adult', + 'maman', + 'mommy', + 'mama', + 'mother' + ], + modifiable: true), + Emoji( + name: 'woman: medium skin tone, blond hair', + char: '\u{1F471}\u{1F3FD}\u{200D}\u{2640}\u{FE0F}', + shortName: 'blond-haired_woman_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.person, + keywords: [ + 'blonde', + 'medium skin tone', + 'woman', + 'uc8', + 'diversity', + 'lesbian', + 'women', + 'feminist', + 'beautiful', + 'girls night', + 'human', + 'parent', + 'wife', + 'mom', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'woman', + 'female', + 'feminism', + 'strong woman', + 'cute', + 'pretty', + 'adorable', + 'adore', + 'beauty', + 'cutie', + 'babe', + 'lovely', + 'ladies night', + 'girls only', + 'girlfriend', + 'gender', + 'people', + 'parents', + 'adult', + 'maman', + 'mommy', + 'mama', + 'mother' + ], + modifiable: true), + Emoji( + name: 'woman: medium-dark skin tone, blond hair', + char: '\u{1F471}\u{1F3FE}\u{200D}\u{2640}\u{FE0F}', + shortName: 'blond-haired_woman_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.person, + keywords: [ + 'blonde', + 'medium-dark skin tone', + 'woman', + 'uc8', + 'diversity', + 'lesbian', + 'women', + 'feminist', + 'beautiful', + 'girls night', + 'human', + 'parent', + 'wife', + 'mom', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'woman', + 'female', + 'feminism', + 'strong woman', + 'cute', + 'pretty', + 'adorable', + 'adore', + 'beauty', + 'cutie', + 'babe', + 'lovely', + 'ladies night', + 'girls only', + 'girlfriend', + 'gender', + 'people', + 'parents', + 'adult', + 'maman', + 'mommy', + 'mama', + 'mother' + ], + modifiable: true), + Emoji( + name: 'woman: dark skin tone, blond hair', + char: '\u{1F471}\u{1F3FF}\u{200D}\u{2640}\u{FE0F}', + shortName: 'blond-haired_woman_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.person, + keywords: [ + 'blonde', + 'dark skin tone', + 'woman', + 'uc8', + 'diversity', + 'lesbian', + 'women', + 'feminist', + 'beautiful', + 'girls night', + 'human', + 'parent', + 'wife', + 'mom', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'woman', + 'female', + 'feminism', + 'strong woman', + 'cute', + 'pretty', + 'adorable', + 'adore', + 'beauty', + 'cutie', + 'babe', + 'lovely', + 'ladies night', + 'girls only', + 'girlfriend', + 'gender', + 'people', + 'parents', + 'adult', + 'maman', + 'mommy', + 'mama', + 'mother' + ], + modifiable: true), + Emoji( + name: 'person: blond hair', + char: '\u{1F471}', + shortName: 'blond_haired_person', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.person, + keywords: [ + 'blond', + 'uc6', + 'diversity', + 'lesbian', + 'men', + 'feminist', + 'boys night', + 'human', + 'daddy', + 'parent', + 'handsome', + 'wife', + 'husband', + 'mom', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'feminism', + 'strong woman', + 'guys night', + 'gender', + 'people', + 'dad', + 'papa', + 'pere', + 'father', + 'parents', + 'adult', + 'stud', + 'maman', + 'mommy', + 'mama', + 'mother' + ]), + Emoji( + name: 'person: light skin tone, blond hair', + char: '\u{1F471}\u{1F3FB}', + shortName: 'blond_haired_person_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.person, + keywords: [ + 'blond', + 'light skin tone', + 'uc8', + 'diversity', + 'lesbian', + 'men', + 'feminist', + 'boys night', + 'human', + 'daddy', + 'parent', + 'handsome', + 'wife', + 'husband', + 'mom', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'feminism', + 'strong woman', + 'guys night', + 'gender', + 'people', + 'dad', + 'papa', + 'pere', + 'father', + 'parents', + 'adult', + 'stud', + 'maman', + 'mommy', + 'mama', + 'mother' + ], + modifiable: true), + Emoji( + name: 'person: medium-light skin tone, blond hair', + char: '\u{1F471}\u{1F3FC}', + shortName: 'blond_haired_person_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.person, + keywords: [ + 'blond', + 'medium-light skin tone', + 'uc8', + 'diversity', + 'lesbian', + 'men', + 'feminist', + 'boys night', + 'human', + 'daddy', + 'parent', + 'handsome', + 'wife', + 'husband', + 'mom', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'feminism', + 'strong woman', + 'guys night', + 'gender', + 'people', + 'dad', + 'papa', + 'pere', + 'father', + 'parents', + 'adult', + 'stud', + 'maman', + 'mommy', + 'mama', + 'mother' + ], + modifiable: true), + Emoji( + name: 'person: medium skin tone, blond hair', + char: '\u{1F471}\u{1F3FD}', + shortName: 'blond_haired_person_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.person, + keywords: [ + 'blond', + 'medium skin tone', + 'uc8', + 'diversity', + 'lesbian', + 'men', + 'feminist', + 'boys night', + 'human', + 'daddy', + 'parent', + 'handsome', + 'wife', + 'husband', + 'mom', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'feminism', + 'strong woman', + 'guys night', + 'gender', + 'people', + 'dad', + 'papa', + 'pere', + 'father', + 'parents', + 'adult', + 'stud', + 'maman', + 'mommy', + 'mama', + 'mother' + ], + modifiable: true), + Emoji( + name: 'person: medium-dark skin tone, blond hair', + char: '\u{1F471}\u{1F3FE}', + shortName: 'blond_haired_person_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.person, + keywords: [ + 'blond', + 'medium-dark skin tone', + 'uc8', + 'diversity', + 'lesbian', + 'men', + 'feminist', + 'boys night', + 'human', + 'daddy', + 'parent', + 'handsome', + 'wife', + 'husband', + 'mom', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'feminism', + 'strong woman', + 'guys night', + 'gender', + 'people', + 'dad', + 'papa', + 'pere', + 'father', + 'parents', + 'adult', + 'stud', + 'maman', + 'mommy', + 'mama', + 'mother' + ], + modifiable: true), + Emoji( + name: 'person: dark skin tone, blond hair', + char: '\u{1F471}\u{1F3FF}', + shortName: 'blond_haired_person_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.person, + keywords: [ + 'blond', + 'dark skin tone', + 'uc8', + 'diversity', + 'lesbian', + 'men', + 'feminist', + 'boys night', + 'human', + 'daddy', + 'parent', + 'handsome', + 'wife', + 'husband', + 'mom', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'feminism', + 'strong woman', + 'guys night', + 'gender', + 'people', + 'dad', + 'papa', + 'pere', + 'father', + 'parents', + 'adult', + 'stud', + 'maman', + 'mommy', + 'mama', + 'mother' + ], + modifiable: true), + Emoji( + name: 'man: blond hair', + char: '\u{1F471}\u{200D}\u{2642}\u{FE0F}', + shortName: 'blond-haired_man', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.person, + keywords: [ + 'blond', + 'man', + 'uc6', + 'diversity', + 'men', + 'boys night', + 'human', + 'daddy', + 'parent', + 'husband', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'guys night', + 'gender', + 'people', + 'dad', + 'papa', + 'pere', + 'father', + 'parents', + 'adult' + ]), + Emoji( + name: 'man: light skin tone, blond hair', + char: '\u{1F471}\u{1F3FB}\u{200D}\u{2642}\u{FE0F}', + shortName: 'blond-haired_man_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.person, + keywords: [ + 'blond', + 'light skin tone', + 'man', + 'uc8', + 'diversity', + 'men', + 'boys night', + 'human', + 'daddy', + 'parent', + 'husband', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'guys night', + 'gender', + 'people', + 'dad', + 'papa', + 'pere', + 'father', + 'parents', + 'adult' + ], + modifiable: true), + Emoji( + name: 'man: medium-light skin tone, blond hair', + char: '\u{1F471}\u{1F3FC}\u{200D}\u{2642}\u{FE0F}', + shortName: 'blond-haired_man_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.person, + keywords: [ + 'blond', + 'man', + 'medium-light skin tone', + 'uc8', + 'diversity', + 'men', + 'boys night', + 'human', + 'daddy', + 'parent', + 'husband', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'guys night', + 'gender', + 'people', + 'dad', + 'papa', + 'pere', + 'father', + 'parents', + 'adult' + ], + modifiable: true), + Emoji( + name: 'man: medium skin tone, blond hair', + char: '\u{1F471}\u{1F3FD}\u{200D}\u{2642}\u{FE0F}', + shortName: 'blond-haired_man_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.person, + keywords: [ + 'blond', + 'man', + 'medium skin tone', + 'uc8', + 'diversity', + 'men', + 'boys night', + 'human', + 'daddy', + 'parent', + 'husband', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'guys night', + 'gender', + 'people', + 'dad', + 'papa', + 'pere', + 'father', + 'parents', + 'adult' + ], + modifiable: true), + Emoji( + name: 'man: medium-dark skin tone, blond hair', + char: '\u{1F471}\u{1F3FE}\u{200D}\u{2642}\u{FE0F}', + shortName: 'blond-haired_man_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.person, + keywords: [ + 'blond', + 'man', + 'medium-dark skin tone', + 'uc8', + 'diversity', + 'men', + 'boys night', + 'human', + 'daddy', + 'parent', + 'husband', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'guys night', + 'gender', + 'people', + 'dad', + 'papa', + 'pere', + 'father', + 'parents', + 'adult' + ], + modifiable: true), + Emoji( + name: 'man: dark skin tone, blond hair', + char: '\u{1F471}\u{1F3FF}\u{200D}\u{2642}\u{FE0F}', + shortName: 'blond-haired_man_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.person, + keywords: [ + 'blond', + 'dark skin tone', + 'man', + 'uc8', + 'diversity', + 'men', + 'boys night', + 'human', + 'daddy', + 'parent', + 'husband', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'guys night', + 'gender', + 'people', + 'dad', + 'papa', + 'pere', + 'father', + 'parents', + 'adult' + ], + modifiable: true), + Emoji( + name: 'person: white hair', + char: '\u{1F9D1}\u{200D}\u{1F9B3}', + shortName: 'person_white_hair', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.person, + keywords: [ + 'uc12', + 'old people', + 'lesbian', + 'men', + 'human', + 'daddy', + 'parent', + 'handsome', + 'wife', + 'husband', + 'mom', + 'grey hair', + 'grandparents', + 'elderly', + 'grandma', + 'grandpa', + 'grandmother', + 'grandfather', + 'mamie', + 'papy', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'gender', + 'people', + 'dad', + 'papa', + 'pere', + 'father', + 'parents', + 'adult', + 'stud', + 'maman', + 'mommy', + 'mama', + 'mother', + 'silver hair' + ]), + Emoji( + name: 'person: light skin tone, white hair', + char: '\u{1F9D1}\u{1F3FB}\u{200D}\u{1F9B3}', + shortName: 'person_tone1_white_hair', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.person, + keywords: [ + 'uc12', + 'old people', + 'lesbian', + 'men', + 'human', + 'daddy', + 'parent', + 'handsome', + 'wife', + 'husband', + 'mom', + 'grey hair', + 'grandparents', + 'elderly', + 'grandma', + 'grandpa', + 'grandmother', + 'grandfather', + 'mamie', + 'papy', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'gender', + 'people', + 'dad', + 'papa', + 'pere', + 'father', + 'parents', + 'adult', + 'stud', + 'maman', + 'mommy', + 'mama', + 'mother', + 'silver hair' + ], + modifiable: true), + Emoji( + name: 'person: medium-light skin tone, white hair', + char: '\u{1F9D1}\u{1F3FC}\u{200D}\u{1F9B3}', + shortName: 'person_tone2_white_hair', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.person, + keywords: [ + 'uc12', + 'old people', + 'lesbian', + 'men', + 'human', + 'daddy', + 'parent', + 'handsome', + 'wife', + 'husband', + 'mom', + 'grey hair', + 'grandparents', + 'elderly', + 'grandma', + 'grandpa', + 'grandmother', + 'grandfather', + 'mamie', + 'papy', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'gender', + 'people', + 'dad', + 'papa', + 'pere', + 'father', + 'parents', + 'adult', + 'stud', + 'maman', + 'mommy', + 'mama', + 'mother', + 'silver hair' + ], + modifiable: true), + Emoji( + name: 'person: medium skin tone, white hair', + char: '\u{1F9D1}\u{1F3FD}\u{200D}\u{1F9B3}', + shortName: 'person_tone3_white_hair', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.person, + keywords: [ + 'uc12', + 'old people', + 'lesbian', + 'men', + 'human', + 'daddy', + 'parent', + 'handsome', + 'wife', + 'husband', + 'mom', + 'grey hair', + 'grandparents', + 'elderly', + 'grandma', + 'grandpa', + 'grandmother', + 'grandfather', + 'mamie', + 'papy', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'gender', + 'people', + 'dad', + 'papa', + 'pere', + 'father', + 'parents', + 'adult', + 'stud', + 'maman', + 'mommy', + 'mama', + 'mother', + 'silver hair' + ], + modifiable: true), + Emoji( + name: 'person: medium-dark skin tone, white hair', + char: '\u{1F9D1}\u{1F3FE}\u{200D}\u{1F9B3}', + shortName: 'person_tone4_white_hair', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.person, + keywords: [ + 'uc12', + 'old people', + 'lesbian', + 'men', + 'human', + 'daddy', + 'parent', + 'handsome', + 'wife', + 'husband', + 'mom', + 'grey hair', + 'grandparents', + 'elderly', + 'grandma', + 'grandpa', + 'grandmother', + 'grandfather', + 'mamie', + 'papy', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'gender', + 'people', + 'dad', + 'papa', + 'pere', + 'father', + 'parents', + 'adult', + 'stud', + 'maman', + 'mommy', + 'mama', + 'mother', + 'silver hair' + ], + modifiable: true), + Emoji( + name: 'person: dark skin tone, white hair', + char: '\u{1F9D1}\u{1F3FF}\u{200D}\u{1F9B3}', + shortName: 'person_tone5_white_hair', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.person, + keywords: [ + 'uc12', + 'old people', + 'lesbian', + 'men', + 'human', + 'daddy', + 'parent', + 'handsome', + 'wife', + 'husband', + 'mom', + 'grey hair', + 'grandparents', + 'elderly', + 'grandma', + 'grandpa', + 'grandmother', + 'grandfather', + 'mamie', + 'papy', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'gender', + 'people', + 'dad', + 'papa', + 'pere', + 'father', + 'parents', + 'adult', + 'stud', + 'maman', + 'mommy', + 'mama', + 'mother', + 'silver hair' + ], + modifiable: true), + Emoji( + name: 'woman: white hair', + char: '\u{1F469}\u{200D}\u{1F9B3}', + shortName: 'woman_white_haired', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.person, + keywords: [ + 'uc11', + 'old people', + 'diversity', + 'lesbian', + 'women', + 'beautiful', + 'human', + 'parent', + 'wife', + 'mom', + 'grey hair', + 'grandparents', + 'elderly', + 'grandma', + 'grandpa', + 'grandmother', + 'grandfather', + 'mamie', + 'papy', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'woman', + 'female', + 'cute', + 'pretty', + 'adorable', + 'adore', + 'beauty', + 'cutie', + 'babe', + 'lovely', + 'gender', + 'people', + 'parents', + 'adult', + 'maman', + 'mommy', + 'mama', + 'mother', + 'silver hair' + ]), + Emoji( + name: 'woman: light skin tone, white hair', + char: '\u{1F469}\u{1F3FB}\u{200D}\u{1F9B3}', + shortName: 'woman_white_haired_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.person, + keywords: [ + 'uc11', + 'old people', + 'diversity', + 'lesbian', + 'women', + 'beautiful', + 'human', + 'parent', + 'wife', + 'mom', + 'grey hair', + 'grandparents', + 'elderly', + 'grandma', + 'grandpa', + 'grandmother', + 'grandfather', + 'mamie', + 'papy', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'woman', + 'female', + 'cute', + 'pretty', + 'adorable', + 'adore', + 'beauty', + 'cutie', + 'babe', + 'lovely', + 'gender', + 'people', + 'parents', + 'adult', + 'maman', + 'mommy', + 'mama', + 'mother', + 'silver hair' + ], + modifiable: true), + Emoji( + name: 'woman: medium-light skin tone, white hair', + char: '\u{1F469}\u{1F3FC}\u{200D}\u{1F9B3}', + shortName: 'woman_white_haired_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.person, + keywords: [ + 'uc11', + 'old people', + 'diversity', + 'lesbian', + 'women', + 'beautiful', + 'human', + 'parent', + 'wife', + 'mom', + 'grey hair', + 'grandparents', + 'elderly', + 'grandma', + 'grandpa', + 'grandmother', + 'grandfather', + 'mamie', + 'papy', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'woman', + 'female', + 'cute', + 'pretty', + 'adorable', + 'adore', + 'beauty', + 'cutie', + 'babe', + 'lovely', + 'gender', + 'people', + 'parents', + 'adult', + 'maman', + 'mommy', + 'mama', + 'mother', + 'silver hair' + ], + modifiable: true), + Emoji( + name: 'woman: medium skin tone, white hair', + char: '\u{1F469}\u{1F3FD}\u{200D}\u{1F9B3}', + shortName: 'woman_white_haired_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.person, + keywords: [ + 'uc11', + 'old people', + 'diversity', + 'lesbian', + 'women', + 'beautiful', + 'human', + 'parent', + 'wife', + 'mom', + 'grey hair', + 'grandparents', + 'elderly', + 'grandma', + 'grandpa', + 'grandmother', + 'grandfather', + 'mamie', + 'papy', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'woman', + 'female', + 'cute', + 'pretty', + 'adorable', + 'adore', + 'beauty', + 'cutie', + 'babe', + 'lovely', + 'gender', + 'people', + 'parents', + 'adult', + 'maman', + 'mommy', + 'mama', + 'mother', + 'silver hair' + ], + modifiable: true), + Emoji( + name: 'woman: medium-dark skin tone, white hair', + char: '\u{1F469}\u{1F3FE}\u{200D}\u{1F9B3}', + shortName: 'woman_white_haired_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.person, + keywords: [ + 'uc11', + 'old people', + 'diversity', + 'lesbian', + 'women', + 'beautiful', + 'human', + 'parent', + 'wife', + 'mom', + 'grey hair', + 'grandparents', + 'elderly', + 'grandma', + 'grandpa', + 'grandmother', + 'grandfather', + 'mamie', + 'papy', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'woman', + 'female', + 'cute', + 'pretty', + 'adorable', + 'adore', + 'beauty', + 'cutie', + 'babe', + 'lovely', + 'gender', + 'people', + 'parents', + 'adult', + 'maman', + 'mommy', + 'mama', + 'mother', + 'silver hair' + ], + modifiable: true), + Emoji( + name: 'woman: dark skin tone, white hair', + char: '\u{1F469}\u{1F3FF}\u{200D}\u{1F9B3}', + shortName: 'woman_white_haired_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.person, + keywords: [ + 'uc11', + 'old people', + 'diversity', + 'lesbian', + 'women', + 'beautiful', + 'human', + 'parent', + 'wife', + 'mom', + 'grey hair', + 'grandparents', + 'elderly', + 'grandma', + 'grandpa', + 'grandmother', + 'grandfather', + 'mamie', + 'papy', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'woman', + 'female', + 'cute', + 'pretty', + 'adorable', + 'adore', + 'beauty', + 'cutie', + 'babe', + 'lovely', + 'gender', + 'people', + 'parents', + 'adult', + 'maman', + 'mommy', + 'mama', + 'mother', + 'silver hair' + ], + modifiable: true), + Emoji( + name: 'man: white hair', + char: '\u{1F468}\u{200D}\u{1F9B3}', + shortName: 'man_white_haired', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.person, + keywords: [ + 'uc11', + 'old people', + 'diversity', + 'men', + 'human', + 'daddy', + 'parent', + 'handsome', + 'husband', + 'grey hair', + 'grandparents', + 'elderly', + 'grandma', + 'grandpa', + 'grandmother', + 'grandfather', + 'mamie', + 'papy', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'gender', + 'people', + 'dad', + 'papa', + 'pere', + 'father', + 'parents', + 'adult', + 'stud', + 'silver hair' + ]), + Emoji( + name: 'man: light skin tone, white hair', + char: '\u{1F468}\u{1F3FB}\u{200D}\u{1F9B3}', + shortName: 'man_white_haired_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.person, + keywords: [ + 'uc11', + 'old people', + 'diversity', + 'men', + 'human', + 'daddy', + 'parent', + 'handsome', + 'husband', + 'grey hair', + 'grandparents', + 'elderly', + 'grandma', + 'grandpa', + 'grandmother', + 'grandfather', + 'mamie', + 'papy', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'gender', + 'people', + 'dad', + 'papa', + 'pere', + 'father', + 'parents', + 'adult', + 'stud', + 'silver hair' + ], + modifiable: true), + Emoji( + name: 'man: medium-light skin tone, white hair', + char: '\u{1F468}\u{1F3FC}\u{200D}\u{1F9B3}', + shortName: 'man_white_haired_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.person, + keywords: [ + 'uc11', + 'old people', + 'diversity', + 'men', + 'human', + 'daddy', + 'parent', + 'handsome', + 'husband', + 'grey hair', + 'grandparents', + 'elderly', + 'grandma', + 'grandpa', + 'grandmother', + 'grandfather', + 'mamie', + 'papy', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'gender', + 'people', + 'dad', + 'papa', + 'pere', + 'father', + 'parents', + 'adult', + 'stud', + 'silver hair' + ], + modifiable: true), + Emoji( + name: 'man: medium skin tone, white hair', + char: '\u{1F468}\u{1F3FD}\u{200D}\u{1F9B3}', + shortName: 'man_white_haired_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.person, + keywords: [ + 'uc11', + 'old people', + 'diversity', + 'men', + 'human', + 'daddy', + 'parent', + 'handsome', + 'husband', + 'grey hair', + 'grandparents', + 'elderly', + 'grandma', + 'grandpa', + 'grandmother', + 'grandfather', + 'mamie', + 'papy', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'gender', + 'people', + 'dad', + 'papa', + 'pere', + 'father', + 'parents', + 'adult', + 'stud', + 'silver hair' + ], + modifiable: true), + Emoji( + name: 'man: medium-dark skin tone, white hair', + char: '\u{1F468}\u{1F3FE}\u{200D}\u{1F9B3}', + shortName: 'man_white_haired_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.person, + keywords: [ + 'uc11', + 'old people', + 'diversity', + 'men', + 'human', + 'daddy', + 'parent', + 'handsome', + 'husband', + 'grey hair', + 'grandparents', + 'elderly', + 'grandma', + 'grandpa', + 'grandmother', + 'grandfather', + 'mamie', + 'papy', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'gender', + 'people', + 'dad', + 'papa', + 'pere', + 'father', + 'parents', + 'adult', + 'stud', + 'silver hair' + ], + modifiable: true), + Emoji( + name: 'man: dark skin tone, white hair', + char: '\u{1F468}\u{1F3FF}\u{200D}\u{1F9B3}', + shortName: 'man_white_haired_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.person, + keywords: [ + 'uc11', + 'old people', + 'diversity', + 'men', + 'human', + 'daddy', + 'parent', + 'handsome', + 'husband', + 'grey hair', + 'grandparents', + 'elderly', + 'grandma', + 'grandpa', + 'grandmother', + 'grandfather', + 'mamie', + 'papy', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'gender', + 'people', + 'dad', + 'papa', + 'pere', + 'father', + 'parents', + 'adult', + 'stud', + 'silver hair' + ], + modifiable: true), + Emoji( + name: 'person: bald', + char: '\u{1F9D1}\u{200D}\u{1F9B2}', + shortName: 'person_bald', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.person, + keywords: [ + 'uc12', + 'lesbian', + 'men', + 'human', + 'daddy', + 'parent', + 'handsome', + 'wife', + 'husband', + 'mom', + 'shaved head', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'gender', + 'people', + 'dad', + 'papa', + 'pere', + 'father', + 'parents', + 'adult', + 'stud', + 'maman', + 'mommy', + 'mama', + 'mother' + ]), + Emoji( + name: 'person: light skin tone, bald', + char: '\u{1F9D1}\u{1F3FB}\u{200D}\u{1F9B2}', + shortName: 'person_tone1_bald', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.person, + keywords: [ + 'uc12', + 'lesbian', + 'men', + 'human', + 'daddy', + 'parent', + 'handsome', + 'wife', + 'husband', + 'mom', + 'shaved head', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'gender', + 'people', + 'dad', + 'papa', + 'pere', + 'father', + 'parents', + 'adult', + 'stud', + 'maman', + 'mommy', + 'mama', + 'mother' + ], + modifiable: true), + Emoji( + name: 'person: medium-light skin tone, bald', + char: '\u{1F9D1}\u{1F3FC}\u{200D}\u{1F9B2}', + shortName: 'person_tone2_bald', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.person, + keywords: [ + 'uc12', + 'lesbian', + 'men', + 'human', + 'daddy', + 'parent', + 'handsome', + 'wife', + 'husband', + 'mom', + 'shaved head', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'gender', + 'people', + 'dad', + 'papa', + 'pere', + 'father', + 'parents', + 'adult', + 'stud', + 'maman', + 'mommy', + 'mama', + 'mother' + ], + modifiable: true), + Emoji( + name: 'person: medium skin tone, bald', + char: '\u{1F9D1}\u{1F3FD}\u{200D}\u{1F9B2}', + shortName: 'person_tone3_bald', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.person, + keywords: [ + 'uc12', + 'lesbian', + 'men', + 'human', + 'daddy', + 'parent', + 'handsome', + 'wife', + 'husband', + 'mom', + 'shaved head', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'gender', + 'people', + 'dad', + 'papa', + 'pere', + 'father', + 'parents', + 'adult', + 'stud', + 'maman', + 'mommy', + 'mama', + 'mother' + ], + modifiable: true), + Emoji( + name: 'person: medium-dark skin tone, bald', + char: '\u{1F9D1}\u{1F3FE}\u{200D}\u{1F9B2}', + shortName: 'person_tone4_bald', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.person, + keywords: [ + 'uc12', + 'lesbian', + 'men', + 'human', + 'daddy', + 'parent', + 'handsome', + 'wife', + 'husband', + 'mom', + 'shaved head', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'gender', + 'people', + 'dad', + 'papa', + 'pere', + 'father', + 'parents', + 'adult', + 'stud', + 'maman', + 'mommy', + 'mama', + 'mother' + ], + modifiable: true), + Emoji( + name: 'person: dark skin tone, bald', + char: '\u{1F9D1}\u{1F3FF}\u{200D}\u{1F9B2}', + shortName: 'person_tone5_bald', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.person, + keywords: [ + 'uc12', + 'lesbian', + 'men', + 'human', + 'daddy', + 'parent', + 'handsome', + 'wife', + 'husband', + 'mom', + 'shaved head', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'gender', + 'people', + 'dad', + 'papa', + 'pere', + 'father', + 'parents', + 'adult', + 'stud', + 'maman', + 'mommy', + 'mama', + 'mother' + ], + modifiable: true), + Emoji( + name: 'woman: bald', + char: '\u{1F469}\u{200D}\u{1F9B2}', + shortName: 'woman_bald', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.person, + keywords: [ + 'uc11', + 'old people', + 'diversity', + 'lesbian', + 'women', + 'beautiful', + 'human', + 'parent', + 'wife', + 'mom', + 'shaved head', + 'hairless', + 'grandparents', + 'elderly', + 'grandma', + 'grandpa', + 'grandmother', + 'grandfather', + 'mamie', + 'papy', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'woman', + 'female', + 'cute', + 'pretty', + 'adorable', + 'adore', + 'beauty', + 'cutie', + 'babe', + 'lovely', + 'gender', + 'people', + 'parents', + 'adult', + 'maman', + 'mommy', + 'mama', + 'mother', + 'balding' + ]), + Emoji( + name: 'woman: light skin tone, bald', + char: '\u{1F469}\u{1F3FB}\u{200D}\u{1F9B2}', + shortName: 'woman_bald_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.person, + keywords: [ + 'uc11', + 'old people', + 'diversity', + 'lesbian', + 'women', + 'beautiful', + 'human', + 'parent', + 'wife', + 'mom', + 'shaved head', + 'hairless', + 'grandparents', + 'elderly', + 'grandma', + 'grandpa', + 'grandmother', + 'grandfather', + 'mamie', + 'papy', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'woman', + 'female', + 'cute', + 'pretty', + 'adorable', + 'adore', + 'beauty', + 'cutie', + 'babe', + 'lovely', + 'gender', + 'people', + 'parents', + 'adult', + 'maman', + 'mommy', + 'mama', + 'mother', + 'balding' + ], + modifiable: true), + Emoji( + name: 'woman: medium-light skin tone, bald', + char: '\u{1F469}\u{1F3FC}\u{200D}\u{1F9B2}', + shortName: 'woman_bald_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.person, + keywords: [ + 'uc11', + 'old people', + 'diversity', + 'lesbian', + 'women', + 'beautiful', + 'human', + 'parent', + 'wife', + 'mom', + 'shaved head', + 'hairless', + 'grandparents', + 'elderly', + 'grandma', + 'grandpa', + 'grandmother', + 'grandfather', + 'mamie', + 'papy', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'woman', + 'female', + 'cute', + 'pretty', + 'adorable', + 'adore', + 'beauty', + 'cutie', + 'babe', + 'lovely', + 'gender', + 'people', + 'parents', + 'adult', + 'maman', + 'mommy', + 'mama', + 'mother', + 'balding' + ], + modifiable: true), + Emoji( + name: 'woman: medium skin tone, bald', + char: '\u{1F469}\u{1F3FD}\u{200D}\u{1F9B2}', + shortName: 'woman_bald_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.person, + keywords: [ + 'uc11', + 'old people', + 'diversity', + 'lesbian', + 'women', + 'beautiful', + 'human', + 'parent', + 'wife', + 'mom', + 'shaved head', + 'hairless', + 'grandparents', + 'elderly', + 'grandma', + 'grandpa', + 'grandmother', + 'grandfather', + 'mamie', + 'papy', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'woman', + 'female', + 'cute', + 'pretty', + 'adorable', + 'adore', + 'beauty', + 'cutie', + 'babe', + 'lovely', + 'gender', + 'people', + 'parents', + 'adult', + 'maman', + 'mommy', + 'mama', + 'mother', + 'balding' + ], + modifiable: true), + Emoji( + name: 'woman: medium-dark skin tone, bald', + char: '\u{1F469}\u{1F3FE}\u{200D}\u{1F9B2}', + shortName: 'woman_bald_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.person, + keywords: [ + 'uc11', + 'old people', + 'diversity', + 'lesbian', + 'women', + 'beautiful', + 'human', + 'parent', + 'wife', + 'mom', + 'shaved head', + 'hairless', + 'grandparents', + 'elderly', + 'grandma', + 'grandpa', + 'grandmother', + 'grandfather', + 'mamie', + 'papy', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'woman', + 'female', + 'cute', + 'pretty', + 'adorable', + 'adore', + 'beauty', + 'cutie', + 'babe', + 'lovely', + 'gender', + 'people', + 'parents', + 'adult', + 'maman', + 'mommy', + 'mama', + 'mother', + 'balding' + ], + modifiable: true), + Emoji( + name: 'woman: dark skin tone, bald', + char: '\u{1F469}\u{1F3FF}\u{200D}\u{1F9B2}', + shortName: 'woman_bald_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.person, + keywords: [ + 'uc11', + 'old people', + 'diversity', + 'lesbian', + 'women', + 'beautiful', + 'human', + 'parent', + 'wife', + 'mom', + 'shaved head', + 'hairless', + 'grandparents', + 'elderly', + 'grandma', + 'grandpa', + 'grandmother', + 'grandfather', + 'mamie', + 'papy', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'woman', + 'female', + 'cute', + 'pretty', + 'adorable', + 'adore', + 'beauty', + 'cutie', + 'babe', + 'lovely', + 'gender', + 'people', + 'parents', + 'adult', + 'maman', + 'mommy', + 'mama', + 'mother', + 'balding' + ], + modifiable: true), + Emoji( + name: 'man: bald', + char: '\u{1F468}\u{200D}\u{1F9B2}', + shortName: 'man_bald', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.person, + keywords: [ + 'uc11', + 'old people', + 'diversity', + 'men', + 'human', + 'daddy', + 'parent', + 'handsome', + 'husband', + 'shaved head', + 'hairless', + 'grandparents', + 'elderly', + 'grandma', + 'grandpa', + 'grandmother', + 'grandfather', + 'mamie', + 'papy', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'gender', + 'people', + 'dad', + 'papa', + 'pere', + 'father', + 'parents', + 'adult', + 'stud', + 'balding' + ]), + Emoji( + name: 'man: light skin tone, bald', + char: '\u{1F468}\u{1F3FB}\u{200D}\u{1F9B2}', + shortName: 'man_bald_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.person, + keywords: [ + 'uc11', + 'old people', + 'diversity', + 'men', + 'human', + 'daddy', + 'parent', + 'handsome', + 'husband', + 'shaved head', + 'hairless', + 'grandparents', + 'elderly', + 'grandma', + 'grandpa', + 'grandmother', + 'grandfather', + 'mamie', + 'papy', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'gender', + 'people', + 'dad', + 'papa', + 'pere', + 'father', + 'parents', + 'adult', + 'stud', + 'balding' + ], + modifiable: true), + Emoji( + name: 'man: medium-light skin tone, bald', + char: '\u{1F468}\u{1F3FC}\u{200D}\u{1F9B2}', + shortName: 'man_bald_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.person, + keywords: [ + 'uc11', + 'old people', + 'diversity', + 'men', + 'human', + 'daddy', + 'parent', + 'handsome', + 'husband', + 'shaved head', + 'hairless', + 'grandparents', + 'elderly', + 'grandma', + 'grandpa', + 'grandmother', + 'grandfather', + 'mamie', + 'papy', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'gender', + 'people', + 'dad', + 'papa', + 'pere', + 'father', + 'parents', + 'adult', + 'stud', + 'balding' + ], + modifiable: true), + Emoji( + name: 'man: medium skin tone, bald', + char: '\u{1F468}\u{1F3FD}\u{200D}\u{1F9B2}', + shortName: 'man_bald_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.person, + keywords: [ + 'uc11', + 'old people', + 'diversity', + 'men', + 'human', + 'daddy', + 'parent', + 'handsome', + 'husband', + 'shaved head', + 'hairless', + 'grandparents', + 'elderly', + 'grandma', + 'grandpa', + 'grandmother', + 'grandfather', + 'mamie', + 'papy', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'gender', + 'people', + 'dad', + 'papa', + 'pere', + 'father', + 'parents', + 'adult', + 'stud', + 'balding' + ], + modifiable: true), + Emoji( + name: 'man: medium-dark skin tone, bald', + char: '\u{1F468}\u{1F3FE}\u{200D}\u{1F9B2}', + shortName: 'man_bald_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.person, + keywords: [ + 'uc11', + 'old people', + 'diversity', + 'men', + 'human', + 'daddy', + 'parent', + 'handsome', + 'husband', + 'shaved head', + 'hairless', + 'grandparents', + 'elderly', + 'grandma', + 'grandpa', + 'grandmother', + 'grandfather', + 'mamie', + 'papy', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'gender', + 'people', + 'dad', + 'papa', + 'pere', + 'father', + 'parents', + 'adult', + 'stud', + 'balding' + ], + modifiable: true), + Emoji( + name: 'man: dark skin tone, bald', + char: '\u{1F468}\u{1F3FF}\u{200D}\u{1F9B2}', + shortName: 'man_bald_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.person, + keywords: [ + 'uc11', + 'old people', + 'diversity', + 'men', + 'human', + 'daddy', + 'parent', + 'handsome', + 'husband', + 'shaved head', + 'hairless', + 'grandparents', + 'elderly', + 'grandma', + 'grandpa', + 'grandmother', + 'grandfather', + 'mamie', + 'papy', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'gender', + 'people', + 'dad', + 'papa', + 'pere', + 'father', + 'parents', + 'adult', + 'stud', + 'balding' + ], + modifiable: true), + Emoji( + name: 'man: beard', + char: '\u{1F9D4}', + shortName: 'bearded_person', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.person, + keywords: [ + 'uc10', + 'diversity', + 'men', + 'boys night', + 'mustache', + 'human', + 'daddy', + 'parent', + 'handsome', + 'husband', + 'beard', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'guys night', + 'gender', + 'people', + 'dad', + 'papa', + 'pere', + 'father', + 'parents', + 'adult', + 'stud' + ]), + Emoji( + name: 'man: light skin tone, beard', + char: '\u{1F9D4}\u{1F3FB}', + shortName: 'bearded_person_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.person, + keywords: [ + 'beard', + 'light skin tone', + 'uc10', + 'diversity', + 'men', + 'boys night', + 'mustache', + 'human', + 'daddy', + 'parent', + 'handsome', + 'husband', + 'beard', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'guys night', + 'gender', + 'people', + 'dad', + 'papa', + 'pere', + 'father', + 'parents', + 'adult', + 'stud' + ], + modifiable: true), + Emoji( + name: 'man: medium-light skin tone, beard', + char: '\u{1F9D4}\u{1F3FC}', + shortName: 'bearded_person_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.person, + keywords: [ + 'beard', + 'medium-light skin tone', + 'uc10', + 'diversity', + 'men', + 'boys night', + 'mustache', + 'human', + 'daddy', + 'parent', + 'handsome', + 'husband', + 'beard', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'guys night', + 'gender', + 'people', + 'dad', + 'papa', + 'pere', + 'father', + 'parents', + 'adult', + 'stud' + ], + modifiable: true), + Emoji( + name: 'man: medium skin tone, beard', + char: '\u{1F9D4}\u{1F3FD}', + shortName: 'bearded_person_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.person, + keywords: [ + 'beard', + 'medium skin tone', + 'uc10', + 'diversity', + 'men', + 'boys night', + 'mustache', + 'human', + 'daddy', + 'parent', + 'handsome', + 'husband', + 'beard', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'guys night', + 'gender', + 'people', + 'dad', + 'papa', + 'pere', + 'father', + 'parents', + 'adult', + 'stud' + ], + modifiable: true), + Emoji( + name: 'man: medium-dark skin tone, beard', + char: '\u{1F9D4}\u{1F3FE}', + shortName: 'bearded_person_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.person, + keywords: [ + 'beard', + 'medium-dark skin tone', + 'uc10', + 'diversity', + 'men', + 'boys night', + 'mustache', + 'human', + 'daddy', + 'parent', + 'handsome', + 'husband', + 'beard', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'guys night', + 'gender', + 'people', + 'dad', + 'papa', + 'pere', + 'father', + 'parents', + 'adult', + 'stud' + ], + modifiable: true), + Emoji( + name: 'man: dark skin tone, beard', + char: '\u{1F9D4}\u{1F3FF}', + shortName: 'bearded_person_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.person, + keywords: [ + 'beard', + 'dark skin tone', + 'uc10', + 'diversity', + 'men', + 'boys night', + 'mustache', + 'human', + 'daddy', + 'parent', + 'handsome', + 'husband', + 'beard', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'guys night', + 'gender', + 'people', + 'dad', + 'papa', + 'pere', + 'father', + 'parents', + 'adult', + 'stud' + ], + modifiable: true), + Emoji( + name: 'old woman', + char: '\u{1F475}', + shortName: 'older_woman', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.person, + keywords: [ + 'old', + 'woman', + 'uc6', + 'old people', + 'diversity', + 'lesbian', + 'women', + 'vintage', + 'human', + 'cane', + 'parent', + 'wife', + 'mom', + 'grey hair', + 'grandparents', + 'elderly', + 'grandma', + 'grandpa', + 'grandmother', + 'grandfather', + 'mamie', + 'papy', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'woman', + 'female', + 'gender', + 'people', + 'parents', + 'adult', + 'maman', + 'mommy', + 'mama', + 'mother', + 'silver hair' + ]), + Emoji( + name: 'old woman: light skin tone', + char: '\u{1F475}\u{1F3FB}', + shortName: 'older_woman_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.person, + keywords: [ + 'light skin tone', + 'old', + 'woman', + 'uc8', + 'old people', + 'diversity', + 'lesbian', + 'women', + 'vintage', + 'human', + 'cane', + 'parent', + 'wife', + 'mom', + 'grey hair', + 'grandparents', + 'elderly', + 'grandma', + 'grandpa', + 'grandmother', + 'grandfather', + 'mamie', + 'papy', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'woman', + 'female', + 'gender', + 'people', + 'parents', + 'adult', + 'maman', + 'mommy', + 'mama', + 'mother', + 'silver hair' + ], + modifiable: true), + Emoji( + name: 'old woman: medium-light skin tone', + char: '\u{1F475}\u{1F3FC}', + shortName: 'older_woman_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.person, + keywords: [ + 'medium-light skin tone', + 'old', + 'woman', + 'uc8', + 'old people', + 'diversity', + 'lesbian', + 'women', + 'vintage', + 'human', + 'cane', + 'parent', + 'wife', + 'mom', + 'grey hair', + 'grandparents', + 'elderly', + 'grandma', + 'grandpa', + 'grandmother', + 'grandfather', + 'mamie', + 'papy', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'woman', + 'female', + 'gender', + 'people', + 'parents', + 'adult', + 'maman', + 'mommy', + 'mama', + 'mother', + 'silver hair' + ], + modifiable: true), + Emoji( + name: 'old woman: medium skin tone', + char: '\u{1F475}\u{1F3FD}', + shortName: 'older_woman_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.person, + keywords: [ + 'medium skin tone', + 'old', + 'woman', + 'uc8', + 'old people', + 'diversity', + 'lesbian', + 'women', + 'vintage', + 'human', + 'cane', + 'parent', + 'wife', + 'mom', + 'grey hair', + 'grandparents', + 'elderly', + 'grandma', + 'grandpa', + 'grandmother', + 'grandfather', + 'mamie', + 'papy', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'woman', + 'female', + 'gender', + 'people', + 'parents', + 'adult', + 'maman', + 'mommy', + 'mama', + 'mother', + 'silver hair' + ], + modifiable: true), + Emoji( + name: 'old woman: medium-dark skin tone', + char: '\u{1F475}\u{1F3FE}', + shortName: 'older_woman_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.person, + keywords: [ + 'medium-dark skin tone', + 'old', + 'woman', + 'uc8', + 'old people', + 'diversity', + 'lesbian', + 'women', + 'vintage', + 'human', + 'cane', + 'parent', + 'wife', + 'mom', + 'grey hair', + 'grandparents', + 'elderly', + 'grandma', + 'grandpa', + 'grandmother', + 'grandfather', + 'mamie', + 'papy', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'woman', + 'female', + 'gender', + 'people', + 'parents', + 'adult', + 'maman', + 'mommy', + 'mama', + 'mother', + 'silver hair' + ], + modifiable: true), + Emoji( + name: 'old woman: dark skin tone', + char: '\u{1F475}\u{1F3FF}', + shortName: 'older_woman_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.person, + keywords: [ + 'dark skin tone', + 'old', + 'woman', + 'uc8', + 'old people', + 'diversity', + 'lesbian', + 'women', + 'vintage', + 'human', + 'cane', + 'parent', + 'wife', + 'mom', + 'grey hair', + 'grandparents', + 'elderly', + 'grandma', + 'grandpa', + 'grandmother', + 'grandfather', + 'mamie', + 'papy', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'woman', + 'female', + 'gender', + 'people', + 'parents', + 'adult', + 'maman', + 'mommy', + 'mama', + 'mother', + 'silver hair' + ], + modifiable: true), + Emoji( + name: 'older person', + char: '\u{1F9D3}', + shortName: 'older_adult', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.person, + keywords: [ + 'uc10', + 'old people', + 'diversity', + 'lesbian', + 'men', + 'feminist', + 'vintage', + 'human', + 'cane', + 'daddy', + 'parent', + 'handsome', + 'wife', + 'husband', + 'mom', + 'grey hair', + 'grandparents', + 'elderly', + 'grandma', + 'grandpa', + 'grandmother', + 'grandfather', + 'mamie', + 'papy', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'feminism', + 'strong woman', + 'gender', + 'people', + 'dad', + 'papa', + 'pere', + 'father', + 'parents', + 'adult', + 'stud', + 'maman', + 'mommy', + 'mama', + 'mother', + 'silver hair' + ]), + Emoji( + name: 'older person: light skin tone', + char: '\u{1F9D3}\u{1F3FB}', + shortName: 'older_adult_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.person, + keywords: [ + 'gender-neutral', + 'light skin tone', + 'old', + 'uc10', + 'old people', + 'diversity', + 'lesbian', + 'men', + 'feminist', + 'vintage', + 'human', + 'cane', + 'daddy', + 'parent', + 'handsome', + 'wife', + 'husband', + 'mom', + 'grey hair', + 'grandparents', + 'elderly', + 'grandma', + 'grandpa', + 'grandmother', + 'grandfather', + 'mamie', + 'papy', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'feminism', + 'strong woman', + 'gender', + 'people', + 'dad', + 'papa', + 'pere', + 'father', + 'parents', + 'adult', + 'stud', + 'maman', + 'mommy', + 'mama', + 'mother', + 'silver hair' + ], + modifiable: true), + Emoji( + name: 'older person: medium-light skin tone', + char: '\u{1F9D3}\u{1F3FC}', + shortName: 'older_adult_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.person, + keywords: [ + 'gender-neutral', + 'medium-light skin tone', + 'old', + 'uc10', + 'old people', + 'diversity', + 'lesbian', + 'men', + 'feminist', + 'vintage', + 'human', + 'cane', + 'daddy', + 'parent', + 'handsome', + 'wife', + 'husband', + 'mom', + 'grey hair', + 'grandparents', + 'elderly', + 'grandma', + 'grandpa', + 'grandmother', + 'grandfather', + 'mamie', + 'papy', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'feminism', + 'strong woman', + 'gender', + 'people', + 'dad', + 'papa', + 'pere', + 'father', + 'parents', + 'adult', + 'stud', + 'maman', + 'mommy', + 'mama', + 'mother', + 'silver hair' + ], + modifiable: true), + Emoji( + name: 'older person: medium skin tone', + char: '\u{1F9D3}\u{1F3FD}', + shortName: 'older_adult_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.person, + keywords: [ + 'gender-neutral', + 'medium skin tone', + 'old', + 'uc10', + 'old people', + 'diversity', + 'lesbian', + 'men', + 'feminist', + 'vintage', + 'human', + 'cane', + 'daddy', + 'parent', + 'handsome', + 'wife', + 'husband', + 'mom', + 'grey hair', + 'grandparents', + 'elderly', + 'grandma', + 'grandpa', + 'grandmother', + 'grandfather', + 'mamie', + 'papy', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'feminism', + 'strong woman', + 'gender', + 'people', + 'dad', + 'papa', + 'pere', + 'father', + 'parents', + 'adult', + 'stud', + 'maman', + 'mommy', + 'mama', + 'mother', + 'silver hair' + ], + modifiable: true), + Emoji( + name: 'older person: medium-dark skin tone', + char: '\u{1F9D3}\u{1F3FE}', + shortName: 'older_adult_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.person, + keywords: [ + 'gender-neutral', + 'medium-dark skin tone', + 'old', + 'uc10', + 'old people', + 'diversity', + 'lesbian', + 'men', + 'feminist', + 'vintage', + 'human', + 'cane', + 'daddy', + 'parent', + 'handsome', + 'wife', + 'husband', + 'mom', + 'grey hair', + 'grandparents', + 'elderly', + 'grandma', + 'grandpa', + 'grandmother', + 'grandfather', + 'mamie', + 'papy', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'feminism', + 'strong woman', + 'gender', + 'people', + 'dad', + 'papa', + 'pere', + 'father', + 'parents', + 'adult', + 'stud', + 'maman', + 'mommy', + 'mama', + 'mother', + 'silver hair' + ], + modifiable: true), + Emoji( + name: 'older person: dark skin tone', + char: '\u{1F9D3}\u{1F3FF}', + shortName: 'older_adult_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.person, + keywords: [ + 'dark skin tone', + 'gender-neutral', + 'old', + 'uc10', + 'old people', + 'diversity', + 'lesbian', + 'men', + 'feminist', + 'vintage', + 'human', + 'cane', + 'daddy', + 'parent', + 'handsome', + 'wife', + 'husband', + 'mom', + 'grey hair', + 'grandparents', + 'elderly', + 'grandma', + 'grandpa', + 'grandmother', + 'grandfather', + 'mamie', + 'papy', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'feminism', + 'strong woman', + 'gender', + 'people', + 'dad', + 'papa', + 'pere', + 'father', + 'parents', + 'adult', + 'stud', + 'maman', + 'mommy', + 'mama', + 'mother', + 'silver hair' + ], + modifiable: true), + Emoji( + name: 'old man', + char: '\u{1F474}', + shortName: 'older_man', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.person, + keywords: [ + 'man', + 'old', + 'uc6', + 'old people', + 'diversity', + 'men', + 'vintage', + 'human', + 'cane', + 'daddy', + 'parent', + 'handsome', + 'husband', + 'grey hair', + 'hairless', + 'grandparents', + 'elderly', + 'grandma', + 'grandpa', + 'grandmother', + 'grandfather', + 'mamie', + 'papy', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'gender', + 'people', + 'dad', + 'papa', + 'pere', + 'father', + 'parents', + 'adult', + 'stud', + 'silver hair', + 'balding' + ]), + Emoji( + name: 'old man: light skin tone', + char: '\u{1F474}\u{1F3FB}', + shortName: 'older_man_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.person, + keywords: [ + 'light skin tone', + 'man', + 'old', + 'uc8', + 'old people', + 'diversity', + 'men', + 'vintage', + 'human', + 'cane', + 'daddy', + 'parent', + 'handsome', + 'husband', + 'grey hair', + 'hairless', + 'grandparents', + 'elderly', + 'grandma', + 'grandpa', + 'grandmother', + 'grandfather', + 'mamie', + 'papy', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'gender', + 'people', + 'dad', + 'papa', + 'pere', + 'father', + 'parents', + 'adult', + 'stud', + 'silver hair', + 'balding' + ], + modifiable: true), + Emoji( + name: 'old man: medium-light skin tone', + char: '\u{1F474}\u{1F3FC}', + shortName: 'older_man_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.person, + keywords: [ + 'man', + 'medium-light skin tone', + 'old', + 'uc8', + 'old people', + 'diversity', + 'men', + 'vintage', + 'human', + 'cane', + 'daddy', + 'parent', + 'handsome', + 'husband', + 'grey hair', + 'hairless', + 'grandparents', + 'elderly', + 'grandma', + 'grandpa', + 'grandmother', + 'grandfather', + 'mamie', + 'papy', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'gender', + 'people', + 'dad', + 'papa', + 'pere', + 'father', + 'parents', + 'adult', + 'stud', + 'silver hair', + 'balding' + ], + modifiable: true), + Emoji( + name: 'old man: medium skin tone', + char: '\u{1F474}\u{1F3FD}', + shortName: 'older_man_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.person, + keywords: [ + 'man', + 'medium skin tone', + 'old', + 'uc8', + 'old people', + 'diversity', + 'men', + 'vintage', + 'human', + 'cane', + 'daddy', + 'parent', + 'handsome', + 'husband', + 'grey hair', + 'hairless', + 'grandparents', + 'elderly', + 'grandma', + 'grandpa', + 'grandmother', + 'grandfather', + 'mamie', + 'papy', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'gender', + 'people', + 'dad', + 'papa', + 'pere', + 'father', + 'parents', + 'adult', + 'stud', + 'silver hair', + 'balding' + ], + modifiable: true), + Emoji( + name: 'old man: medium-dark skin tone', + char: '\u{1F474}\u{1F3FE}', + shortName: 'older_man_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.person, + keywords: [ + 'man', + 'medium-dark skin tone', + 'old', + 'uc8', + 'old people', + 'diversity', + 'men', + 'vintage', + 'human', + 'cane', + 'daddy', + 'parent', + 'handsome', + 'husband', + 'grey hair', + 'hairless', + 'grandparents', + 'elderly', + 'grandma', + 'grandpa', + 'grandmother', + 'grandfather', + 'mamie', + 'papy', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'gender', + 'people', + 'dad', + 'papa', + 'pere', + 'father', + 'parents', + 'adult', + 'stud', + 'silver hair', + 'balding' + ], + modifiable: true), + Emoji( + name: 'old man: dark skin tone', + char: '\u{1F474}\u{1F3FF}', + shortName: 'older_man_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.person, + keywords: [ + 'dark skin tone', + 'man', + 'old', + 'uc8', + 'old people', + 'diversity', + 'men', + 'vintage', + 'human', + 'cane', + 'daddy', + 'parent', + 'handsome', + 'husband', + 'grey hair', + 'hairless', + 'grandparents', + 'elderly', + 'grandma', + 'grandpa', + 'grandmother', + 'grandfather', + 'mamie', + 'papy', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'gender', + 'people', + 'dad', + 'papa', + 'pere', + 'father', + 'parents', + 'adult', + 'stud', + 'silver hair', + 'balding' + ], + modifiable: true), + Emoji( + name: 'person with skullcap', + char: '\u{1F472}', + shortName: 'man_with_chinese_cap', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'gua pi mao', + 'hat', + 'man', + 'uc6', + 'diversity', + 'men', + 'human', + 'chinese', + 'parent', + 'handsome', + 'husband', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'gender', + 'people', + 'chinois', + 'asian', + 'chine', + 'parents', + 'adult', + 'stud' + ]), + Emoji( + name: 'person with skullcap: light skin tone', + char: '\u{1F472}\u{1F3FB}', + shortName: 'man_with_chinese_cap_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'gua pi mao', + 'hat', + 'light skin tone', + 'man', + 'uc8', + 'diversity', + 'men', + 'human', + 'chinese', + 'parent', + 'handsome', + 'husband', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'gender', + 'people', + 'chinois', + 'asian', + 'chine', + 'parents', + 'adult', + 'stud' + ], + modifiable: true), + Emoji( + name: 'person with skullcap: medium-light skin tone', + char: '\u{1F472}\u{1F3FC}', + shortName: 'man_with_chinese_cap_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'gua pi mao', + 'hat', + 'man', + 'medium-light skin tone', + 'uc8', + 'diversity', + 'men', + 'human', + 'chinese', + 'parent', + 'handsome', + 'husband', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'gender', + 'people', + 'chinois', + 'asian', + 'chine', + 'parents', + 'adult', + 'stud' + ], + modifiable: true), + Emoji( + name: 'person with skullcap: medium skin tone', + char: '\u{1F472}\u{1F3FD}', + shortName: 'man_with_chinese_cap_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'gua pi mao', + 'hat', + 'man', + 'medium skin tone', + 'uc8', + 'diversity', + 'men', + 'human', + 'chinese', + 'parent', + 'handsome', + 'husband', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'gender', + 'people', + 'chinois', + 'asian', + 'chine', + 'parents', + 'adult', + 'stud' + ], + modifiable: true), + Emoji( + name: 'person with skullcap: medium-dark skin tone', + char: '\u{1F472}\u{1F3FE}', + shortName: 'man_with_chinese_cap_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'gua pi mao', + 'hat', + 'man', + 'medium-dark skin tone', + 'uc8', + 'diversity', + 'men', + 'human', + 'chinese', + 'parent', + 'handsome', + 'husband', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'gender', + 'people', + 'chinois', + 'asian', + 'chine', + 'parents', + 'adult', + 'stud' + ], + modifiable: true), + Emoji( + name: 'person with skullcap: dark skin tone', + char: '\u{1F472}\u{1F3FF}', + shortName: 'man_with_chinese_cap_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'dark skin tone', + 'gua pi mao', + 'hat', + 'man', + 'uc8', + 'diversity', + 'men', + 'human', + 'chinese', + 'parent', + 'handsome', + 'husband', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'gender', + 'people', + 'chinois', + 'asian', + 'chine', + 'parents', + 'adult', + 'stud' + ], + modifiable: true), + Emoji( + name: 'person wearing turban', + char: '\u{1F473}', + shortName: 'person_wearing_turban', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'turban', + 'uc6', + 'diversity', + 'men', + 'human', + 'disney', + 'daddy', + 'islam', + 'parent', + 'wife', + 'husband', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'gender', + 'people', + 'cartoon', + 'dad', + 'papa', + 'pere', + 'father', + 'muslim', + 'arab', + 'parents', + 'adult' + ]), + Emoji( + name: 'person wearing turban: light skin tone', + char: '\u{1F473}\u{1F3FB}', + shortName: 'person_wearing_turban_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'light skin tone', + 'turban', + 'uc8', + 'diversity', + 'men', + 'human', + 'disney', + 'daddy', + 'islam', + 'parent', + 'wife', + 'husband', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'gender', + 'people', + 'cartoon', + 'dad', + 'papa', + 'pere', + 'father', + 'muslim', + 'arab', + 'parents', + 'adult' + ], + modifiable: true), + Emoji( + name: 'person wearing turban: medium-light skin tone', + char: '\u{1F473}\u{1F3FC}', + shortName: 'person_wearing_turban_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'medium-light skin tone', + 'turban', + 'uc8', + 'diversity', + 'men', + 'human', + 'disney', + 'daddy', + 'islam', + 'parent', + 'wife', + 'husband', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'gender', + 'people', + 'cartoon', + 'dad', + 'papa', + 'pere', + 'father', + 'muslim', + 'arab', + 'parents', + 'adult' + ], + modifiable: true), + Emoji( + name: 'person wearing turban: medium skin tone', + char: '\u{1F473}\u{1F3FD}', + shortName: 'person_wearing_turban_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'medium skin tone', + 'turban', + 'uc8', + 'diversity', + 'men', + 'human', + 'disney', + 'daddy', + 'islam', + 'parent', + 'wife', + 'husband', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'gender', + 'people', + 'cartoon', + 'dad', + 'papa', + 'pere', + 'father', + 'muslim', + 'arab', + 'parents', + 'adult' + ], + modifiable: true), + Emoji( + name: 'person wearing turban: medium-dark skin tone', + char: '\u{1F473}\u{1F3FE}', + shortName: 'person_wearing_turban_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'medium-dark skin tone', + 'turban', + 'uc8', + 'diversity', + 'men', + 'human', + 'disney', + 'daddy', + 'islam', + 'parent', + 'wife', + 'husband', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'gender', + 'people', + 'cartoon', + 'dad', + 'papa', + 'pere', + 'father', + 'muslim', + 'arab', + 'parents', + 'adult' + ], + modifiable: true), + Emoji( + name: 'person wearing turban: dark skin tone', + char: '\u{1F473}\u{1F3FF}', + shortName: 'person_wearing_turban_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'dark skin tone', + 'turban', + 'uc8', + 'diversity', + 'men', + 'human', + 'disney', + 'daddy', + 'islam', + 'parent', + 'wife', + 'husband', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'gender', + 'people', + 'cartoon', + 'dad', + 'papa', + 'pere', + 'father', + 'muslim', + 'arab', + 'parents', + 'adult' + ], + modifiable: true), + Emoji( + name: 'woman wearing turban', + char: '\u{1F473}\u{200D}\u{2640}\u{FE0F}', + shortName: 'woman_wearing_turban', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'turban', + 'woman', + 'uc6', + 'diversity', + 'women', + 'human', + 'islam', + 'parent', + 'wife', + 'mom', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'woman', + 'female', + 'gender', + 'people', + 'muslim', + 'arab', + 'parents', + 'adult', + 'maman', + 'mommy', + 'mama', + 'mother' + ]), + Emoji( + name: 'woman wearing turban: light skin tone', + char: '\u{1F473}\u{1F3FB}\u{200D}\u{2640}\u{FE0F}', + shortName: 'woman_wearing_turban_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'light skin tone', + 'turban', + 'woman', + 'uc8', + 'diversity', + 'women', + 'human', + 'islam', + 'parent', + 'wife', + 'mom', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'woman', + 'female', + 'gender', + 'people', + 'muslim', + 'arab', + 'parents', + 'adult', + 'maman', + 'mommy', + 'mama', + 'mother' + ], + modifiable: true), + Emoji( + name: 'woman wearing turban: medium-light skin tone', + char: '\u{1F473}\u{1F3FC}\u{200D}\u{2640}\u{FE0F}', + shortName: 'woman_wearing_turban_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'medium-light skin tone', + 'turban', + 'woman', + 'uc8', + 'diversity', + 'women', + 'human', + 'islam', + 'parent', + 'wife', + 'mom', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'woman', + 'female', + 'gender', + 'people', + 'muslim', + 'arab', + 'parents', + 'adult', + 'maman', + 'mommy', + 'mama', + 'mother' + ], + modifiable: true), + Emoji( + name: 'woman wearing turban: medium skin tone', + char: '\u{1F473}\u{1F3FD}\u{200D}\u{2640}\u{FE0F}', + shortName: 'woman_wearing_turban_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'medium skin tone', + 'turban', + 'woman', + 'uc8', + 'diversity', + 'women', + 'human', + 'islam', + 'parent', + 'wife', + 'mom', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'woman', + 'female', + 'gender', + 'people', + 'muslim', + 'arab', + 'parents', + 'adult', + 'maman', + 'mommy', + 'mama', + 'mother' + ], + modifiable: true), + Emoji( + name: 'woman wearing turban: medium-dark skin tone', + char: '\u{1F473}\u{1F3FE}\u{200D}\u{2640}\u{FE0F}', + shortName: 'woman_wearing_turban_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'medium-dark skin tone', + 'turban', + 'woman', + 'uc8', + 'diversity', + 'women', + 'human', + 'islam', + 'parent', + 'wife', + 'mom', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'woman', + 'female', + 'gender', + 'people', + 'muslim', + 'arab', + 'parents', + 'adult', + 'maman', + 'mommy', + 'mama', + 'mother' + ], + modifiable: true), + Emoji( + name: 'woman wearing turban: dark skin tone', + char: '\u{1F473}\u{1F3FF}\u{200D}\u{2640}\u{FE0F}', + shortName: 'woman_wearing_turban_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'dark skin tone', + 'turban', + 'woman', + 'uc8', + 'diversity', + 'women', + 'human', + 'islam', + 'parent', + 'wife', + 'mom', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'woman', + 'female', + 'gender', + 'people', + 'muslim', + 'arab', + 'parents', + 'adult', + 'maman', + 'mommy', + 'mama', + 'mother' + ], + modifiable: true), + Emoji( + name: 'man wearing turban', + char: '\u{1F473}\u{200D}\u{2642}\u{FE0F}', + shortName: 'man_wearing_turban', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'man', + 'turban', + 'uc6', + 'diversity', + 'men', + 'mustache', + 'human', + 'daddy', + 'islam', + 'parent', + 'handsome', + 'husband', + 'beard', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'gender', + 'people', + 'dad', + 'papa', + 'pere', + 'father', + 'muslim', + 'arab', + 'parents', + 'adult', + 'stud' + ]), + Emoji( + name: 'man wearing turban: light skin tone', + char: '\u{1F473}\u{1F3FB}\u{200D}\u{2642}\u{FE0F}', + shortName: 'man_wearing_turban_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'light skin tone', + 'man', + 'turban', + 'uc8', + 'diversity', + 'men', + 'mustache', + 'human', + 'daddy', + 'islam', + 'parent', + 'handsome', + 'husband', + 'beard', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'gender', + 'people', + 'dad', + 'papa', + 'pere', + 'father', + 'muslim', + 'arab', + 'parents', + 'adult', + 'stud' + ], + modifiable: true), + Emoji( + name: 'man wearing turban: medium-light skin tone', + char: '\u{1F473}\u{1F3FC}\u{200D}\u{2642}\u{FE0F}', + shortName: 'man_wearing_turban_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'man', + 'medium-light skin tone', + 'turban', + 'uc8', + 'diversity', + 'men', + 'mustache', + 'human', + 'daddy', + 'islam', + 'parent', + 'handsome', + 'husband', + 'beard', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'gender', + 'people', + 'dad', + 'papa', + 'pere', + 'father', + 'muslim', + 'arab', + 'parents', + 'adult', + 'stud' + ], + modifiable: true), + Emoji( + name: 'man wearing turban: medium skin tone', + char: '\u{1F473}\u{1F3FD}\u{200D}\u{2642}\u{FE0F}', + shortName: 'man_wearing_turban_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'man', + 'medium skin tone', + 'turban', + 'uc8', + 'diversity', + 'men', + 'mustache', + 'human', + 'daddy', + 'islam', + 'parent', + 'handsome', + 'husband', + 'beard', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'gender', + 'people', + 'dad', + 'papa', + 'pere', + 'father', + 'muslim', + 'arab', + 'parents', + 'adult', + 'stud' + ], + modifiable: true), + Emoji( + name: 'man wearing turban: medium-dark skin tone', + char: '\u{1F473}\u{1F3FE}\u{200D}\u{2642}\u{FE0F}', + shortName: 'man_wearing_turban_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'man', + 'medium-dark skin tone', + 'turban', + 'uc8', + 'diversity', + 'men', + 'mustache', + 'human', + 'daddy', + 'islam', + 'parent', + 'handsome', + 'husband', + 'beard', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'gender', + 'people', + 'dad', + 'papa', + 'pere', + 'father', + 'muslim', + 'arab', + 'parents', + 'adult', + 'stud' + ], + modifiable: true), + Emoji( + name: 'man wearing turban: dark skin tone', + char: '\u{1F473}\u{1F3FF}\u{200D}\u{2642}\u{FE0F}', + shortName: 'man_wearing_turban_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'dark skin tone', + 'man', + 'turban', + 'uc8', + 'diversity', + 'men', + 'mustache', + 'human', + 'daddy', + 'islam', + 'parent', + 'handsome', + 'husband', + 'beard', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'gender', + 'people', + 'dad', + 'papa', + 'pere', + 'father', + 'muslim', + 'arab', + 'parents', + 'adult', + 'stud' + ], + modifiable: true), + Emoji( + name: 'woman with headscarf', + char: '\u{1F9D5}', + shortName: 'woman_with_headscarf', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'uc10', + 'diversity', + 'women', + 'girls night', + 'human', + 'parent', + 'hijab', + 'wife', + 'mom', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'woman', + 'female', + 'ladies night', + 'girls only', + 'girlfriend', + 'gender', + 'people', + 'parents', + 'adult', + 'maman', + 'mommy', + 'mama', + 'mother' + ]), + Emoji( + name: 'woman with headscarf: light skin tone', + char: '\u{1F9D5}\u{1F3FB}', + shortName: 'woman_with_headscarf_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'headscarf', + 'hijab', + 'light skin tone', + 'mantilla', + 'tichel', + 'uc10', + 'diversity', + 'women', + 'girls night', + 'human', + 'parent', + 'hijab', + 'wife', + 'mom', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'woman', + 'female', + 'ladies night', + 'girls only', + 'girlfriend', + 'gender', + 'people', + 'parents', + 'adult', + 'maman', + 'mommy', + 'mama', + 'mother' + ], + modifiable: true), + Emoji( + name: 'woman with headscarf: medium-light skin tone', + char: '\u{1F9D5}\u{1F3FC}', + shortName: 'woman_with_headscarf_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'headscarf', + 'hijab', + 'mantilla', + 'medium-light skin tone', + 'tichel', + 'uc10', + 'diversity', + 'women', + 'girls night', + 'human', + 'parent', + 'hijab', + 'wife', + 'mom', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'woman', + 'female', + 'ladies night', + 'girls only', + 'girlfriend', + 'gender', + 'people', + 'parents', + 'adult', + 'maman', + 'mommy', + 'mama', + 'mother' + ], + modifiable: true), + Emoji( + name: 'woman with headscarf: medium skin tone', + char: '\u{1F9D5}\u{1F3FD}', + shortName: 'woman_with_headscarf_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'headscarf', + 'hijab', + 'mantilla', + 'medium skin tone', + 'tichel', + 'uc10', + 'diversity', + 'women', + 'girls night', + 'human', + 'parent', + 'hijab', + 'wife', + 'mom', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'woman', + 'female', + 'ladies night', + 'girls only', + 'girlfriend', + 'gender', + 'people', + 'parents', + 'adult', + 'maman', + 'mommy', + 'mama', + 'mother' + ], + modifiable: true), + Emoji( + name: 'woman with headscarf: medium-dark skin tone', + char: '\u{1F9D5}\u{1F3FE}', + shortName: 'woman_with_headscarf_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'headscarf', + 'hijab', + 'mantilla', + 'medium-dark skin tone', + 'tichel', + 'uc10', + 'diversity', + 'women', + 'girls night', + 'human', + 'parent', + 'hijab', + 'wife', + 'mom', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'woman', + 'female', + 'ladies night', + 'girls only', + 'girlfriend', + 'gender', + 'people', + 'parents', + 'adult', + 'maman', + 'mommy', + 'mama', + 'mother' + ], + modifiable: true), + Emoji( + name: 'woman with headscarf: dark skin tone', + char: '\u{1F9D5}\u{1F3FF}', + shortName: 'woman_with_headscarf_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'dark skin tone', + 'headscarf', + 'hijab', + 'mantilla', + 'tichel', + 'uc10', + 'diversity', + 'women', + 'girls night', + 'human', + 'parent', + 'hijab', + 'wife', + 'mom', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'woman', + 'female', + 'ladies night', + 'girls only', + 'girlfriend', + 'gender', + 'people', + 'parents', + 'adult', + 'maman', + 'mommy', + 'mama', + 'mother' + ], + modifiable: true), + Emoji( + name: 'police officer', + char: '\u{1F46E}', + shortName: 'police_officer', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'cop', + 'officer', + 'police', + 'uc6', + 'diversity', + 'job', + 'police', + '911', + 'mustache', + 'power', + 'pig', + 'help', + 'private', + 'mystery', + 'court', + 'disguise', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'profession', + 'boss', + 'career', + 'cop', + 'policeman', + 'popo', + 'prison', + 'handcuff', + 'jail', + 'justice', + 'emergency', + 'injury', + 'pork', + 'прив', + 'privé', + 'privado', + 'reserved' + ]), + Emoji( + name: 'police officer: light skin tone', + char: '\u{1F46E}\u{1F3FB}', + shortName: 'police_officer_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'cop', + 'light skin tone', + 'officer', + 'police', + 'uc8', + 'diversity', + 'job', + 'police', + '911', + 'mustache', + 'power', + 'pig', + 'help', + 'private', + 'mystery', + 'court', + 'disguise', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'profession', + 'boss', + 'career', + 'cop', + 'policeman', + 'popo', + 'prison', + 'handcuff', + 'jail', + 'justice', + 'emergency', + 'injury', + 'pork', + 'прив', + 'privé', + 'privado', + 'reserved' + ], + modifiable: true), + Emoji( + name: 'police officer: medium-light skin tone', + char: '\u{1F46E}\u{1F3FC}', + shortName: 'police_officer_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'cop', + 'medium-light skin tone', + 'officer', + 'police', + 'uc8', + 'diversity', + 'job', + 'police', + '911', + 'mustache', + 'power', + 'pig', + 'help', + 'private', + 'mystery', + 'court', + 'disguise', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'profession', + 'boss', + 'career', + 'cop', + 'policeman', + 'popo', + 'prison', + 'handcuff', + 'jail', + 'justice', + 'emergency', + 'injury', + 'pork', + 'прив', + 'privé', + 'privado', + 'reserved' + ], + modifiable: true), + Emoji( + name: 'police officer: medium skin tone', + char: '\u{1F46E}\u{1F3FD}', + shortName: 'police_officer_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'cop', + 'medium skin tone', + 'officer', + 'police', + 'uc8', + 'diversity', + 'job', + 'police', + '911', + 'mustache', + 'power', + 'pig', + 'help', + 'private', + 'mystery', + 'court', + 'disguise', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'profession', + 'boss', + 'career', + 'cop', + 'policeman', + 'popo', + 'prison', + 'handcuff', + 'jail', + 'justice', + 'emergency', + 'injury', + 'pork', + 'прив', + 'privé', + 'privado', + 'reserved' + ], + modifiable: true), + Emoji( + name: 'police officer: medium-dark skin tone', + char: '\u{1F46E}\u{1F3FE}', + shortName: 'police_officer_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'cop', + 'medium-dark skin tone', + 'officer', + 'police', + 'uc8', + 'diversity', + 'job', + 'police', + '911', + 'mustache', + 'power', + 'pig', + 'help', + 'private', + 'mystery', + 'court', + 'disguise', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'profession', + 'boss', + 'career', + 'cop', + 'policeman', + 'popo', + 'prison', + 'handcuff', + 'jail', + 'justice', + 'emergency', + 'injury', + 'pork', + 'прив', + 'privé', + 'privado', + 'reserved' + ], + modifiable: true), + Emoji( + name: 'police officer: dark skin tone', + char: '\u{1F46E}\u{1F3FF}', + shortName: 'police_officer_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'cop', + 'dark skin tone', + 'officer', + 'police', + 'uc8', + 'diversity', + 'job', + 'police', + '911', + 'mustache', + 'power', + 'pig', + 'help', + 'private', + 'mystery', + 'court', + 'disguise', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'profession', + 'boss', + 'career', + 'cop', + 'policeman', + 'popo', + 'prison', + 'handcuff', + 'jail', + 'justice', + 'emergency', + 'injury', + 'pork', + 'прив', + 'privé', + 'privado', + 'reserved' + ], + modifiable: true), + Emoji( + name: 'woman police officer', + char: '\u{1F46E}\u{200D}\u{2640}\u{FE0F}', + shortName: 'woman_police_officer', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'cop', + 'officer', + 'police', + 'woman', + 'uc6', + 'diversity', + 'job', + 'police', + '911', + 'power', + 'pig', + 'help', + 'private', + 'mystery', + 'court', + 'disguise', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'profession', + 'boss', + 'career', + 'cop', + 'policeman', + 'popo', + 'prison', + 'handcuff', + 'jail', + 'justice', + 'emergency', + 'injury', + 'pork', + 'прив', + 'privé', + 'privado', + 'reserved' + ]), + Emoji( + name: 'woman police officer: light skin tone', + char: '\u{1F46E}\u{1F3FB}\u{200D}\u{2640}\u{FE0F}', + shortName: 'woman_police_officer_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'cop', + 'light skin tone', + 'officer', + 'police', + 'woman', + 'uc8', + 'diversity', + 'job', + 'police', + '911', + 'power', + 'pig', + 'help', + 'private', + 'mystery', + 'court', + 'disguise', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'profession', + 'boss', + 'career', + 'cop', + 'policeman', + 'popo', + 'prison', + 'handcuff', + 'jail', + 'justice', + 'emergency', + 'injury', + 'pork', + 'прив', + 'privé', + 'privado', + 'reserved' + ], + modifiable: true), + Emoji( + name: 'woman police officer: medium-light skin tone', + char: '\u{1F46E}\u{1F3FC}\u{200D}\u{2640}\u{FE0F}', + shortName: 'woman_police_officer_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'cop', + 'medium-light skin tone', + 'officer', + 'police', + 'woman', + 'uc8', + 'diversity', + 'job', + 'police', + '911', + 'power', + 'pig', + 'help', + 'private', + 'mystery', + 'court', + 'disguise', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'profession', + 'boss', + 'career', + 'cop', + 'policeman', + 'popo', + 'prison', + 'handcuff', + 'jail', + 'justice', + 'emergency', + 'injury', + 'pork', + 'прив', + 'privé', + 'privado', + 'reserved' + ], + modifiable: true), + Emoji( + name: 'woman police officer: medium skin tone', + char: '\u{1F46E}\u{1F3FD}\u{200D}\u{2640}\u{FE0F}', + shortName: 'woman_police_officer_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'cop', + 'medium skin tone', + 'officer', + 'police', + 'woman', + 'uc8', + 'diversity', + 'job', + 'police', + '911', + 'power', + 'pig', + 'help', + 'private', + 'mystery', + 'court', + 'disguise', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'profession', + 'boss', + 'career', + 'cop', + 'policeman', + 'popo', + 'prison', + 'handcuff', + 'jail', + 'justice', + 'emergency', + 'injury', + 'pork', + 'прив', + 'privé', + 'privado', + 'reserved' + ], + modifiable: true), + Emoji( + name: 'woman police officer: medium-dark skin tone', + char: '\u{1F46E}\u{1F3FE}\u{200D}\u{2640}\u{FE0F}', + shortName: 'woman_police_officer_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'cop', + 'medium-dark skin tone', + 'officer', + 'police', + 'woman', + 'uc8', + 'diversity', + 'job', + 'police', + '911', + 'power', + 'pig', + 'help', + 'private', + 'mystery', + 'court', + 'disguise', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'profession', + 'boss', + 'career', + 'cop', + 'policeman', + 'popo', + 'prison', + 'handcuff', + 'jail', + 'justice', + 'emergency', + 'injury', + 'pork', + 'прив', + 'privé', + 'privado', + 'reserved' + ], + modifiable: true), + Emoji( + name: 'woman police officer: dark skin tone', + char: '\u{1F46E}\u{1F3FF}\u{200D}\u{2640}\u{FE0F}', + shortName: 'woman_police_officer_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'cop', + 'dark skin tone', + 'officer', + 'police', + 'woman', + 'uc8', + 'diversity', + 'job', + 'police', + '911', + 'power', + 'pig', + 'help', + 'private', + 'mystery', + 'court', + 'disguise', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'profession', + 'boss', + 'career', + 'cop', + 'policeman', + 'popo', + 'prison', + 'handcuff', + 'jail', + 'justice', + 'emergency', + 'injury', + 'pork', + 'прив', + 'privé', + 'privado', + 'reserved' + ], + modifiable: true), + Emoji( + name: 'man police officer', + char: '\u{1F46E}\u{200D}\u{2642}\u{FE0F}', + shortName: 'man_police_officer', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'cop', + 'man', + 'officer', + 'police', + 'uc6', + 'diversity', + 'job', + 'police', + '911', + 'mustache', + 'power', + 'pig', + 'help', + 'private', + 'mystery', + 'court', + 'disguise', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'profession', + 'boss', + 'career', + 'cop', + 'policeman', + 'popo', + 'prison', + 'handcuff', + 'jail', + 'justice', + 'emergency', + 'injury', + 'pork', + 'прив', + 'privé', + 'privado', + 'reserved' + ]), + Emoji( + name: 'man police officer: light skin tone', + char: '\u{1F46E}\u{1F3FB}\u{200D}\u{2642}\u{FE0F}', + shortName: 'man_police_officer_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'cop', + 'light skin tone', + 'man', + 'officer', + 'police', + 'uc8', + 'diversity', + 'job', + 'police', + '911', + 'mustache', + 'power', + 'pig', + 'help', + 'private', + 'mystery', + 'court', + 'disguise', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'profession', + 'boss', + 'career', + 'cop', + 'policeman', + 'popo', + 'prison', + 'handcuff', + 'jail', + 'justice', + 'emergency', + 'injury', + 'pork', + 'прив', + 'privé', + 'privado', + 'reserved' + ], + modifiable: true), + Emoji( + name: 'man police officer: medium-light skin tone', + char: '\u{1F46E}\u{1F3FC}\u{200D}\u{2642}\u{FE0F}', + shortName: 'man_police_officer_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'cop', + 'man', + 'medium-light skin tone', + 'officer', + 'police', + 'uc8', + 'diversity', + 'job', + 'police', + '911', + 'mustache', + 'power', + 'pig', + 'help', + 'private', + 'mystery', + 'court', + 'disguise', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'profession', + 'boss', + 'career', + 'cop', + 'policeman', + 'popo', + 'prison', + 'handcuff', + 'jail', + 'justice', + 'emergency', + 'injury', + 'pork', + 'прив', + 'privé', + 'privado', + 'reserved' + ], + modifiable: true), + Emoji( + name: 'man police officer: medium skin tone', + char: '\u{1F46E}\u{1F3FD}\u{200D}\u{2642}\u{FE0F}', + shortName: 'man_police_officer_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'cop', + 'man', + 'medium skin tone', + 'officer', + 'police', + 'uc8', + 'diversity', + 'job', + 'police', + '911', + 'mustache', + 'power', + 'pig', + 'help', + 'private', + 'mystery', + 'court', + 'disguise', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'profession', + 'boss', + 'career', + 'cop', + 'policeman', + 'popo', + 'prison', + 'handcuff', + 'jail', + 'justice', + 'emergency', + 'injury', + 'pork', + 'прив', + 'privé', + 'privado', + 'reserved' + ], + modifiable: true), + Emoji( + name: 'man police officer: medium-dark skin tone', + char: '\u{1F46E}\u{1F3FE}\u{200D}\u{2642}\u{FE0F}', + shortName: 'man_police_officer_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'cop', + 'man', + 'medium-dark skin tone', + 'officer', + 'police', + 'uc8', + 'diversity', + 'job', + 'police', + '911', + 'mustache', + 'power', + 'pig', + 'help', + 'private', + 'mystery', + 'court', + 'disguise', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'profession', + 'boss', + 'career', + 'cop', + 'policeman', + 'popo', + 'prison', + 'handcuff', + 'jail', + 'justice', + 'emergency', + 'injury', + 'pork', + 'прив', + 'privé', + 'privado', + 'reserved' + ], + modifiable: true), + Emoji( + name: 'man police officer: dark skin tone', + char: '\u{1F46E}\u{1F3FF}\u{200D}\u{2642}\u{FE0F}', + shortName: 'man_police_officer_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'cop', + 'dark skin tone', + 'man', + 'officer', + 'police', + 'uc8', + 'diversity', + 'job', + 'police', + '911', + 'mustache', + 'power', + 'pig', + 'help', + 'private', + 'mystery', + 'court', + 'disguise', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'profession', + 'boss', + 'career', + 'cop', + 'policeman', + 'popo', + 'prison', + 'handcuff', + 'jail', + 'justice', + 'emergency', + 'injury', + 'pork', + 'прив', + 'privé', + 'privado', + 'reserved' + ], + modifiable: true), + Emoji( + name: 'construction worker', + char: '\u{1F477}', + shortName: 'construction_worker', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'construction', + 'hat', + 'worker', + 'uc6', + 'diversity', + 'job', + 'build', + 'construction', + 'helmet', + 'disguise', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'profession', + 'boss', + 'career' + ]), + Emoji( + name: 'construction worker: light skin tone', + char: '\u{1F477}\u{1F3FB}', + shortName: 'construction_worker_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'construction', + 'hat', + 'light skin tone', + 'worker', + 'uc8', + 'diversity', + 'job', + 'build', + 'construction', + 'helmet', + 'disguise', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'profession', + 'boss', + 'career' + ], + modifiable: true), + Emoji( + name: 'construction worker: medium-light skin tone', + char: '\u{1F477}\u{1F3FC}', + shortName: 'construction_worker_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'construction', + 'hat', + 'medium-light skin tone', + 'worker', + 'uc8', + 'diversity', + 'job', + 'build', + 'construction', + 'helmet', + 'disguise', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'profession', + 'boss', + 'career' + ], + modifiable: true), + Emoji( + name: 'construction worker: medium skin tone', + char: '\u{1F477}\u{1F3FD}', + shortName: 'construction_worker_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'construction', + 'hat', + 'medium skin tone', + 'worker', + 'uc8', + 'diversity', + 'job', + 'build', + 'construction', + 'helmet', + 'disguise', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'profession', + 'boss', + 'career' + ], + modifiable: true), + Emoji( + name: 'construction worker: medium-dark skin tone', + char: '\u{1F477}\u{1F3FE}', + shortName: 'construction_worker_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'construction', + 'hat', + 'medium-dark skin tone', + 'worker', + 'uc8', + 'diversity', + 'job', + 'build', + 'construction', + 'helmet', + 'disguise', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'profession', + 'boss', + 'career' + ], + modifiable: true), + Emoji( + name: 'construction worker: dark skin tone', + char: '\u{1F477}\u{1F3FF}', + shortName: 'construction_worker_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'construction', + 'dark skin tone', + 'hat', + 'worker', + 'uc8', + 'diversity', + 'job', + 'build', + 'construction', + 'helmet', + 'disguise', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'profession', + 'boss', + 'career' + ], + modifiable: true), + Emoji( + name: 'woman construction worker', + char: '\u{1F477}\u{200D}\u{2640}\u{FE0F}', + shortName: 'woman_construction_worker', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'construction', + 'woman', + 'worker', + 'uc6', + 'diversity', + 'job', + 'build', + 'construction', + 'helmet', + 'disguise', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'profession', + 'boss', + 'career' + ]), + Emoji( + name: 'woman construction worker: light skin tone', + char: '\u{1F477}\u{1F3FB}\u{200D}\u{2640}\u{FE0F}', + shortName: 'woman_construction_worker_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'construction', + 'light skin tone', + 'woman', + 'worker', + 'uc8', + 'diversity', + 'job', + 'build', + 'construction', + 'helmet', + 'disguise', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'profession', + 'boss', + 'career' + ], + modifiable: true), + Emoji( + name: 'woman construction worker: medium-light skin tone', + char: '\u{1F477}\u{1F3FC}\u{200D}\u{2640}\u{FE0F}', + shortName: 'woman_construction_worker_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'construction', + 'medium-light skin tone', + 'woman', + 'worker', + 'uc8', + 'diversity', + 'job', + 'build', + 'construction', + 'helmet', + 'disguise', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'profession', + 'boss', + 'career' + ], + modifiable: true), + Emoji( + name: 'woman construction worker: medium skin tone', + char: '\u{1F477}\u{1F3FD}\u{200D}\u{2640}\u{FE0F}', + shortName: 'woman_construction_worker_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'construction', + 'medium skin tone', + 'woman', + 'worker', + 'uc8', + 'diversity', + 'job', + 'build', + 'construction', + 'helmet', + 'disguise', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'profession', + 'boss', + 'career' + ], + modifiable: true), + Emoji( + name: 'woman construction worker: medium-dark skin tone', + char: '\u{1F477}\u{1F3FE}\u{200D}\u{2640}\u{FE0F}', + shortName: 'woman_construction_worker_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'construction', + 'medium-dark skin tone', + 'woman', + 'worker', + 'uc8', + 'diversity', + 'job', + 'build', + 'construction', + 'helmet', + 'disguise', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'profession', + 'boss', + 'career' + ], + modifiable: true), + Emoji( + name: 'woman construction worker: dark skin tone', + char: '\u{1F477}\u{1F3FF}\u{200D}\u{2640}\u{FE0F}', + shortName: 'woman_construction_worker_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'construction', + 'dark skin tone', + 'woman', + 'worker', + 'uc8', + 'diversity', + 'job', + 'build', + 'construction', + 'helmet', + 'disguise', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'profession', + 'boss', + 'career' + ], + modifiable: true), + Emoji( + name: 'man construction worker', + char: '\u{1F477}\u{200D}\u{2642}\u{FE0F}', + shortName: 'man_construction_worker', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'construction', + 'man', + 'worker', + 'uc6', + 'diversity', + 'job', + 'build', + 'construction', + 'helmet', + 'disguise', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'profession', + 'boss', + 'career' + ]), + Emoji( + name: 'man construction worker: light skin tone', + char: '\u{1F477}\u{1F3FB}\u{200D}\u{2642}\u{FE0F}', + shortName: 'man_construction_worker_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'construction', + 'light skin tone', + 'man', + 'worker', + 'uc8', + 'diversity', + 'job', + 'build', + 'construction', + 'helmet', + 'disguise', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'profession', + 'boss', + 'career' + ], + modifiable: true), + Emoji( + name: 'man construction worker: medium-light skin tone', + char: '\u{1F477}\u{1F3FC}\u{200D}\u{2642}\u{FE0F}', + shortName: 'man_construction_worker_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'construction', + 'man', + 'medium-light skin tone', + 'worker', + 'uc8', + 'diversity', + 'job', + 'build', + 'construction', + 'helmet', + 'disguise', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'profession', + 'boss', + 'career' + ], + modifiable: true), + Emoji( + name: 'man construction worker: medium skin tone', + char: '\u{1F477}\u{1F3FD}\u{200D}\u{2642}\u{FE0F}', + shortName: 'man_construction_worker_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'construction', + 'man', + 'medium skin tone', + 'worker', + 'uc8', + 'diversity', + 'job', + 'build', + 'construction', + 'helmet', + 'disguise', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'profession', + 'boss', + 'career' + ], + modifiable: true), + Emoji( + name: 'man construction worker: medium-dark skin tone', + char: '\u{1F477}\u{1F3FE}\u{200D}\u{2642}\u{FE0F}', + shortName: 'man_construction_worker_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'construction', + 'man', + 'medium-dark skin tone', + 'worker', + 'uc8', + 'diversity', + 'job', + 'build', + 'construction', + 'helmet', + 'disguise', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'profession', + 'boss', + 'career' + ], + modifiable: true), + Emoji( + name: 'man construction worker: dark skin tone', + char: '\u{1F477}\u{1F3FF}\u{200D}\u{2642}\u{FE0F}', + shortName: 'man_construction_worker_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'construction', + 'dark skin tone', + 'man', + 'worker', + 'uc8', + 'diversity', + 'job', + 'build', + 'construction', + 'helmet', + 'disguise', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'profession', + 'boss', + 'career' + ], + modifiable: true), + Emoji( + name: 'guard', + char: '\u{1F482}', + shortName: 'guard', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'guard', + 'uc6', + 'diversity', + 'job', + 'queen', + 'england', + 'private', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'profession', + 'boss', + 'career', + 'king', + 'prince', + 'princess', + 'united kingdom', + 'london', + 'uk', + 'прив', + 'privé', + 'privado', + 'reserved' + ]), + Emoji( + name: 'guard: light skin tone', + char: '\u{1F482}\u{1F3FB}', + shortName: 'guard_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'guard', + 'light skin tone', + 'uc8', + 'diversity', + 'job', + 'queen', + 'england', + 'private', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'profession', + 'boss', + 'career', + 'king', + 'prince', + 'princess', + 'united kingdom', + 'london', + 'uk', + 'прив', + 'privé', + 'privado', + 'reserved' + ], + modifiable: true), + Emoji( + name: 'guard: medium-light skin tone', + char: '\u{1F482}\u{1F3FC}', + shortName: 'guard_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'guard', + 'medium-light skin tone', + 'uc8', + 'diversity', + 'job', + 'queen', + 'england', + 'private', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'profession', + 'boss', + 'career', + 'king', + 'prince', + 'princess', + 'united kingdom', + 'london', + 'uk', + 'прив', + 'privé', + 'privado', + 'reserved' + ], + modifiable: true), + Emoji( + name: 'guard: medium skin tone', + char: '\u{1F482}\u{1F3FD}', + shortName: 'guard_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'guard', + 'medium skin tone', + 'uc8', + 'diversity', + 'job', + 'queen', + 'england', + 'private', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'profession', + 'boss', + 'career', + 'king', + 'prince', + 'princess', + 'united kingdom', + 'london', + 'uk', + 'прив', + 'privé', + 'privado', + 'reserved' + ], + modifiable: true), + Emoji( + name: 'guard: medium-dark skin tone', + char: '\u{1F482}\u{1F3FE}', + shortName: 'guard_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'guard', + 'medium-dark skin tone', + 'uc8', + 'diversity', + 'job', + 'queen', + 'england', + 'private', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'profession', + 'boss', + 'career', + 'king', + 'prince', + 'princess', + 'united kingdom', + 'london', + 'uk', + 'прив', + 'privé', + 'privado', + 'reserved' + ], + modifiable: true), + Emoji( + name: 'guard: dark skin tone', + char: '\u{1F482}\u{1F3FF}', + shortName: 'guard_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'dark skin tone', + 'guard', + 'uc8', + 'diversity', + 'job', + 'queen', + 'england', + 'private', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'profession', + 'boss', + 'career', + 'king', + 'prince', + 'princess', + 'united kingdom', + 'london', + 'uk', + 'прив', + 'privé', + 'privado', + 'reserved' + ], + modifiable: true), + Emoji( + name: 'woman guard', + char: '\u{1F482}\u{200D}\u{2640}\u{FE0F}', + shortName: 'woman_guard', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'guard', + 'woman', + 'uc6', + 'diversity', + 'job', + 'queen', + 'england', + 'private', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'profession', + 'boss', + 'career', + 'king', + 'prince', + 'princess', + 'united kingdom', + 'london', + 'uk', + 'прив', + 'privé', + 'privado', + 'reserved' + ]), + Emoji( + name: 'woman guard: light skin tone', + char: '\u{1F482}\u{1F3FB}\u{200D}\u{2640}\u{FE0F}', + shortName: 'woman_guard_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'guard', + 'light skin tone', + 'woman', + 'uc8', + 'diversity', + 'job', + 'queen', + 'england', + 'private', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'profession', + 'boss', + 'career', + 'king', + 'prince', + 'princess', + 'united kingdom', + 'london', + 'uk', + 'прив', + 'privé', + 'privado', + 'reserved' + ], + modifiable: true), + Emoji( + name: 'woman guard: medium-light skin tone', + char: '\u{1F482}\u{1F3FC}\u{200D}\u{2640}\u{FE0F}', + shortName: 'woman_guard_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'guard', + 'medium-light skin tone', + 'woman', + 'uc8', + 'diversity', + 'job', + 'queen', + 'england', + 'private', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'profession', + 'boss', + 'career', + 'king', + 'prince', + 'princess', + 'united kingdom', + 'london', + 'uk', + 'прив', + 'privé', + 'privado', + 'reserved' + ], + modifiable: true), + Emoji( + name: 'woman guard: medium skin tone', + char: '\u{1F482}\u{1F3FD}\u{200D}\u{2640}\u{FE0F}', + shortName: 'woman_guard_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'guard', + 'medium skin tone', + 'woman', + 'uc8', + 'diversity', + 'job', + 'queen', + 'england', + 'private', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'profession', + 'boss', + 'career', + 'king', + 'prince', + 'princess', + 'united kingdom', + 'london', + 'uk', + 'прив', + 'privé', + 'privado', + 'reserved' + ], + modifiable: true), + Emoji( + name: 'woman guard: medium-dark skin tone', + char: '\u{1F482}\u{1F3FE}\u{200D}\u{2640}\u{FE0F}', + shortName: 'woman_guard_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'guard', + 'medium-dark skin tone', + 'woman', + 'uc8', + 'diversity', + 'job', + 'queen', + 'england', + 'private', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'profession', + 'boss', + 'career', + 'king', + 'prince', + 'princess', + 'united kingdom', + 'london', + 'uk', + 'прив', + 'privé', + 'privado', + 'reserved' + ], + modifiable: true), + Emoji( + name: 'woman guard: dark skin tone', + char: '\u{1F482}\u{1F3FF}\u{200D}\u{2640}\u{FE0F}', + shortName: 'woman_guard_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'dark skin tone', + 'guard', + 'woman', + 'uc8', + 'diversity', + 'job', + 'queen', + 'england', + 'private', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'profession', + 'boss', + 'career', + 'king', + 'prince', + 'princess', + 'united kingdom', + 'london', + 'uk', + 'прив', + 'privé', + 'privado', + 'reserved' + ], + modifiable: true), + Emoji( + name: 'man guard', + char: '\u{1F482}\u{200D}\u{2642}\u{FE0F}', + shortName: 'man_guard', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'guard', + 'man', + 'uc6', + 'diversity', + 'job', + 'queen', + 'england', + 'private', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'profession', + 'boss', + 'career', + 'king', + 'prince', + 'princess', + 'united kingdom', + 'london', + 'uk', + 'прив', + 'privé', + 'privado', + 'reserved' + ]), + Emoji( + name: 'man guard: light skin tone', + char: '\u{1F482}\u{1F3FB}\u{200D}\u{2642}\u{FE0F}', + shortName: 'man_guard_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'guard', + 'light skin tone', + 'man', + 'uc8', + 'diversity', + 'job', + 'queen', + 'england', + 'private', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'profession', + 'boss', + 'career', + 'king', + 'prince', + 'princess', + 'united kingdom', + 'london', + 'uk', + 'прив', + 'privé', + 'privado', + 'reserved' + ], + modifiable: true), + Emoji( + name: 'man guard: medium-light skin tone', + char: '\u{1F482}\u{1F3FC}\u{200D}\u{2642}\u{FE0F}', + shortName: 'man_guard_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'guard', + 'man', + 'medium-light skin tone', + 'uc8', + 'diversity', + 'job', + 'queen', + 'england', + 'private', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'profession', + 'boss', + 'career', + 'king', + 'prince', + 'princess', + 'united kingdom', + 'london', + 'uk', + 'прив', + 'privé', + 'privado', + 'reserved' + ], + modifiable: true), + Emoji( + name: 'man guard: medium skin tone', + char: '\u{1F482}\u{1F3FD}\u{200D}\u{2642}\u{FE0F}', + shortName: 'man_guard_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'guard', + 'man', + 'medium skin tone', + 'uc8', + 'diversity', + 'job', + 'queen', + 'england', + 'private', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'profession', + 'boss', + 'career', + 'king', + 'prince', + 'princess', + 'united kingdom', + 'london', + 'uk', + 'прив', + 'privé', + 'privado', + 'reserved' + ], + modifiable: true), + Emoji( + name: 'man guard: medium-dark skin tone', + char: '\u{1F482}\u{1F3FE}\u{200D}\u{2642}\u{FE0F}', + shortName: 'man_guard_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'guard', + 'man', + 'medium-dark skin tone', + 'uc8', + 'diversity', + 'job', + 'queen', + 'england', + 'private', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'profession', + 'boss', + 'career', + 'king', + 'prince', + 'princess', + 'united kingdom', + 'london', + 'uk', + 'прив', + 'privé', + 'privado', + 'reserved' + ], + modifiable: true), + Emoji( + name: 'man guard: dark skin tone', + char: '\u{1F482}\u{1F3FF}\u{200D}\u{2642}\u{FE0F}', + shortName: 'man_guard_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'dark skin tone', + 'guard', + 'man', + 'uc8', + 'diversity', + 'job', + 'queen', + 'england', + 'private', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'profession', + 'boss', + 'career', + 'king', + 'prince', + 'princess', + 'united kingdom', + 'london', + 'uk', + 'прив', + 'privé', + 'privado', + 'reserved' + ], + modifiable: true), + Emoji( + name: 'detective', + char: '\u{1F575}', + shortName: 'detective', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'detective', + 'sleuth', + 'spy', + 'uc7', + 'diversity', + 'glasses', + 'halloween', + 'job', + 'google', + 'search', + 'detective', + 'super hero', + 'private', + 'mystery', + 'clever', + 'disguise', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'eyeglasses', + 'eye glasses', + 'samhain', + 'profession', + 'boss', + 'career', + 'look', + 'find', + 'looking', + 'see', + 'superhero', + 'superman', + 'batman', + 'прив', + 'privé', + 'privado', + 'reserved', + 'witty' + ]), + Emoji( + name: 'detective: light skin tone', + char: '\u{1F575}\u{1F3FB}', + shortName: 'detective_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'detective', + 'light skin tone', + 'sleuth', + 'spy', + 'uc8', + 'diversity', + 'glasses', + 'halloween', + 'job', + 'google', + 'search', + 'detective', + 'super hero', + 'private', + 'mystery', + 'clever', + 'disguise', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'eyeglasses', + 'eye glasses', + 'samhain', + 'profession', + 'boss', + 'career', + 'look', + 'find', + 'looking', + 'see', + 'superhero', + 'superman', + 'batman', + 'прив', + 'privé', + 'privado', + 'reserved', + 'witty' + ], + modifiable: true), + Emoji( + name: 'detective: medium-light skin tone', + char: '\u{1F575}\u{1F3FC}', + shortName: 'detective_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'detective', + 'medium-light skin tone', + 'sleuth', + 'spy', + 'uc8', + 'diversity', + 'glasses', + 'halloween', + 'job', + 'google', + 'search', + 'detective', + 'super hero', + 'private', + 'mystery', + 'clever', + 'disguise', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'eyeglasses', + 'eye glasses', + 'samhain', + 'profession', + 'boss', + 'career', + 'look', + 'find', + 'looking', + 'see', + 'superhero', + 'superman', + 'batman', + 'прив', + 'privé', + 'privado', + 'reserved', + 'witty' + ], + modifiable: true), + Emoji( + name: 'detective: medium skin tone', + char: '\u{1F575}\u{1F3FD}', + shortName: 'detective_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'detective', + 'medium skin tone', + 'sleuth', + 'spy', + 'uc8', + 'diversity', + 'glasses', + 'halloween', + 'job', + 'google', + 'search', + 'detective', + 'super hero', + 'private', + 'mystery', + 'clever', + 'disguise', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'eyeglasses', + 'eye glasses', + 'samhain', + 'profession', + 'boss', + 'career', + 'look', + 'find', + 'looking', + 'see', + 'superhero', + 'superman', + 'batman', + 'прив', + 'privé', + 'privado', + 'reserved', + 'witty' + ], + modifiable: true), + Emoji( + name: 'detective: medium-dark skin tone', + char: '\u{1F575}\u{1F3FE}', + shortName: 'detective_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'detective', + 'medium-dark skin tone', + 'sleuth', + 'spy', + 'uc8', + 'diversity', + 'glasses', + 'halloween', + 'job', + 'google', + 'search', + 'detective', + 'super hero', + 'private', + 'mystery', + 'clever', + 'disguise', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'eyeglasses', + 'eye glasses', + 'samhain', + 'profession', + 'boss', + 'career', + 'look', + 'find', + 'looking', + 'see', + 'superhero', + 'superman', + 'batman', + 'прив', + 'privé', + 'privado', + 'reserved', + 'witty' + ], + modifiable: true), + Emoji( + name: 'detective: dark skin tone', + char: '\u{1F575}\u{1F3FF}', + shortName: 'detective_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'dark skin tone', + 'detective', + 'sleuth', + 'spy', + 'uc8', + 'diversity', + 'glasses', + 'halloween', + 'job', + 'google', + 'search', + 'detective', + 'super hero', + 'private', + 'mystery', + 'clever', + 'disguise', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'eyeglasses', + 'eye glasses', + 'samhain', + 'profession', + 'boss', + 'career', + 'look', + 'find', + 'looking', + 'see', + 'superhero', + 'superman', + 'batman', + 'прив', + 'privé', + 'privado', + 'reserved', + 'witty' + ], + modifiable: true), + Emoji( + name: 'woman detective', + char: '\u{1F575}\u{FE0F}\u{200D}\u{2640}\u{FE0F}', + shortName: 'woman_detective', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'detective', + 'sleuth', + 'spy', + 'woman', + 'uc7', + 'diversity', + 'halloween', + 'job', + 'search', + 'detective', + 'super hero', + 'private', + 'mystery', + 'clever', + 'disguise', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'samhain', + 'profession', + 'boss', + 'career', + 'look', + 'find', + 'looking', + 'see', + 'superhero', + 'superman', + 'batman', + 'прив', + 'privé', + 'privado', + 'reserved', + 'witty' + ]), + Emoji( + name: 'woman detective: light skin tone', + char: '\u{1F575}\u{1F3FB}\u{200D}\u{2640}\u{FE0F}', + shortName: 'woman_detective_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'detective', + 'light skin tone', + 'sleuth', + 'spy', + 'woman', + 'uc8', + 'diversity', + 'halloween', + 'job', + 'search', + 'detective', + 'super hero', + 'private', + 'mystery', + 'clever', + 'disguise', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'samhain', + 'profession', + 'boss', + 'career', + 'look', + 'find', + 'looking', + 'see', + 'superhero', + 'superman', + 'batman', + 'прив', + 'privé', + 'privado', + 'reserved', + 'witty' + ], + modifiable: true), + Emoji( + name: 'woman detective: medium-light skin tone', + char: '\u{1F575}\u{1F3FC}\u{200D}\u{2640}\u{FE0F}', + shortName: 'woman_detective_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'detective', + 'medium-light skin tone', + 'sleuth', + 'spy', + 'woman', + 'uc8', + 'diversity', + 'halloween', + 'job', + 'search', + 'detective', + 'super hero', + 'private', + 'mystery', + 'clever', + 'disguise', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'samhain', + 'profession', + 'boss', + 'career', + 'look', + 'find', + 'looking', + 'see', + 'superhero', + 'superman', + 'batman', + 'прив', + 'privé', + 'privado', + 'reserved', + 'witty' + ], + modifiable: true), + Emoji( + name: 'woman detective: medium skin tone', + char: '\u{1F575}\u{1F3FD}\u{200D}\u{2640}\u{FE0F}', + shortName: 'woman_detective_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'detective', + 'medium skin tone', + 'sleuth', + 'spy', + 'woman', + 'uc8', + 'diversity', + 'halloween', + 'job', + 'search', + 'detective', + 'super hero', + 'private', + 'mystery', + 'clever', + 'disguise', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'samhain', + 'profession', + 'boss', + 'career', + 'look', + 'find', + 'looking', + 'see', + 'superhero', + 'superman', + 'batman', + 'прив', + 'privé', + 'privado', + 'reserved', + 'witty' + ], + modifiable: true), + Emoji( + name: 'woman detective: medium-dark skin tone', + char: '\u{1F575}\u{1F3FE}\u{200D}\u{2640}\u{FE0F}', + shortName: 'woman_detective_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'detective', + 'medium-dark skin tone', + 'sleuth', + 'spy', + 'woman', + 'uc8', + 'diversity', + 'halloween', + 'job', + 'search', + 'detective', + 'super hero', + 'private', + 'mystery', + 'clever', + 'disguise', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'samhain', + 'profession', + 'boss', + 'career', + 'look', + 'find', + 'looking', + 'see', + 'superhero', + 'superman', + 'batman', + 'прив', + 'privé', + 'privado', + 'reserved', + 'witty' + ], + modifiable: true), + Emoji( + name: 'woman detective: dark skin tone', + char: '\u{1F575}\u{1F3FF}\u{200D}\u{2640}\u{FE0F}', + shortName: 'woman_detective_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'dark skin tone', + 'detective', + 'sleuth', + 'spy', + 'woman', + 'uc8', + 'diversity', + 'halloween', + 'job', + 'search', + 'detective', + 'super hero', + 'private', + 'mystery', + 'clever', + 'disguise', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'samhain', + 'profession', + 'boss', + 'career', + 'look', + 'find', + 'looking', + 'see', + 'superhero', + 'superman', + 'batman', + 'прив', + 'privé', + 'privado', + 'reserved', + 'witty' + ], + modifiable: true), + Emoji( + name: 'man detective', + char: '\u{1F575}\u{FE0F}\u{200D}\u{2642}\u{FE0F}', + shortName: 'man_detective', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'detective', + 'man', + 'sleuth', + 'spy', + 'uc7', + 'diversity', + 'halloween', + 'job', + 'search', + 'detective', + 'super hero', + 'private', + 'mystery', + 'clever', + 'disguise', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'samhain', + 'profession', + 'boss', + 'career', + 'look', + 'find', + 'looking', + 'see', + 'superhero', + 'superman', + 'batman', + 'прив', + 'privé', + 'privado', + 'reserved', + 'witty' + ]), + Emoji( + name: 'man detective: light skin tone', + char: '\u{1F575}\u{1F3FB}\u{200D}\u{2642}\u{FE0F}', + shortName: 'man_detective_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'detective', + 'light skin tone', + 'man', + 'sleuth', + 'spy', + 'uc8', + 'diversity', + 'halloween', + 'job', + 'search', + 'detective', + 'super hero', + 'private', + 'mystery', + 'clever', + 'disguise', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'samhain', + 'profession', + 'boss', + 'career', + 'look', + 'find', + 'looking', + 'see', + 'superhero', + 'superman', + 'batman', + 'прив', + 'privé', + 'privado', + 'reserved', + 'witty' + ], + modifiable: true), + Emoji( + name: 'man detective: medium-light skin tone', + char: '\u{1F575}\u{1F3FC}\u{200D}\u{2642}\u{FE0F}', + shortName: 'man_detective_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'detective', + 'man', + 'medium-light skin tone', + 'sleuth', + 'spy', + 'uc8', + 'diversity', + 'halloween', + 'job', + 'search', + 'detective', + 'super hero', + 'private', + 'mystery', + 'clever', + 'disguise', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'samhain', + 'profession', + 'boss', + 'career', + 'look', + 'find', + 'looking', + 'see', + 'superhero', + 'superman', + 'batman', + 'прив', + 'privé', + 'privado', + 'reserved', + 'witty' + ], + modifiable: true), + Emoji( + name: 'man detective: medium skin tone', + char: '\u{1F575}\u{1F3FD}\u{200D}\u{2642}\u{FE0F}', + shortName: 'man_detective_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'detective', + 'man', + 'medium skin tone', + 'sleuth', + 'spy', + 'uc8', + 'diversity', + 'halloween', + 'job', + 'search', + 'detective', + 'super hero', + 'private', + 'mystery', + 'clever', + 'disguise', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'samhain', + 'profession', + 'boss', + 'career', + 'look', + 'find', + 'looking', + 'see', + 'superhero', + 'superman', + 'batman', + 'прив', + 'privé', + 'privado', + 'reserved', + 'witty' + ], + modifiable: true), + Emoji( + name: 'man detective: medium-dark skin tone', + char: '\u{1F575}\u{1F3FE}\u{200D}\u{2642}\u{FE0F}', + shortName: 'man_detective_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'detective', + 'man', + 'medium-dark skin tone', + 'sleuth', + 'spy', + 'uc8', + 'diversity', + 'halloween', + 'job', + 'search', + 'detective', + 'super hero', + 'private', + 'mystery', + 'clever', + 'disguise', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'samhain', + 'profession', + 'boss', + 'career', + 'look', + 'find', + 'looking', + 'see', + 'superhero', + 'superman', + 'batman', + 'прив', + 'privé', + 'privado', + 'reserved', + 'witty' + ], + modifiable: true), + Emoji( + name: 'man detective: dark skin tone', + char: '\u{1F575}\u{1F3FF}\u{200D}\u{2642}\u{FE0F}', + shortName: 'man_detective_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'dark skin tone', + 'detective', + 'man', + 'sleuth', + 'spy', + 'uc8', + 'diversity', + 'halloween', + 'job', + 'search', + 'detective', + 'super hero', + 'private', + 'mystery', + 'clever', + 'disguise', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'samhain', + 'profession', + 'boss', + 'career', + 'look', + 'find', + 'looking', + 'see', + 'superhero', + 'superman', + 'batman', + 'прив', + 'privé', + 'privado', + 'reserved', + 'witty' + ], + modifiable: true), + Emoji( + name: 'health worker', + char: '\u{1F9D1}\u{200D}\u{2695}\u{FE0F}', + shortName: 'health_worker', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'uc12', + 'health', + 'sick', + 'job', + '911', + 'nerd', + 'nurse', + 'help', + 'disguise', + 'medical', + 'medicine', + 'doctor', + 'barf', + 'vomit', + 'throw up', + 'puke', + 'get well', + 'cough', + 'puking', + 'barfing', + 'malade', + 'spew', + 'profession', + 'boss', + 'career', + 'emergency', + 'injury', + 'smart', + 'geek', + 'serious' + ]), + Emoji( + name: 'health worker: light skin tone', + char: '\u{1F9D1}\u{1F3FB}\u{200D}\u{2695}\u{FE0F}', + shortName: 'health_worker_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'uc12', + 'health', + 'sick', + 'job', + '911', + 'nerd', + 'nurse', + 'help', + 'disguise', + 'medical', + 'medicine', + 'doctor', + 'barf', + 'vomit', + 'throw up', + 'puke', + 'get well', + 'cough', + 'puking', + 'barfing', + 'malade', + 'spew', + 'profession', + 'boss', + 'career', + 'emergency', + 'injury', + 'smart', + 'geek', + 'serious' + ], + modifiable: true), + Emoji( + name: 'health worker: medium-light skin tone', + char: '\u{1F9D1}\u{1F3FC}\u{200D}\u{2695}\u{FE0F}', + shortName: 'health_worker_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'uc12', + 'health', + 'sick', + 'job', + '911', + 'nerd', + 'nurse', + 'help', + 'disguise', + 'medical', + 'medicine', + 'doctor', + 'barf', + 'vomit', + 'throw up', + 'puke', + 'get well', + 'cough', + 'puking', + 'barfing', + 'malade', + 'spew', + 'profession', + 'boss', + 'career', + 'emergency', + 'injury', + 'smart', + 'geek', + 'serious' + ], + modifiable: true), + Emoji( + name: 'health worker: medium skin tone', + char: '\u{1F9D1}\u{1F3FD}\u{200D}\u{2695}\u{FE0F}', + shortName: 'health_worker_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'uc12', + 'health', + 'sick', + 'job', + '911', + 'nerd', + 'nurse', + 'help', + 'disguise', + 'medical', + 'medicine', + 'doctor', + 'barf', + 'vomit', + 'throw up', + 'puke', + 'get well', + 'cough', + 'puking', + 'barfing', + 'malade', + 'spew', + 'profession', + 'boss', + 'career', + 'emergency', + 'injury', + 'smart', + 'geek', + 'serious' + ], + modifiable: true), + Emoji( + name: 'health worker: medium-dark skin tone', + char: '\u{1F9D1}\u{1F3FE}\u{200D}\u{2695}\u{FE0F}', + shortName: 'health_worker_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'uc12', + 'health', + 'sick', + 'job', + '911', + 'nerd', + 'nurse', + 'help', + 'disguise', + 'medical', + 'medicine', + 'doctor', + 'barf', + 'vomit', + 'throw up', + 'puke', + 'get well', + 'cough', + 'puking', + 'barfing', + 'malade', + 'spew', + 'profession', + 'boss', + 'career', + 'emergency', + 'injury', + 'smart', + 'geek', + 'serious' + ], + modifiable: true), + Emoji( + name: 'health worker: dark skin tone', + char: '\u{1F9D1}\u{1F3FF}\u{200D}\u{2695}\u{FE0F}', + shortName: 'health_worker_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'uc12', + 'health', + 'sick', + 'job', + '911', + 'nerd', + 'nurse', + 'help', + 'disguise', + 'medical', + 'medicine', + 'doctor', + 'barf', + 'vomit', + 'throw up', + 'puke', + 'get well', + 'cough', + 'puking', + 'barfing', + 'malade', + 'spew', + 'profession', + 'boss', + 'career', + 'emergency', + 'injury', + 'smart', + 'geek', + 'serious' + ], + modifiable: true), + Emoji( + name: 'woman health worker', + char: '\u{1F469}\u{200D}\u{2695}\u{FE0F}', + shortName: 'woman_health_worker', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'doctor', + 'healthcare', + 'nurse', + 'therapist', + 'woman', + 'uc6', + 'diversity', + 'health', + 'sick', + 'job', + '911', + 'nerd', + 'nurse', + 'help', + 'disguise', + 'medical', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'medicine', + 'doctor', + 'barf', + 'vomit', + 'throw up', + 'puke', + 'get well', + 'cough', + 'puking', + 'barfing', + 'malade', + 'spew', + 'profession', + 'boss', + 'career', + 'emergency', + 'injury', + 'smart', + 'geek', + 'serious' + ]), + Emoji( + name: 'woman health worker: light skin tone', + char: '\u{1F469}\u{1F3FB}\u{200D}\u{2695}\u{FE0F}', + shortName: 'woman_health_worker_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'doctor', + 'healthcare', + 'light skin tone', + 'nurse', + 'therapist', + 'woman', + 'uc8', + 'diversity', + 'health', + 'sick', + 'job', + '911', + 'nerd', + 'nurse', + 'help', + 'disguise', + 'medical', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'medicine', + 'doctor', + 'barf', + 'vomit', + 'throw up', + 'puke', + 'get well', + 'cough', + 'puking', + 'barfing', + 'malade', + 'spew', + 'profession', + 'boss', + 'career', + 'emergency', + 'injury', + 'smart', + 'geek', + 'serious' + ], + modifiable: true), + Emoji( + name: 'woman health worker: medium-light skin tone', + char: '\u{1F469}\u{1F3FC}\u{200D}\u{2695}\u{FE0F}', + shortName: 'woman_health_worker_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'doctor', + 'healthcare', + 'medium-light skin tone', + 'nurse', + 'therapist', + 'woman', + 'uc8', + 'diversity', + 'health', + 'sick', + 'job', + '911', + 'nerd', + 'nurse', + 'help', + 'disguise', + 'medical', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'medicine', + 'doctor', + 'barf', + 'vomit', + 'throw up', + 'puke', + 'get well', + 'cough', + 'puking', + 'barfing', + 'malade', + 'spew', + 'profession', + 'boss', + 'career', + 'emergency', + 'injury', + 'smart', + 'geek', + 'serious' + ], + modifiable: true), + Emoji( + name: 'woman health worker: medium skin tone', + char: '\u{1F469}\u{1F3FD}\u{200D}\u{2695}\u{FE0F}', + shortName: 'woman_health_worker_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'doctor', + 'healthcare', + 'medium skin tone', + 'nurse', + 'therapist', + 'woman', + 'uc8', + 'diversity', + 'health', + 'sick', + 'job', + '911', + 'nerd', + 'nurse', + 'help', + 'disguise', + 'medical', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'medicine', + 'doctor', + 'barf', + 'vomit', + 'throw up', + 'puke', + 'get well', + 'cough', + 'puking', + 'barfing', + 'malade', + 'spew', + 'profession', + 'boss', + 'career', + 'emergency', + 'injury', + 'smart', + 'geek', + 'serious' + ], + modifiable: true), + Emoji( + name: 'woman health worker: medium-dark skin tone', + char: '\u{1F469}\u{1F3FE}\u{200D}\u{2695}\u{FE0F}', + shortName: 'woman_health_worker_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'doctor', + 'healthcare', + 'medium-dark skin tone', + 'nurse', + 'therapist', + 'woman', + 'uc8', + 'diversity', + 'health', + 'sick', + 'job', + '911', + 'nerd', + 'nurse', + 'help', + 'disguise', + 'medical', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'medicine', + 'doctor', + 'barf', + 'vomit', + 'throw up', + 'puke', + 'get well', + 'cough', + 'puking', + 'barfing', + 'malade', + 'spew', + 'profession', + 'boss', + 'career', + 'emergency', + 'injury', + 'smart', + 'geek', + 'serious' + ], + modifiable: true), + Emoji( + name: 'woman health worker: dark skin tone', + char: '\u{1F469}\u{1F3FF}\u{200D}\u{2695}\u{FE0F}', + shortName: 'woman_health_worker_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'dark skin tone', + 'doctor', + 'healthcare', + 'nurse', + 'therapist', + 'woman', + 'uc8', + 'diversity', + 'health', + 'sick', + 'job', + '911', + 'nerd', + 'nurse', + 'help', + 'disguise', + 'medical', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'medicine', + 'doctor', + 'barf', + 'vomit', + 'throw up', + 'puke', + 'get well', + 'cough', + 'puking', + 'barfing', + 'malade', + 'spew', + 'profession', + 'boss', + 'career', + 'emergency', + 'injury', + 'smart', + 'geek', + 'serious' + ], + modifiable: true), + Emoji( + name: 'man health worker', + char: '\u{1F468}\u{200D}\u{2695}\u{FE0F}', + shortName: 'man_health_worker', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'doctor', + 'healthcare', + 'man', + 'nurse', + 'therapist', + 'uc6', + 'diversity', + 'health', + 'sick', + 'job', + '911', + 'nerd', + 'nurse', + 'help', + 'disguise', + 'medical', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'medicine', + 'doctor', + 'barf', + 'vomit', + 'throw up', + 'puke', + 'get well', + 'cough', + 'puking', + 'barfing', + 'malade', + 'spew', + 'profession', + 'boss', + 'career', + 'emergency', + 'injury', + 'smart', + 'geek', + 'serious' + ]), + Emoji( + name: 'man health worker: light skin tone', + char: '\u{1F468}\u{1F3FB}\u{200D}\u{2695}\u{FE0F}', + shortName: 'man_health_worker_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'doctor', + 'healthcare', + 'light skin tone', + 'man', + 'nurse', + 'therapist', + 'uc8', + 'diversity', + 'health', + 'sick', + 'job', + '911', + 'nerd', + 'nurse', + 'help', + 'disguise', + 'medical', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'medicine', + 'doctor', + 'barf', + 'vomit', + 'throw up', + 'puke', + 'get well', + 'cough', + 'puking', + 'barfing', + 'malade', + 'spew', + 'profession', + 'boss', + 'career', + 'emergency', + 'injury', + 'smart', + 'geek', + 'serious' + ], + modifiable: true), + Emoji( + name: 'man health worker: medium-light skin tone', + char: '\u{1F468}\u{1F3FC}\u{200D}\u{2695}\u{FE0F}', + shortName: 'man_health_worker_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'doctor', + 'healthcare', + 'man', + 'medium-light skin tone', + 'nurse', + 'therapist', + 'uc8', + 'diversity', + 'health', + 'sick', + 'job', + '911', + 'nerd', + 'nurse', + 'help', + 'disguise', + 'medical', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'medicine', + 'doctor', + 'barf', + 'vomit', + 'throw up', + 'puke', + 'get well', + 'cough', + 'puking', + 'barfing', + 'malade', + 'spew', + 'profession', + 'boss', + 'career', + 'emergency', + 'injury', + 'smart', + 'geek', + 'serious' + ], + modifiable: true), + Emoji( + name: 'man health worker: medium skin tone', + char: '\u{1F468}\u{1F3FD}\u{200D}\u{2695}\u{FE0F}', + shortName: 'man_health_worker_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'doctor', + 'healthcare', + 'man', + 'medium skin tone', + 'nurse', + 'therapist', + 'uc8', + 'diversity', + 'health', + 'sick', + 'job', + '911', + 'nerd', + 'nurse', + 'help', + 'disguise', + 'medical', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'medicine', + 'doctor', + 'barf', + 'vomit', + 'throw up', + 'puke', + 'get well', + 'cough', + 'puking', + 'barfing', + 'malade', + 'spew', + 'profession', + 'boss', + 'career', + 'emergency', + 'injury', + 'smart', + 'geek', + 'serious' + ], + modifiable: true), + Emoji( + name: 'man health worker: medium-dark skin tone', + char: '\u{1F468}\u{1F3FE}\u{200D}\u{2695}\u{FE0F}', + shortName: 'man_health_worker_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'doctor', + 'healthcare', + 'man', + 'medium-dark skin tone', + 'nurse', + 'therapist', + 'uc8', + 'diversity', + 'health', + 'sick', + 'job', + '911', + 'nerd', + 'nurse', + 'help', + 'disguise', + 'medical', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'medicine', + 'doctor', + 'barf', + 'vomit', + 'throw up', + 'puke', + 'get well', + 'cough', + 'puking', + 'barfing', + 'malade', + 'spew', + 'profession', + 'boss', + 'career', + 'emergency', + 'injury', + 'smart', + 'geek', + 'serious' + ], + modifiable: true), + Emoji( + name: 'man health worker: dark skin tone', + char: '\u{1F468}\u{1F3FF}\u{200D}\u{2695}\u{FE0F}', + shortName: 'man_health_worker_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'dark skin tone', + 'doctor', + 'healthcare', + 'man', + 'nurse', + 'therapist', + 'uc8', + 'diversity', + 'health', + 'sick', + 'job', + '911', + 'nerd', + 'nurse', + 'help', + 'disguise', + 'medical', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'medicine', + 'doctor', + 'barf', + 'vomit', + 'throw up', + 'puke', + 'get well', + 'cough', + 'puking', + 'barfing', + 'malade', + 'spew', + 'profession', + 'boss', + 'career', + 'emergency', + 'injury', + 'smart', + 'geek', + 'serious' + ], + modifiable: true), + Emoji( + name: 'farmer', + char: '\u{1F9D1}\u{200D}\u{1F33E}', + shortName: 'farmer', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'uc12', + 'job', + 'farm', + 'disguise', + 'profession', + 'boss', + 'career' + ]), + Emoji( + name: 'farmer: light skin tone', + char: '\u{1F9D1}\u{1F3FB}\u{200D}\u{1F33E}', + shortName: 'farmer_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'uc12', + 'job', + 'farm', + 'disguise', + 'profession', + 'boss', + 'career' + ], + modifiable: true), + Emoji( + name: 'farmer: medium-light skin tone', + char: '\u{1F9D1}\u{1F3FC}\u{200D}\u{1F33E}', + shortName: 'farmer_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'uc12', + 'job', + 'farm', + 'disguise', + 'profession', + 'boss', + 'career' + ], + modifiable: true), + Emoji( + name: 'farmer: medium skin tone', + char: '\u{1F9D1}\u{1F3FD}\u{200D}\u{1F33E}', + shortName: 'farmer_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'uc12', + 'job', + 'farm', + 'disguise', + 'profession', + 'boss', + 'career' + ], + modifiable: true), + Emoji( + name: 'farmer: medium-dark skin tone', + char: '\u{1F9D1}\u{1F3FE}\u{200D}\u{1F33E}', + shortName: 'farmer_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'uc12', + 'job', + 'farm', + 'disguise', + 'profession', + 'boss', + 'career' + ], + modifiable: true), + Emoji( + name: 'farmer: dark skin tone', + char: '\u{1F9D1}\u{1F3FF}\u{200D}\u{1F33E}', + shortName: 'farmer_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'uc12', + 'job', + 'farm', + 'disguise', + 'profession', + 'boss', + 'career' + ], + modifiable: true), + Emoji( + name: 'woman farmer', + char: '\u{1F469}\u{200D}\u{1F33E}', + shortName: 'woman_farmer', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'farmer', + 'gardener', + 'rancher', + 'woman', + 'uc6', + 'diversity', + 'job', + 'farm', + 'disguise', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'profession', + 'boss', + 'career' + ]), + Emoji( + name: 'woman farmer: light skin tone', + char: '\u{1F469}\u{1F3FB}\u{200D}\u{1F33E}', + shortName: 'woman_farmer_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'farmer', + 'gardener', + 'light skin tone', + 'rancher', + 'woman', + 'uc8', + 'diversity', + 'job', + 'farm', + 'disguise', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'profession', + 'boss', + 'career' + ], + modifiable: true), + Emoji( + name: 'woman farmer: medium-light skin tone', + char: '\u{1F469}\u{1F3FC}\u{200D}\u{1F33E}', + shortName: 'woman_farmer_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'farmer', + 'gardener', + 'medium-light skin tone', + 'rancher', + 'woman', + 'uc8', + 'diversity', + 'job', + 'farm', + 'disguise', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'profession', + 'boss', + 'career' + ], + modifiable: true), + Emoji( + name: 'woman farmer: medium skin tone', + char: '\u{1F469}\u{1F3FD}\u{200D}\u{1F33E}', + shortName: 'woman_farmer_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'farmer', + 'gardener', + 'medium skin tone', + 'rancher', + 'woman', + 'uc8', + 'diversity', + 'job', + 'farm', + 'disguise', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'profession', + 'boss', + 'career' + ], + modifiable: true), + Emoji( + name: 'woman farmer: medium-dark skin tone', + char: '\u{1F469}\u{1F3FE}\u{200D}\u{1F33E}', + shortName: 'woman_farmer_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'farmer', + 'gardener', + 'medium-dark skin tone', + 'rancher', + 'woman', + 'uc8', + 'diversity', + 'job', + 'farm', + 'disguise', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'profession', + 'boss', + 'career' + ], + modifiable: true), + Emoji( + name: 'woman farmer: dark skin tone', + char: '\u{1F469}\u{1F3FF}\u{200D}\u{1F33E}', + shortName: 'woman_farmer_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'dark skin tone', + 'farmer', + 'gardener', + 'rancher', + 'woman', + 'uc8', + 'diversity', + 'job', + 'farm', + 'disguise', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'profession', + 'boss', + 'career' + ], + modifiable: true), + Emoji( + name: 'man farmer', + char: '\u{1F468}\u{200D}\u{1F33E}', + shortName: 'man_farmer', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'farmer', + 'gardener', + 'man', + 'rancher', + 'uc6', + 'diversity', + 'job', + 'farm', + 'disguise', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'profession', + 'boss', + 'career' + ]), + Emoji( + name: 'man farmer: light skin tone', + char: '\u{1F468}\u{1F3FB}\u{200D}\u{1F33E}', + shortName: 'man_farmer_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'farmer', + 'gardener', + 'light skin tone', + 'man', + 'rancher', + 'uc8', + 'diversity', + 'job', + 'farm', + 'disguise', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'profession', + 'boss', + 'career' + ], + modifiable: true), + Emoji( + name: 'man farmer: medium-light skin tone', + char: '\u{1F468}\u{1F3FC}\u{200D}\u{1F33E}', + shortName: 'man_farmer_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'farmer', + 'gardener', + 'man', + 'medium-light skin tone', + 'rancher', + 'uc8', + 'diversity', + 'job', + 'farm', + 'disguise', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'profession', + 'boss', + 'career' + ], + modifiable: true), + Emoji( + name: 'man farmer: medium skin tone', + char: '\u{1F468}\u{1F3FD}\u{200D}\u{1F33E}', + shortName: 'man_farmer_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'farmer', + 'gardener', + 'man', + 'medium skin tone', + 'rancher', + 'uc8', + 'diversity', + 'job', + 'farm', + 'disguise', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'profession', + 'boss', + 'career' + ], + modifiable: true), + Emoji( + name: 'man farmer: medium-dark skin tone', + char: '\u{1F468}\u{1F3FE}\u{200D}\u{1F33E}', + shortName: 'man_farmer_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'farmer', + 'gardener', + 'man', + 'medium-dark skin tone', + 'rancher', + 'uc8', + 'diversity', + 'job', + 'farm', + 'disguise', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'profession', + 'boss', + 'career' + ], + modifiable: true), + Emoji( + name: 'man farmer: dark skin tone', + char: '\u{1F468}\u{1F3FF}\u{200D}\u{1F33E}', + shortName: 'man_farmer_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'dark skin tone', + 'farmer', + 'gardener', + 'man', + 'rancher', + 'uc8', + 'diversity', + 'job', + 'farm', + 'disguise', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'profession', + 'boss', + 'career' + ], + modifiable: true), + Emoji( + name: 'cook', + char: '\u{1F9D1}\u{200D}\u{1F373}', + shortName: 'cook', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'uc12', + 'job', + 'chef', + 'dinner', + 'disguise', + 'profession', + 'boss', + 'career', + 'cuisinière', + 'cuisinier', + 'lunch' + ]), + Emoji( + name: 'cook: light skin tone', + char: '\u{1F9D1}\u{1F3FB}\u{200D}\u{1F373}', + shortName: 'cook_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'uc12', + 'job', + 'chef', + 'dinner', + 'disguise', + 'profession', + 'boss', + 'career', + 'cuisinière', + 'cuisinier', + 'lunch' + ], + modifiable: true), + Emoji( + name: 'cook: medium-light skin tone', + char: '\u{1F9D1}\u{1F3FC}\u{200D}\u{1F373}', + shortName: 'cook_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'uc12', + 'job', + 'chef', + 'dinner', + 'disguise', + 'profession', + 'boss', + 'career', + 'cuisinière', + 'cuisinier', + 'lunch' + ], + modifiable: true), + Emoji( + name: 'cook: medium skin tone', + char: '\u{1F9D1}\u{1F3FD}\u{200D}\u{1F373}', + shortName: 'cook_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'uc12', + 'job', + 'chef', + 'dinner', + 'disguise', + 'profession', + 'boss', + 'career', + 'cuisinière', + 'cuisinier', + 'lunch' + ], + modifiable: true), + Emoji( + name: 'cook: medium-dark skin tone', + char: '\u{1F9D1}\u{1F3FE}\u{200D}\u{1F373}', + shortName: 'cook_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'uc12', + 'job', + 'chef', + 'dinner', + 'disguise', + 'profession', + 'boss', + 'career', + 'cuisinière', + 'cuisinier', + 'lunch' + ], + modifiable: true), + Emoji( + name: 'cook: dark skin tone', + char: '\u{1F9D1}\u{1F3FF}\u{200D}\u{1F373}', + shortName: 'cook_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'uc12', + 'job', + 'chef', + 'dinner', + 'disguise', + 'profession', + 'boss', + 'career', + 'cuisinière', + 'cuisinier', + 'lunch' + ], + modifiable: true), + Emoji( + name: 'woman cook', + char: '\u{1F469}\u{200D}\u{1F373}', + shortName: 'woman_cook', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'chef', + 'cook', + 'woman', + 'uc6', + 'diversity', + 'job', + 'bake', + 'chef', + 'dinner', + 'disguise', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'profession', + 'boss', + 'career', + 'baking', + 'cuisinière', + 'cuisinier', + 'lunch' + ]), + Emoji( + name: 'woman cook: light skin tone', + char: '\u{1F469}\u{1F3FB}\u{200D}\u{1F373}', + shortName: 'woman_cook_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'chef', + 'cook', + 'light skin tone', + 'woman', + 'uc8', + 'diversity', + 'job', + 'bake', + 'chef', + 'dinner', + 'disguise', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'profession', + 'boss', + 'career', + 'baking', + 'cuisinière', + 'cuisinier', + 'lunch' + ], + modifiable: true), + Emoji( + name: 'woman cook: medium-light skin tone', + char: '\u{1F469}\u{1F3FC}\u{200D}\u{1F373}', + shortName: 'woman_cook_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'chef', + 'cook', + 'medium-light skin tone', + 'woman', + 'uc8', + 'diversity', + 'job', + 'bake', + 'chef', + 'dinner', + 'disguise', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'profession', + 'boss', + 'career', + 'baking', + 'cuisinière', + 'cuisinier', + 'lunch' + ], + modifiable: true), + Emoji( + name: 'woman cook: medium skin tone', + char: '\u{1F469}\u{1F3FD}\u{200D}\u{1F373}', + shortName: 'woman_cook_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'chef', + 'cook', + 'medium skin tone', + 'woman', + 'uc8', + 'diversity', + 'job', + 'bake', + 'chef', + 'dinner', + 'disguise', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'profession', + 'boss', + 'career', + 'baking', + 'cuisinière', + 'cuisinier', + 'lunch' + ], + modifiable: true), + Emoji( + name: 'woman cook: medium-dark skin tone', + char: '\u{1F469}\u{1F3FE}\u{200D}\u{1F373}', + shortName: 'woman_cook_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'chef', + 'cook', + 'medium-dark skin tone', + 'woman', + 'uc8', + 'diversity', + 'job', + 'bake', + 'chef', + 'dinner', + 'disguise', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'profession', + 'boss', + 'career', + 'baking', + 'cuisinière', + 'cuisinier', + 'lunch' + ], + modifiable: true), + Emoji( + name: 'woman cook: dark skin tone', + char: '\u{1F469}\u{1F3FF}\u{200D}\u{1F373}', + shortName: 'woman_cook_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'chef', + 'cook', + 'dark skin tone', + 'woman', + 'uc8', + 'diversity', + 'job', + 'bake', + 'chef', + 'dinner', + 'disguise', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'profession', + 'boss', + 'career', + 'baking', + 'cuisinière', + 'cuisinier', + 'lunch' + ], + modifiable: true), + Emoji( + name: 'man cook', + char: '\u{1F468}\u{200D}\u{1F373}', + shortName: 'man_cook', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'chef', + 'cook', + 'man', + 'uc6', + 'diversity', + 'job', + 'bake', + 'chef', + 'dinner', + 'disguise', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'profession', + 'boss', + 'career', + 'baking', + 'cuisinière', + 'cuisinier', + 'lunch' + ]), + Emoji( + name: 'man cook: light skin tone', + char: '\u{1F468}\u{1F3FB}\u{200D}\u{1F373}', + shortName: 'man_cook_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'chef', + 'cook', + 'light skin tone', + 'man', + 'uc8', + 'diversity', + 'job', + 'bake', + 'chef', + 'dinner', + 'disguise', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'profession', + 'boss', + 'career', + 'baking', + 'cuisinière', + 'cuisinier', + 'lunch' + ], + modifiable: true), + Emoji( + name: 'man cook: medium-light skin tone', + char: '\u{1F468}\u{1F3FC}\u{200D}\u{1F373}', + shortName: 'man_cook_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'chef', + 'cook', + 'man', + 'medium-light skin tone', + 'uc8', + 'diversity', + 'job', + 'bake', + 'chef', + 'dinner', + 'disguise', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'profession', + 'boss', + 'career', + 'baking', + 'cuisinière', + 'cuisinier', + 'lunch' + ], + modifiable: true), + Emoji( + name: 'man cook: medium skin tone', + char: '\u{1F468}\u{1F3FD}\u{200D}\u{1F373}', + shortName: 'man_cook_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'chef', + 'cook', + 'man', + 'medium skin tone', + 'uc8', + 'diversity', + 'job', + 'bake', + 'chef', + 'dinner', + 'disguise', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'profession', + 'boss', + 'career', + 'baking', + 'cuisinière', + 'cuisinier', + 'lunch' + ], + modifiable: true), + Emoji( + name: 'man cook: medium-dark skin tone', + char: '\u{1F468}\u{1F3FE}\u{200D}\u{1F373}', + shortName: 'man_cook_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'chef', + 'cook', + 'man', + 'medium-dark skin tone', + 'uc8', + 'diversity', + 'job', + 'bake', + 'chef', + 'dinner', + 'disguise', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'profession', + 'boss', + 'career', + 'baking', + 'cuisinière', + 'cuisinier', + 'lunch' + ], + modifiable: true), + Emoji( + name: 'man cook: dark skin tone', + char: '\u{1F468}\u{1F3FF}\u{200D}\u{1F373}', + shortName: 'man_cook_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'chef', + 'cook', + 'dark skin tone', + 'man', + 'uc8', + 'diversity', + 'job', + 'bake', + 'chef', + 'dinner', + 'disguise', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'profession', + 'boss', + 'career', + 'baking', + 'cuisinière', + 'cuisinier', + 'lunch' + ], + modifiable: true), + Emoji( + name: 'student', + char: '\u{1F9D1}\u{200D}\u{1F393}', + shortName: 'student', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'uc12', + 'classroom', + 'nerd', + 'graduate', + 'school', + 'teach', + 'learn', + 'study', + 'college', + 'degree', + 'education', + 'homework', + 'student', + 'teacher', + 'university', + 'test', + 'learning', + 'smart', + 'geek', + 'serious' + ]), + Emoji( + name: 'student: light skin tone', + char: '\u{1F9D1}\u{1F3FB}\u{200D}\u{1F393}', + shortName: 'student_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'uc12', + 'classroom', + 'nerd', + 'graduate', + 'school', + 'teach', + 'learn', + 'study', + 'college', + 'degree', + 'education', + 'homework', + 'student', + 'teacher', + 'university', + 'test', + 'learning', + 'smart', + 'geek', + 'serious' + ], + modifiable: true), + Emoji( + name: 'student: medium-light skin tone', + char: '\u{1F9D1}\u{1F3FC}\u{200D}\u{1F393}', + shortName: 'student_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'uc12', + 'classroom', + 'nerd', + 'graduate', + 'school', + 'teach', + 'learn', + 'study', + 'college', + 'degree', + 'education', + 'homework', + 'student', + 'teacher', + 'university', + 'test', + 'learning', + 'smart', + 'geek', + 'serious' + ], + modifiable: true), + Emoji( + name: 'student: medium skin tone', + char: '\u{1F9D1}\u{1F3FD}\u{200D}\u{1F393}', + shortName: 'student_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'uc12', + 'classroom', + 'nerd', + 'graduate', + 'school', + 'teach', + 'learn', + 'study', + 'college', + 'degree', + 'education', + 'homework', + 'student', + 'teacher', + 'university', + 'test', + 'learning', + 'smart', + 'geek', + 'serious' + ], + modifiable: true), + Emoji( + name: 'student: medium-dark skin tone', + char: '\u{1F9D1}\u{1F3FE}\u{200D}\u{1F393}', + shortName: 'student_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'uc12', + 'classroom', + 'nerd', + 'graduate', + 'school', + 'teach', + 'learn', + 'study', + 'college', + 'degree', + 'education', + 'homework', + 'student', + 'teacher', + 'university', + 'test', + 'learning', + 'smart', + 'geek', + 'serious' + ], + modifiable: true), + Emoji( + name: 'student: dark skin tone', + char: '\u{1F9D1}\u{1F3FF}\u{200D}\u{1F393}', + shortName: 'student_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'uc12', + 'classroom', + 'nerd', + 'graduate', + 'school', + 'teach', + 'learn', + 'study', + 'college', + 'degree', + 'education', + 'homework', + 'student', + 'teacher', + 'university', + 'test', + 'learning', + 'smart', + 'geek', + 'serious' + ], + modifiable: true), + Emoji( + name: 'woman student', + char: '\u{1F469}\u{200D}\u{1F393}', + shortName: 'woman_student', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'graduate', + 'student', + 'woman', + 'uc6', + 'diversity', + 'classroom', + 'nerd', + 'graduate', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'school', + 'teach', + 'learn', + 'study', + 'college', + 'degree', + 'education', + 'homework', + 'student', + 'teacher', + 'university', + 'test', + 'learning', + 'smart', + 'geek', + 'serious' + ]), + Emoji( + name: 'woman student: light skin tone', + char: '\u{1F469}\u{1F3FB}\u{200D}\u{1F393}', + shortName: 'woman_student_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'graduate', + 'light skin tone', + 'student', + 'woman', + 'uc8', + 'diversity', + 'classroom', + 'nerd', + 'graduate', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'school', + 'teach', + 'learn', + 'study', + 'college', + 'degree', + 'education', + 'homework', + 'student', + 'teacher', + 'university', + 'test', + 'learning', + 'smart', + 'geek', + 'serious' + ], + modifiable: true), + Emoji( + name: 'woman student: medium-light skin tone', + char: '\u{1F469}\u{1F3FC}\u{200D}\u{1F393}', + shortName: 'woman_student_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'graduate', + 'medium-light skin tone', + 'student', + 'woman', + 'uc8', + 'diversity', + 'classroom', + 'nerd', + 'graduate', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'school', + 'teach', + 'learn', + 'study', + 'college', + 'degree', + 'education', + 'homework', + 'student', + 'teacher', + 'university', + 'test', + 'learning', + 'smart', + 'geek', + 'serious' + ], + modifiable: true), + Emoji( + name: 'woman student: medium skin tone', + char: '\u{1F469}\u{1F3FD}\u{200D}\u{1F393}', + shortName: 'woman_student_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'graduate', + 'medium skin tone', + 'student', + 'woman', + 'uc8', + 'diversity', + 'classroom', + 'nerd', + 'graduate', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'school', + 'teach', + 'learn', + 'study', + 'college', + 'degree', + 'education', + 'homework', + 'student', + 'teacher', + 'university', + 'test', + 'learning', + 'smart', + 'geek', + 'serious' + ], + modifiable: true), + Emoji( + name: 'woman student: medium-dark skin tone', + char: '\u{1F469}\u{1F3FE}\u{200D}\u{1F393}', + shortName: 'woman_student_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'graduate', + 'medium-dark skin tone', + 'student', + 'woman', + 'uc8', + 'diversity', + 'classroom', + 'nerd', + 'graduate', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'school', + 'teach', + 'learn', + 'study', + 'college', + 'degree', + 'education', + 'homework', + 'student', + 'teacher', + 'university', + 'test', + 'learning', + 'smart', + 'geek', + 'serious' + ], + modifiable: true), + Emoji( + name: 'woman student: dark skin tone', + char: '\u{1F469}\u{1F3FF}\u{200D}\u{1F393}', + shortName: 'woman_student_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'dark skin tone', + 'graduate', + 'student', + 'woman', + 'uc8', + 'diversity', + 'classroom', + 'nerd', + 'graduate', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'school', + 'teach', + 'learn', + 'study', + 'college', + 'degree', + 'education', + 'homework', + 'student', + 'teacher', + 'university', + 'test', + 'learning', + 'smart', + 'geek', + 'serious' + ], + modifiable: true), + Emoji( + name: 'man student', + char: '\u{1F468}\u{200D}\u{1F393}', + shortName: 'man_student', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'graduate', + 'man', + 'student', + 'uc6', + 'diversity', + 'classroom', + 'nerd', + 'graduate', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'school', + 'teach', + 'learn', + 'study', + 'college', + 'degree', + 'education', + 'homework', + 'student', + 'teacher', + 'university', + 'test', + 'learning', + 'smart', + 'geek', + 'serious' + ]), + Emoji( + name: 'man student: light skin tone', + char: '\u{1F468}\u{1F3FB}\u{200D}\u{1F393}', + shortName: 'man_student_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'graduate', + 'light skin tone', + 'man', + 'student', + 'uc8', + 'diversity', + 'classroom', + 'nerd', + 'graduate', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'school', + 'teach', + 'learn', + 'study', + 'college', + 'degree', + 'education', + 'homework', + 'student', + 'teacher', + 'university', + 'test', + 'learning', + 'smart', + 'geek', + 'serious' + ], + modifiable: true), + Emoji( + name: 'man student: medium-light skin tone', + char: '\u{1F468}\u{1F3FC}\u{200D}\u{1F393}', + shortName: 'man_student_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'graduate', + 'man', + 'medium-light skin tone', + 'student', + 'uc8', + 'diversity', + 'classroom', + 'nerd', + 'graduate', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'school', + 'teach', + 'learn', + 'study', + 'college', + 'degree', + 'education', + 'homework', + 'student', + 'teacher', + 'university', + 'test', + 'learning', + 'smart', + 'geek', + 'serious' + ], + modifiable: true), + Emoji( + name: 'man student: medium skin tone', + char: '\u{1F468}\u{1F3FD}\u{200D}\u{1F393}', + shortName: 'man_student_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'graduate', + 'man', + 'medium skin tone', + 'student', + 'uc8', + 'diversity', + 'classroom', + 'nerd', + 'graduate', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'school', + 'teach', + 'learn', + 'study', + 'college', + 'degree', + 'education', + 'homework', + 'student', + 'teacher', + 'university', + 'test', + 'learning', + 'smart', + 'geek', + 'serious' + ], + modifiable: true), + Emoji( + name: 'man student: medium-dark skin tone', + char: '\u{1F468}\u{1F3FE}\u{200D}\u{1F393}', + shortName: 'man_student_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'graduate', + 'man', + 'medium-dark skin tone', + 'student', + 'uc8', + 'diversity', + 'classroom', + 'nerd', + 'graduate', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'school', + 'teach', + 'learn', + 'study', + 'college', + 'degree', + 'education', + 'homework', + 'student', + 'teacher', + 'university', + 'test', + 'learning', + 'smart', + 'geek', + 'serious' + ], + modifiable: true), + Emoji( + name: 'man student: dark skin tone', + char: '\u{1F468}\u{1F3FF}\u{200D}\u{1F393}', + shortName: 'man_student_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'dark skin tone', + 'graduate', + 'man', + 'student', + 'uc8', + 'diversity', + 'classroom', + 'nerd', + 'graduate', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'school', + 'teach', + 'learn', + 'study', + 'college', + 'degree', + 'education', + 'homework', + 'student', + 'teacher', + 'university', + 'test', + 'learning', + 'smart', + 'geek', + 'serious' + ], + modifiable: true), + Emoji( + name: 'singer', + char: '\u{1F9D1}\u{200D}\u{1F3A4}', + shortName: 'singer', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'uc12', + 'job', + 'rock and roll', + 'fame', + 'artist', + 'disguise', + 'profession', + 'boss', + 'career', + 'famous', + 'celebrity' + ]), + Emoji( + name: 'singer: light skin tone', + char: '\u{1F9D1}\u{1F3FB}\u{200D}\u{1F3A4}', + shortName: 'singer_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'uc12', + 'job', + 'rock and roll', + 'fame', + 'artist', + 'disguise', + 'profession', + 'boss', + 'career', + 'famous', + 'celebrity' + ], + modifiable: true), + Emoji( + name: 'singer: medium-light skin tone', + char: '\u{1F9D1}\u{1F3FC}\u{200D}\u{1F3A4}', + shortName: 'singer_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'uc12', + 'job', + 'rock and roll', + 'fame', + 'artist', + 'disguise', + 'profession', + 'boss', + 'career', + 'famous', + 'celebrity' + ], + modifiable: true), + Emoji( + name: 'singer: medium skin tone', + char: '\u{1F9D1}\u{1F3FD}\u{200D}\u{1F3A4}', + shortName: 'singer_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'uc12', + 'job', + 'rock and roll', + 'fame', + 'artist', + 'disguise', + 'profession', + 'boss', + 'career', + 'famous', + 'celebrity' + ], + modifiable: true), + Emoji( + name: 'singer: medium-dark skin tone', + char: '\u{1F9D1}\u{1F3FE}\u{200D}\u{1F3A4}', + shortName: 'singer_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'uc12', + 'job', + 'rock and roll', + 'fame', + 'artist', + 'disguise', + 'profession', + 'boss', + 'career', + 'famous', + 'celebrity' + ], + modifiable: true), + Emoji( + name: 'singer: dark skin tone', + char: '\u{1F9D1}\u{1F3FF}\u{200D}\u{1F3A4}', + shortName: 'singer_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'uc12', + 'job', + 'rock and roll', + 'fame', + 'artist', + 'disguise', + 'profession', + 'boss', + 'career', + 'famous', + 'celebrity' + ], + modifiable: true), + Emoji( + name: 'woman singer', + char: '\u{1F469}\u{200D}\u{1F3A4}', + shortName: 'woman_singer', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'actor', + 'entertainer', + 'rock', + 'singer', + 'star', + 'woman', + 'uc6', + 'instruments', + 'diversity', + 'job', + 'rock and roll', + 'fame', + 'artist', + 'disguise', + 'music', + 'instrument', + 'singing', + 'concert', + 'jaz', + 'listen', + 'singer', + 'song', + 'musique', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'profession', + 'boss', + 'career', + 'famous', + 'celebrity' + ]), + Emoji( + name: 'woman singer: light skin tone', + char: '\u{1F469}\u{1F3FB}\u{200D}\u{1F3A4}', + shortName: 'woman_singer_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'actor', + 'entertainer', + 'light skin tone', + 'rock', + 'singer', + 'star', + 'woman', + 'uc8', + 'instruments', + 'diversity', + 'job', + 'rock and roll', + 'fame', + 'artist', + 'disguise', + 'music', + 'instrument', + 'singing', + 'concert', + 'jaz', + 'listen', + 'singer', + 'song', + 'musique', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'profession', + 'boss', + 'career', + 'famous', + 'celebrity' + ], + modifiable: true), + Emoji( + name: 'woman singer: medium-light skin tone', + char: '\u{1F469}\u{1F3FC}\u{200D}\u{1F3A4}', + shortName: 'woman_singer_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'actor', + 'entertainer', + 'medium-light skin tone', + 'rock', + 'singer', + 'star', + 'woman', + 'uc8', + 'instruments', + 'diversity', + 'job', + 'rock and roll', + 'fame', + 'artist', + 'disguise', + 'music', + 'instrument', + 'singing', + 'concert', + 'jaz', + 'listen', + 'singer', + 'song', + 'musique', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'profession', + 'boss', + 'career', + 'famous', + 'celebrity' + ], + modifiable: true), + Emoji( + name: 'woman singer: medium skin tone', + char: '\u{1F469}\u{1F3FD}\u{200D}\u{1F3A4}', + shortName: 'woman_singer_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'actor', + 'entertainer', + 'medium skin tone', + 'rock', + 'singer', + 'star', + 'woman', + 'uc8', + 'instruments', + 'diversity', + 'job', + 'rock and roll', + 'fame', + 'artist', + 'disguise', + 'music', + 'instrument', + 'singing', + 'concert', + 'jaz', + 'listen', + 'singer', + 'song', + 'musique', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'profession', + 'boss', + 'career', + 'famous', + 'celebrity' + ], + modifiable: true), + Emoji( + name: 'woman singer: medium-dark skin tone', + char: '\u{1F469}\u{1F3FE}\u{200D}\u{1F3A4}', + shortName: 'woman_singer_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'actor', + 'entertainer', + 'medium-dark skin tone', + 'rock', + 'singer', + 'star', + 'woman', + 'uc8', + 'instruments', + 'diversity', + 'job', + 'rock and roll', + 'fame', + 'artist', + 'disguise', + 'music', + 'instrument', + 'singing', + 'concert', + 'jaz', + 'listen', + 'singer', + 'song', + 'musique', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'profession', + 'boss', + 'career', + 'famous', + 'celebrity' + ], + modifiable: true), + Emoji( + name: 'woman singer: dark skin tone', + char: '\u{1F469}\u{1F3FF}\u{200D}\u{1F3A4}', + shortName: 'woman_singer_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'actor', + 'dark skin tone', + 'entertainer', + 'rock', + 'singer', + 'star', + 'woman', + 'uc8', + 'instruments', + 'diversity', + 'job', + 'rock and roll', + 'fame', + 'artist', + 'disguise', + 'music', + 'instrument', + 'singing', + 'concert', + 'jaz', + 'listen', + 'singer', + 'song', + 'musique', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'profession', + 'boss', + 'career', + 'famous', + 'celebrity' + ], + modifiable: true), + Emoji( + name: 'man singer', + char: '\u{1F468}\u{200D}\u{1F3A4}', + shortName: 'man_singer', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'actor', + 'entertainer', + 'man', + 'rock', + 'singer', + 'star', + 'uc6', + 'instruments', + 'diversity', + 'job', + 'rock and roll', + 'fame', + 'artist', + 'disguise', + 'music', + 'instrument', + 'singing', + 'concert', + 'jaz', + 'listen', + 'singer', + 'song', + 'musique', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'profession', + 'boss', + 'career', + 'famous', + 'celebrity' + ]), + Emoji( + name: 'man singer: light skin tone', + char: '\u{1F468}\u{1F3FB}\u{200D}\u{1F3A4}', + shortName: 'man_singer_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'actor', + 'entertainer', + 'light skin tone', + 'man', + 'rock', + 'singer', + 'star', + 'uc8', + 'instruments', + 'diversity', + 'job', + 'rock and roll', + 'fame', + 'artist', + 'disguise', + 'music', + 'instrument', + 'singing', + 'concert', + 'jaz', + 'listen', + 'singer', + 'song', + 'musique', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'profession', + 'boss', + 'career', + 'famous', + 'celebrity' + ], + modifiable: true), + Emoji( + name: 'man singer: medium-light skin tone', + char: '\u{1F468}\u{1F3FC}\u{200D}\u{1F3A4}', + shortName: 'man_singer_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'actor', + 'entertainer', + 'man', + 'medium-light skin tone', + 'rock', + 'singer', + 'star', + 'uc8', + 'instruments', + 'diversity', + 'job', + 'rock and roll', + 'fame', + 'artist', + 'disguise', + 'music', + 'instrument', + 'singing', + 'concert', + 'jaz', + 'listen', + 'singer', + 'song', + 'musique', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'profession', + 'boss', + 'career', + 'famous', + 'celebrity' + ], + modifiable: true), + Emoji( + name: 'man singer: medium skin tone', + char: '\u{1F468}\u{1F3FD}\u{200D}\u{1F3A4}', + shortName: 'man_singer_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'actor', + 'entertainer', + 'man', + 'medium skin tone', + 'rock', + 'singer', + 'star', + 'uc8', + 'instruments', + 'diversity', + 'job', + 'rock and roll', + 'fame', + 'artist', + 'disguise', + 'music', + 'instrument', + 'singing', + 'concert', + 'jaz', + 'listen', + 'singer', + 'song', + 'musique', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'profession', + 'boss', + 'career', + 'famous', + 'celebrity' + ], + modifiable: true), + Emoji( + name: 'man singer: medium-dark skin tone', + char: '\u{1F468}\u{1F3FE}\u{200D}\u{1F3A4}', + shortName: 'man_singer_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'actor', + 'entertainer', + 'man', + 'medium-dark skin tone', + 'rock', + 'singer', + 'star', + 'uc8', + 'instruments', + 'diversity', + 'job', + 'rock and roll', + 'fame', + 'artist', + 'disguise', + 'music', + 'instrument', + 'singing', + 'concert', + 'jaz', + 'listen', + 'singer', + 'song', + 'musique', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'profession', + 'boss', + 'career', + 'famous', + 'celebrity' + ], + modifiable: true), + Emoji( + name: 'man singer: dark skin tone', + char: '\u{1F468}\u{1F3FF}\u{200D}\u{1F3A4}', + shortName: 'man_singer_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'actor', + 'dark skin tone', + 'entertainer', + 'man', + 'rock', + 'singer', + 'star', + 'uc8', + 'instruments', + 'diversity', + 'job', + 'rock and roll', + 'fame', + 'artist', + 'disguise', + 'music', + 'instrument', + 'singing', + 'concert', + 'jaz', + 'listen', + 'singer', + 'song', + 'musique', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'profession', + 'boss', + 'career', + 'famous', + 'celebrity' + ], + modifiable: true), + Emoji( + name: 'teacher', + char: '\u{1F9D1}\u{200D}\u{1F3EB}', + shortName: 'teacher', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'uc12', + 'classroom', + 'job', + 'nerd', + 'school', + 'teach', + 'learn', + 'study', + 'college', + 'degree', + 'education', + 'homework', + 'student', + 'teacher', + 'university', + 'test', + 'learning', + 'profession', + 'boss', + 'career', + 'smart', + 'geek', + 'serious' + ]), + Emoji( + name: 'teacher: light skin tone', + char: '\u{1F9D1}\u{1F3FB}\u{200D}\u{1F3EB}', + shortName: 'teacher_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'uc12', + 'classroom', + 'job', + 'nerd', + 'school', + 'teach', + 'learn', + 'study', + 'college', + 'degree', + 'education', + 'homework', + 'student', + 'teacher', + 'university', + 'test', + 'learning', + 'profession', + 'boss', + 'career', + 'smart', + 'geek', + 'serious' + ], + modifiable: true), + Emoji( + name: 'teacher: medium-light skin tone', + char: '\u{1F9D1}\u{1F3FC}\u{200D}\u{1F3EB}', + shortName: 'teacher_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'uc12', + 'classroom', + 'job', + 'nerd', + 'school', + 'teach', + 'learn', + 'study', + 'college', + 'degree', + 'education', + 'homework', + 'student', + 'teacher', + 'university', + 'test', + 'learning', + 'profession', + 'boss', + 'career', + 'smart', + 'geek', + 'serious' + ], + modifiable: true), + Emoji( + name: 'teacher: medium skin tone', + char: '\u{1F9D1}\u{1F3FD}\u{200D}\u{1F3EB}', + shortName: 'teacher_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'uc12', + 'classroom', + 'job', + 'nerd', + 'school', + 'teach', + 'learn', + 'study', + 'college', + 'degree', + 'education', + 'homework', + 'student', + 'teacher', + 'university', + 'test', + 'learning', + 'profession', + 'boss', + 'career', + 'smart', + 'geek', + 'serious' + ], + modifiable: true), + Emoji( + name: 'teacher: medium-dark skin tone', + char: '\u{1F9D1}\u{1F3FE}\u{200D}\u{1F3EB}', + shortName: 'teacher_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'uc12', + 'classroom', + 'job', + 'nerd', + 'school', + 'teach', + 'learn', + 'study', + 'college', + 'degree', + 'education', + 'homework', + 'student', + 'teacher', + 'university', + 'test', + 'learning', + 'profession', + 'boss', + 'career', + 'smart', + 'geek', + 'serious' + ], + modifiable: true), + Emoji( + name: 'teacher: dark skin tone', + char: '\u{1F9D1}\u{1F3FF}\u{200D}\u{1F3EB}', + shortName: 'teacher_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'uc12', + 'classroom', + 'job', + 'nerd', + 'school', + 'teach', + 'learn', + 'study', + 'college', + 'degree', + 'education', + 'homework', + 'student', + 'teacher', + 'university', + 'test', + 'learning', + 'profession', + 'boss', + 'career', + 'smart', + 'geek', + 'serious' + ], + modifiable: true), + Emoji( + name: 'woman teacher', + char: '\u{1F469}\u{200D}\u{1F3EB}', + shortName: 'woman_teacher', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'instructor', + 'professor', + 'teacher', + 'woman', + 'uc6', + 'diversity', + 'classroom', + 'job', + 'nerd', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'school', + 'teach', + 'learn', + 'study', + 'college', + 'degree', + 'education', + 'homework', + 'student', + 'teacher', + 'university', + 'test', + 'learning', + 'profession', + 'boss', + 'career', + 'smart', + 'geek', + 'serious' + ]), + Emoji( + name: 'woman teacher: light skin tone', + char: '\u{1F469}\u{1F3FB}\u{200D}\u{1F3EB}', + shortName: 'woman_teacher_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'instructor', + 'light skin tone', + 'professor', + 'teacher', + 'woman', + 'uc8', + 'diversity', + 'classroom', + 'job', + 'nerd', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'school', + 'teach', + 'learn', + 'study', + 'college', + 'degree', + 'education', + 'homework', + 'student', + 'teacher', + 'university', + 'test', + 'learning', + 'profession', + 'boss', + 'career', + 'smart', + 'geek', + 'serious' + ], + modifiable: true), + Emoji( + name: 'woman teacher: medium-light skin tone', + char: '\u{1F469}\u{1F3FC}\u{200D}\u{1F3EB}', + shortName: 'woman_teacher_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'instructor', + 'medium-light skin tone', + 'professor', + 'teacher', + 'woman', + 'uc8', + 'diversity', + 'classroom', + 'job', + 'nerd', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'school', + 'teach', + 'learn', + 'study', + 'college', + 'degree', + 'education', + 'homework', + 'student', + 'teacher', + 'university', + 'test', + 'learning', + 'profession', + 'boss', + 'career', + 'smart', + 'geek', + 'serious' + ], + modifiable: true), + Emoji( + name: 'woman teacher: medium skin tone', + char: '\u{1F469}\u{1F3FD}\u{200D}\u{1F3EB}', + shortName: 'woman_teacher_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'instructor', + 'medium skin tone', + 'professor', + 'teacher', + 'woman', + 'uc8', + 'diversity', + 'classroom', + 'job', + 'nerd', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'school', + 'teach', + 'learn', + 'study', + 'college', + 'degree', + 'education', + 'homework', + 'student', + 'teacher', + 'university', + 'test', + 'learning', + 'profession', + 'boss', + 'career', + 'smart', + 'geek', + 'serious' + ], + modifiable: true), + Emoji( + name: 'woman teacher: medium-dark skin tone', + char: '\u{1F469}\u{1F3FE}\u{200D}\u{1F3EB}', + shortName: 'woman_teacher_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'instructor', + 'medium-dark skin tone', + 'professor', + 'teacher', + 'woman', + 'uc8', + 'diversity', + 'classroom', + 'job', + 'nerd', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'school', + 'teach', + 'learn', + 'study', + 'college', + 'degree', + 'education', + 'homework', + 'student', + 'teacher', + 'university', + 'test', + 'learning', + 'profession', + 'boss', + 'career', + 'smart', + 'geek', + 'serious' + ], + modifiable: true), + Emoji( + name: 'woman teacher: dark skin tone', + char: '\u{1F469}\u{1F3FF}\u{200D}\u{1F3EB}', + shortName: 'woman_teacher_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'dark skin tone', + 'instructor', + 'professor', + 'teacher', + 'woman', + 'uc8', + 'diversity', + 'classroom', + 'job', + 'nerd', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'school', + 'teach', + 'learn', + 'study', + 'college', + 'degree', + 'education', + 'homework', + 'student', + 'teacher', + 'university', + 'test', + 'learning', + 'profession', + 'boss', + 'career', + 'smart', + 'geek', + 'serious' + ], + modifiable: true), + Emoji( + name: 'man teacher', + char: '\u{1F468}\u{200D}\u{1F3EB}', + shortName: 'man_teacher', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'instructor', + 'man', + 'professor', + 'teacher', + 'uc6', + 'diversity', + 'classroom', + 'job', + 'nerd', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'school', + 'teach', + 'learn', + 'study', + 'college', + 'degree', + 'education', + 'homework', + 'student', + 'teacher', + 'university', + 'test', + 'learning', + 'profession', + 'boss', + 'career', + 'smart', + 'geek', + 'serious' + ]), + Emoji( + name: 'man teacher: light skin tone', + char: '\u{1F468}\u{1F3FB}\u{200D}\u{1F3EB}', + shortName: 'man_teacher_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'instructor', + 'light skin tone', + 'man', + 'professor', + 'teacher', + 'uc8', + 'diversity', + 'classroom', + 'job', + 'nerd', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'school', + 'teach', + 'learn', + 'study', + 'college', + 'degree', + 'education', + 'homework', + 'student', + 'teacher', + 'university', + 'test', + 'learning', + 'profession', + 'boss', + 'career', + 'smart', + 'geek', + 'serious' + ], + modifiable: true), + Emoji( + name: 'man teacher: medium-light skin tone', + char: '\u{1F468}\u{1F3FC}\u{200D}\u{1F3EB}', + shortName: 'man_teacher_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'instructor', + 'man', + 'medium-light skin tone', + 'professor', + 'teacher', + 'uc8', + 'diversity', + 'classroom', + 'job', + 'nerd', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'school', + 'teach', + 'learn', + 'study', + 'college', + 'degree', + 'education', + 'homework', + 'student', + 'teacher', + 'university', + 'test', + 'learning', + 'profession', + 'boss', + 'career', + 'smart', + 'geek', + 'serious' + ], + modifiable: true), + Emoji( + name: 'man teacher: medium skin tone', + char: '\u{1F468}\u{1F3FD}\u{200D}\u{1F3EB}', + shortName: 'man_teacher_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'instructor', + 'man', + 'medium skin tone', + 'professor', + 'teacher', + 'uc8', + 'diversity', + 'classroom', + 'job', + 'nerd', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'school', + 'teach', + 'learn', + 'study', + 'college', + 'degree', + 'education', + 'homework', + 'student', + 'teacher', + 'university', + 'test', + 'learning', + 'profession', + 'boss', + 'career', + 'smart', + 'geek', + 'serious' + ], + modifiable: true), + Emoji( + name: 'man teacher: medium-dark skin tone', + char: '\u{1F468}\u{1F3FE}\u{200D}\u{1F3EB}', + shortName: 'man_teacher_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'instructor', + 'man', + 'medium-dark skin tone', + 'professor', + 'teacher', + 'uc8', + 'diversity', + 'classroom', + 'job', + 'nerd', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'school', + 'teach', + 'learn', + 'study', + 'college', + 'degree', + 'education', + 'homework', + 'student', + 'teacher', + 'university', + 'test', + 'learning', + 'profession', + 'boss', + 'career', + 'smart', + 'geek', + 'serious' + ], + modifiable: true), + Emoji( + name: 'man teacher: dark skin tone', + char: '\u{1F468}\u{1F3FF}\u{200D}\u{1F3EB}', + shortName: 'man_teacher_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'dark skin tone', + 'instructor', + 'man', + 'professor', + 'teacher', + 'uc8', + 'diversity', + 'classroom', + 'job', + 'nerd', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'school', + 'teach', + 'learn', + 'study', + 'college', + 'degree', + 'education', + 'homework', + 'student', + 'teacher', + 'university', + 'test', + 'learning', + 'profession', + 'boss', + 'career', + 'smart', + 'geek', + 'serious' + ], + modifiable: true), + Emoji( + name: 'factory worker', + char: '\u{1F9D1}\u{200D}\u{1F3ED}', + shortName: 'factory_worker', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'uc12', + 'job', + 'mask', + 'build', + 'profession', + 'boss', + 'career' + ]), + Emoji( + name: 'factory worker: light skin tone', + char: '\u{1F9D1}\u{1F3FB}\u{200D}\u{1F3ED}', + shortName: 'factory_worker_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'uc12', + 'job', + 'mask', + 'build', + 'profession', + 'boss', + 'career' + ], + modifiable: true), + Emoji( + name: 'factory worker: medium-light skin tone', + char: '\u{1F9D1}\u{1F3FC}\u{200D}\u{1F3ED}', + shortName: 'factory_worker_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'uc12', + 'job', + 'mask', + 'build', + 'profession', + 'boss', + 'career' + ], + modifiable: true), + Emoji( + name: 'factory worker: medium skin tone', + char: '\u{1F9D1}\u{1F3FD}\u{200D}\u{1F3ED}', + shortName: 'factory_worker_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'uc12', + 'job', + 'mask', + 'build', + 'profession', + 'boss', + 'career' + ], + modifiable: true), + Emoji( + name: 'factory worker: medium-dark skin tone', + char: '\u{1F9D1}\u{1F3FE}\u{200D}\u{1F3ED}', + shortName: 'factory_worker_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'uc12', + 'job', + 'mask', + 'build', + 'profession', + 'boss', + 'career' + ], + modifiable: true), + Emoji( + name: 'factory worker: dark skin tone', + char: '\u{1F9D1}\u{1F3FF}\u{200D}\u{1F3ED}', + shortName: 'factory_worker_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'uc12', + 'job', + 'mask', + 'build', + 'profession', + 'boss', + 'career' + ], + modifiable: true), + Emoji( + name: 'woman factory worker', + char: '\u{1F469}\u{200D}\u{1F3ED}', + shortName: 'woman_factory_worker', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'assembly', + 'factory', + 'industrial', + 'woman', + 'worker', + 'uc6', + 'diversity', + 'job', + 'mask', + 'build', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'profession', + 'boss', + 'career' + ]), + Emoji( + name: 'woman factory worker: light skin tone', + char: '\u{1F469}\u{1F3FB}\u{200D}\u{1F3ED}', + shortName: 'woman_factory_worker_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'assembly', + 'factory', + 'industrial', + 'light skin tone', + 'woman', + 'worker', + 'uc8', + 'diversity', + 'job', + 'mask', + 'build', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'profession', + 'boss', + 'career' + ], + modifiable: true), + Emoji( + name: 'woman factory worker: medium-light skin tone', + char: '\u{1F469}\u{1F3FC}\u{200D}\u{1F3ED}', + shortName: 'woman_factory_worker_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'assembly', + 'factory', + 'industrial', + 'medium-light skin tone', + 'woman', + 'worker', + 'uc8', + 'diversity', + 'job', + 'mask', + 'build', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'profession', + 'boss', + 'career' + ], + modifiable: true), + Emoji( + name: 'woman factory worker: medium skin tone', + char: '\u{1F469}\u{1F3FD}\u{200D}\u{1F3ED}', + shortName: 'woman_factory_worker_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'assembly', + 'factory', + 'industrial', + 'medium skin tone', + 'woman', + 'worker', + 'uc8', + 'diversity', + 'job', + 'mask', + 'build', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'profession', + 'boss', + 'career' + ], + modifiable: true), + Emoji( + name: 'woman factory worker: medium-dark skin tone', + char: '\u{1F469}\u{1F3FE}\u{200D}\u{1F3ED}', + shortName: 'woman_factory_worker_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'assembly', + 'factory', + 'industrial', + 'medium-dark skin tone', + 'woman', + 'worker', + 'uc8', + 'diversity', + 'job', + 'mask', + 'build', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'profession', + 'boss', + 'career' + ], + modifiable: true), + Emoji( + name: 'woman factory worker: dark skin tone', + char: '\u{1F469}\u{1F3FF}\u{200D}\u{1F3ED}', + shortName: 'woman_factory_worker_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'assembly', + 'dark skin tone', + 'factory', + 'industrial', + 'woman', + 'worker', + 'uc8', + 'diversity', + 'job', + 'mask', + 'build', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'profession', + 'boss', + 'career' + ], + modifiable: true), + Emoji( + name: 'man factory worker', + char: '\u{1F468}\u{200D}\u{1F3ED}', + shortName: 'man_factory_worker', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'assembly', + 'factory', + 'industrial', + 'man', + 'worker', + 'uc6', + 'diversity', + 'job', + 'mask', + 'build', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'profession', + 'boss', + 'career' + ]), + Emoji( + name: 'man factory worker: light skin tone', + char: '\u{1F468}\u{1F3FB}\u{200D}\u{1F3ED}', + shortName: 'man_factory_worker_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'assembly', + 'factory', + 'industrial', + 'light skin tone', + 'man', + 'worker', + 'uc8', + 'diversity', + 'job', + 'mask', + 'build', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'profession', + 'boss', + 'career' + ], + modifiable: true), + Emoji( + name: 'man factory worker: medium-light skin tone', + char: '\u{1F468}\u{1F3FC}\u{200D}\u{1F3ED}', + shortName: 'man_factory_worker_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'assembly', + 'factory', + 'industrial', + 'man', + 'medium-light skin tone', + 'worker', + 'uc8', + 'diversity', + 'job', + 'mask', + 'build', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'profession', + 'boss', + 'career' + ], + modifiable: true), + Emoji( + name: 'man factory worker: medium skin tone', + char: '\u{1F468}\u{1F3FD}\u{200D}\u{1F3ED}', + shortName: 'man_factory_worker_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'assembly', + 'factory', + 'industrial', + 'man', + 'medium skin tone', + 'worker', + 'uc8', + 'diversity', + 'job', + 'mask', + 'build', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'profession', + 'boss', + 'career' + ], + modifiable: true), + Emoji( + name: 'man factory worker: medium-dark skin tone', + char: '\u{1F468}\u{1F3FE}\u{200D}\u{1F3ED}', + shortName: 'man_factory_worker_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'assembly', + 'factory', + 'industrial', + 'man', + 'medium-dark skin tone', + 'worker', + 'uc8', + 'diversity', + 'job', + 'mask', + 'build', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'profession', + 'boss', + 'career' + ], + modifiable: true), + Emoji( + name: 'man factory worker: dark skin tone', + char: '\u{1F468}\u{1F3FF}\u{200D}\u{1F3ED}', + shortName: 'man_factory_worker_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'assembly', + 'dark skin tone', + 'factory', + 'industrial', + 'man', + 'worker', + 'uc8', + 'diversity', + 'job', + 'mask', + 'build', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'profession', + 'boss', + 'career' + ], + modifiable: true), + Emoji( + name: 'technologist', + char: '\u{1F9D1}\u{200D}\u{1F4BB}', + shortName: 'technologist', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'uc12', + 'classroom', + 'job', + 'business', + 'nerd', + 'work', + 'school', + 'teach', + 'learn', + 'study', + 'college', + 'degree', + 'education', + 'homework', + 'student', + 'teacher', + 'university', + 'test', + 'learning', + 'profession', + 'boss', + 'career', + 'smart', + 'geek', + 'serious', + 'office' + ]), + Emoji( + name: 'technologist: light skin tone', + char: '\u{1F9D1}\u{1F3FB}\u{200D}\u{1F4BB}', + shortName: 'technologist_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'uc12', + 'classroom', + 'job', + 'business', + 'nerd', + 'work', + 'school', + 'teach', + 'learn', + 'study', + 'college', + 'degree', + 'education', + 'homework', + 'student', + 'teacher', + 'university', + 'test', + 'learning', + 'profession', + 'boss', + 'career', + 'smart', + 'geek', + 'serious', + 'office' + ], + modifiable: true), + Emoji( + name: 'technologist: medium-light skin tone', + char: '\u{1F9D1}\u{1F3FC}\u{200D}\u{1F4BB}', + shortName: 'technologist_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'uc12', + 'classroom', + 'job', + 'business', + 'nerd', + 'work', + 'school', + 'teach', + 'learn', + 'study', + 'college', + 'degree', + 'education', + 'homework', + 'student', + 'teacher', + 'university', + 'test', + 'learning', + 'profession', + 'boss', + 'career', + 'smart', + 'geek', + 'serious', + 'office' + ], + modifiable: true), + Emoji( + name: 'technologist: medium skin tone', + char: '\u{1F9D1}\u{1F3FD}\u{200D}\u{1F4BB}', + shortName: 'technologist_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'uc12', + 'classroom', + 'job', + 'business', + 'nerd', + 'work', + 'school', + 'teach', + 'learn', + 'study', + 'college', + 'degree', + 'education', + 'homework', + 'student', + 'teacher', + 'university', + 'test', + 'learning', + 'profession', + 'boss', + 'career', + 'smart', + 'geek', + 'serious', + 'office' + ], + modifiable: true), + Emoji( + name: 'technologist: medium-dark skin tone', + char: '\u{1F9D1}\u{1F3FE}\u{200D}\u{1F4BB}', + shortName: 'technologist_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'uc12', + 'classroom', + 'job', + 'business', + 'nerd', + 'work', + 'school', + 'teach', + 'learn', + 'study', + 'college', + 'degree', + 'education', + 'homework', + 'student', + 'teacher', + 'university', + 'test', + 'learning', + 'profession', + 'boss', + 'career', + 'smart', + 'geek', + 'serious', + 'office' + ], + modifiable: true), + Emoji( + name: 'technologist: dark skin tone', + char: '\u{1F9D1}\u{1F3FF}\u{200D}\u{1F4BB}', + shortName: 'technologist_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'uc12', + 'classroom', + 'job', + 'business', + 'nerd', + 'work', + 'school', + 'teach', + 'learn', + 'study', + 'college', + 'degree', + 'education', + 'homework', + 'student', + 'teacher', + 'university', + 'test', + 'learning', + 'profession', + 'boss', + 'career', + 'smart', + 'geek', + 'serious', + 'office' + ], + modifiable: true), + Emoji( + name: 'woman technologist', + char: '\u{1F469}\u{200D}\u{1F4BB}', + shortName: 'woman_technologist', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'coder', + 'developer', + 'inventor', + 'software', + 'technologist', + 'woman', + 'uc6', + 'diversity', + 'classroom', + 'job', + 'business', + 'nerd', + 'code', + 'work', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'school', + 'teach', + 'learn', + 'study', + 'college', + 'degree', + 'education', + 'homework', + 'student', + 'teacher', + 'university', + 'test', + 'learning', + 'profession', + 'boss', + 'career', + 'smart', + 'geek', + 'serious', + 'coding', + 'office' + ]), + Emoji( + name: 'woman technologist: light skin tone', + char: '\u{1F469}\u{1F3FB}\u{200D}\u{1F4BB}', + shortName: 'woman_technologist_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'coder', + 'developer', + 'inventor', + 'light skin tone', + 'software', + 'technologist', + 'woman', + 'uc8', + 'diversity', + 'classroom', + 'job', + 'business', + 'nerd', + 'code', + 'work', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'school', + 'teach', + 'learn', + 'study', + 'college', + 'degree', + 'education', + 'homework', + 'student', + 'teacher', + 'university', + 'test', + 'learning', + 'profession', + 'boss', + 'career', + 'smart', + 'geek', + 'serious', + 'coding', + 'office' + ], + modifiable: true), + Emoji( + name: 'woman technologist: medium-light skin tone', + char: '\u{1F469}\u{1F3FC}\u{200D}\u{1F4BB}', + shortName: 'woman_technologist_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'coder', + 'developer', + 'inventor', + 'medium-light skin tone', + 'software', + 'technologist', + 'woman', + 'uc8', + 'diversity', + 'classroom', + 'job', + 'business', + 'nerd', + 'code', + 'work', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'school', + 'teach', + 'learn', + 'study', + 'college', + 'degree', + 'education', + 'homework', + 'student', + 'teacher', + 'university', + 'test', + 'learning', + 'profession', + 'boss', + 'career', + 'smart', + 'geek', + 'serious', + 'coding', + 'office' + ], + modifiable: true), + Emoji( + name: 'woman technologist: medium skin tone', + char: '\u{1F469}\u{1F3FD}\u{200D}\u{1F4BB}', + shortName: 'woman_technologist_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'coder', + 'developer', + 'inventor', + 'medium skin tone', + 'software', + 'technologist', + 'woman', + 'uc8', + 'diversity', + 'classroom', + 'job', + 'business', + 'nerd', + 'code', + 'work', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'school', + 'teach', + 'learn', + 'study', + 'college', + 'degree', + 'education', + 'homework', + 'student', + 'teacher', + 'university', + 'test', + 'learning', + 'profession', + 'boss', + 'career', + 'smart', + 'geek', + 'serious', + 'coding', + 'office' + ], + modifiable: true), + Emoji( + name: 'woman technologist: medium-dark skin tone', + char: '\u{1F469}\u{1F3FE}\u{200D}\u{1F4BB}', + shortName: 'woman_technologist_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'coder', + 'developer', + 'inventor', + 'medium-dark skin tone', + 'software', + 'technologist', + 'woman', + 'uc8', + 'diversity', + 'classroom', + 'job', + 'business', + 'nerd', + 'code', + 'work', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'school', + 'teach', + 'learn', + 'study', + 'college', + 'degree', + 'education', + 'homework', + 'student', + 'teacher', + 'university', + 'test', + 'learning', + 'profession', + 'boss', + 'career', + 'smart', + 'geek', + 'serious', + 'coding', + 'office' + ], + modifiable: true), + Emoji( + name: 'woman technologist: dark skin tone', + char: '\u{1F469}\u{1F3FF}\u{200D}\u{1F4BB}', + shortName: 'woman_technologist_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'coder', + 'dark skin tone', + 'developer', + 'inventor', + 'software', + 'technologist', + 'woman', + 'uc8', + 'diversity', + 'classroom', + 'job', + 'business', + 'nerd', + 'code', + 'work', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'school', + 'teach', + 'learn', + 'study', + 'college', + 'degree', + 'education', + 'homework', + 'student', + 'teacher', + 'university', + 'test', + 'learning', + 'profession', + 'boss', + 'career', + 'smart', + 'geek', + 'serious', + 'coding', + 'office' + ], + modifiable: true), + Emoji( + name: 'man technologist', + char: '\u{1F468}\u{200D}\u{1F4BB}', + shortName: 'man_technologist', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'coder', + 'developer', + 'inventor', + 'man', + 'software', + 'technologist', + 'uc6', + 'diversity', + 'classroom', + 'job', + 'business', + 'nerd', + 'code', + 'work', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'school', + 'teach', + 'learn', + 'study', + 'college', + 'degree', + 'education', + 'homework', + 'student', + 'teacher', + 'university', + 'test', + 'learning', + 'profession', + 'boss', + 'career', + 'smart', + 'geek', + 'serious', + 'coding', + 'office' + ]), + Emoji( + name: 'man technologist: light skin tone', + char: '\u{1F468}\u{1F3FB}\u{200D}\u{1F4BB}', + shortName: 'man_technologist_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'coder', + 'developer', + 'inventor', + 'light skin tone', + 'man', + 'software', + 'technologist', + 'uc8', + 'diversity', + 'classroom', + 'job', + 'business', + 'nerd', + 'code', + 'work', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'school', + 'teach', + 'learn', + 'study', + 'college', + 'degree', + 'education', + 'homework', + 'student', + 'teacher', + 'university', + 'test', + 'learning', + 'profession', + 'boss', + 'career', + 'smart', + 'geek', + 'serious', + 'coding', + 'office' + ], + modifiable: true), + Emoji( + name: 'man technologist: medium-light skin tone', + char: '\u{1F468}\u{1F3FC}\u{200D}\u{1F4BB}', + shortName: 'man_technologist_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'coder', + 'developer', + 'inventor', + 'man', + 'medium-light skin tone', + 'software', + 'technologist', + 'uc8', + 'diversity', + 'classroom', + 'job', + 'business', + 'nerd', + 'code', + 'work', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'school', + 'teach', + 'learn', + 'study', + 'college', + 'degree', + 'education', + 'homework', + 'student', + 'teacher', + 'university', + 'test', + 'learning', + 'profession', + 'boss', + 'career', + 'smart', + 'geek', + 'serious', + 'coding', + 'office' + ], + modifiable: true), + Emoji( + name: 'man technologist: medium skin tone', + char: '\u{1F468}\u{1F3FD}\u{200D}\u{1F4BB}', + shortName: 'man_technologist_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'coder', + 'developer', + 'inventor', + 'man', + 'medium skin tone', + 'software', + 'technologist', + 'uc8', + 'diversity', + 'classroom', + 'job', + 'business', + 'nerd', + 'code', + 'work', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'school', + 'teach', + 'learn', + 'study', + 'college', + 'degree', + 'education', + 'homework', + 'student', + 'teacher', + 'university', + 'test', + 'learning', + 'profession', + 'boss', + 'career', + 'smart', + 'geek', + 'serious', + 'coding', + 'office' + ], + modifiable: true), + Emoji( + name: 'man technologist: medium-dark skin tone', + char: '\u{1F468}\u{1F3FE}\u{200D}\u{1F4BB}', + shortName: 'man_technologist_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'coder', + 'developer', + 'inventor', + 'man', + 'medium-dark skin tone', + 'software', + 'technologist', + 'uc8', + 'diversity', + 'classroom', + 'job', + 'business', + 'nerd', + 'code', + 'work', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'school', + 'teach', + 'learn', + 'study', + 'college', + 'degree', + 'education', + 'homework', + 'student', + 'teacher', + 'university', + 'test', + 'learning', + 'profession', + 'boss', + 'career', + 'smart', + 'geek', + 'serious', + 'coding', + 'office' + ], + modifiable: true), + Emoji( + name: 'man technologist: dark skin tone', + char: '\u{1F468}\u{1F3FF}\u{200D}\u{1F4BB}', + shortName: 'man_technologist_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'coder', + 'dark skin tone', + 'developer', + 'inventor', + 'man', + 'software', + 'technologist', + 'uc8', + 'diversity', + 'classroom', + 'job', + 'business', + 'nerd', + 'code', + 'work', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'school', + 'teach', + 'learn', + 'study', + 'college', + 'degree', + 'education', + 'homework', + 'student', + 'teacher', + 'university', + 'test', + 'learning', + 'profession', + 'boss', + 'career', + 'smart', + 'geek', + 'serious', + 'coding', + 'office' + ], + modifiable: true), + Emoji( + name: 'office worker', + char: '\u{1F9D1}\u{200D}\u{1F4BC}', + shortName: 'office_worker', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'uc12', + 'men', + 'job', + 'business', + 'nerd', + 'work', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'profession', + 'boss', + 'career', + 'smart', + 'geek', + 'serious', + 'office' + ]), + Emoji( + name: 'office worker: light skin tone', + char: '\u{1F9D1}\u{1F3FB}\u{200D}\u{1F4BC}', + shortName: 'office_worker_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'uc12', + 'men', + 'job', + 'business', + 'nerd', + 'work', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'profession', + 'boss', + 'career', + 'smart', + 'geek', + 'serious', + 'office' + ], + modifiable: true), + Emoji( + name: 'office worker: medium-light skin tone', + char: '\u{1F9D1}\u{1F3FC}\u{200D}\u{1F4BC}', + shortName: 'office_worker_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'uc12', + 'men', + 'job', + 'business', + 'nerd', + 'work', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'profession', + 'boss', + 'career', + 'smart', + 'geek', + 'serious', + 'office' + ], + modifiable: true), + Emoji( + name: 'office worker: medium skin tone', + char: '\u{1F9D1}\u{1F3FD}\u{200D}\u{1F4BC}', + shortName: 'office_worker_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'uc12', + 'men', + 'job', + 'business', + 'nerd', + 'work', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'profession', + 'boss', + 'career', + 'smart', + 'geek', + 'serious', + 'office' + ], + modifiable: true), + Emoji( + name: 'office worker: medium-dark skin tone', + char: '\u{1F9D1}\u{1F3FE}\u{200D}\u{1F4BC}', + shortName: 'office_worker_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'uc12', + 'men', + 'job', + 'business', + 'nerd', + 'work', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'profession', + 'boss', + 'career', + 'smart', + 'geek', + 'serious', + 'office' + ], + modifiable: true), + Emoji( + name: 'office worker: dark skin tone', + char: '\u{1F9D1}\u{1F3FF}\u{200D}\u{1F4BC}', + shortName: 'office_worker_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'uc12', + 'men', + 'job', + 'business', + 'nerd', + 'work', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'profession', + 'boss', + 'career', + 'smart', + 'geek', + 'serious', + 'office' + ], + modifiable: true), + Emoji( + name: 'woman office worker', + char: '\u{1F469}\u{200D}\u{1F4BC}', + shortName: 'woman_office_worker', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'architect', + 'business', + 'manager', + 'office', + 'white-collar', + 'woman', + 'uc6', + 'diversity', + 'women', + 'job', + 'business', + 'nerd', + 'costume', + 'work', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'woman', + 'female', + 'profession', + 'boss', + 'career', + 'smart', + 'geek', + 'serious', + 'office' + ]), + Emoji( + name: 'woman office worker: light skin tone', + char: '\u{1F469}\u{1F3FB}\u{200D}\u{1F4BC}', + shortName: 'woman_office_worker_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'architect', + 'business', + 'light skin tone', + 'manager', + 'office', + 'white-collar', + 'woman', + 'uc8', + 'diversity', + 'women', + 'job', + 'business', + 'nerd', + 'costume', + 'work', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'woman', + 'female', + 'profession', + 'boss', + 'career', + 'smart', + 'geek', + 'serious', + 'office' + ], + modifiable: true), + Emoji( + name: 'woman office worker: medium-light skin tone', + char: '\u{1F469}\u{1F3FC}\u{200D}\u{1F4BC}', + shortName: 'woman_office_worker_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'architect', + 'business', + 'manager', + 'medium-light skin tone', + 'office', + 'white-collar', + 'woman', + 'uc8', + 'diversity', + 'women', + 'job', + 'business', + 'nerd', + 'costume', + 'work', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'woman', + 'female', + 'profession', + 'boss', + 'career', + 'smart', + 'geek', + 'serious', + 'office' + ], + modifiable: true), + Emoji( + name: 'woman office worker: medium skin tone', + char: '\u{1F469}\u{1F3FD}\u{200D}\u{1F4BC}', + shortName: 'woman_office_worker_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'architect', + 'business', + 'manager', + 'medium skin tone', + 'office', + 'white-collar', + 'woman', + 'uc8', + 'diversity', + 'women', + 'job', + 'business', + 'nerd', + 'costume', + 'work', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'woman', + 'female', + 'profession', + 'boss', + 'career', + 'smart', + 'geek', + 'serious', + 'office' + ], + modifiable: true), + Emoji( + name: 'woman office worker: medium-dark skin tone', + char: '\u{1F469}\u{1F3FE}\u{200D}\u{1F4BC}', + shortName: 'woman_office_worker_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'architect', + 'business', + 'manager', + 'medium-dark skin tone', + 'office', + 'white-collar', + 'woman', + 'uc8', + 'diversity', + 'women', + 'job', + 'business', + 'nerd', + 'costume', + 'work', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'woman', + 'female', + 'profession', + 'boss', + 'career', + 'smart', + 'geek', + 'serious', + 'office' + ], + modifiable: true), + Emoji( + name: 'woman office worker: dark skin tone', + char: '\u{1F469}\u{1F3FF}\u{200D}\u{1F4BC}', + shortName: 'woman_office_worker_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'architect', + 'business', + 'dark skin tone', + 'manager', + 'office', + 'white-collar', + 'woman', + 'uc8', + 'diversity', + 'women', + 'job', + 'business', + 'nerd', + 'costume', + 'work', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'woman', + 'female', + 'profession', + 'boss', + 'career', + 'smart', + 'geek', + 'serious', + 'office' + ], + modifiable: true), + Emoji( + name: 'man office worker', + char: '\u{1F468}\u{200D}\u{1F4BC}', + shortName: 'man_office_worker', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'architect', + 'business', + 'man', + 'manager', + 'office', + 'white-collar', + 'uc6', + 'diversity', + 'men', + 'job', + 'business', + 'nerd', + 'work', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'profession', + 'boss', + 'career', + 'smart', + 'geek', + 'serious', + 'office' + ]), + Emoji( + name: 'man office worker: light skin tone', + char: '\u{1F468}\u{1F3FB}\u{200D}\u{1F4BC}', + shortName: 'man_office_worker_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'architect', + 'business', + 'light skin tone', + 'man', + 'manager', + 'office', + 'white-collar', + 'uc8', + 'diversity', + 'men', + 'job', + 'business', + 'nerd', + 'work', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'profession', + 'boss', + 'career', + 'smart', + 'geek', + 'serious', + 'office' + ], + modifiable: true), + Emoji( + name: 'man office worker: medium-light skin tone', + char: '\u{1F468}\u{1F3FC}\u{200D}\u{1F4BC}', + shortName: 'man_office_worker_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'architect', + 'business', + 'man', + 'manager', + 'medium-light skin tone', + 'office', + 'white-collar', + 'uc8', + 'diversity', + 'men', + 'job', + 'business', + 'nerd', + 'work', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'profession', + 'boss', + 'career', + 'smart', + 'geek', + 'serious', + 'office' + ], + modifiable: true), + Emoji( + name: 'man office worker: medium skin tone', + char: '\u{1F468}\u{1F3FD}\u{200D}\u{1F4BC}', + shortName: 'man_office_worker_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'architect', + 'business', + 'man', + 'manager', + 'medium skin tone', + 'office', + 'white-collar', + 'uc8', + 'diversity', + 'men', + 'job', + 'business', + 'nerd', + 'work', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'profession', + 'boss', + 'career', + 'smart', + 'geek', + 'serious', + 'office' + ], + modifiable: true), + Emoji( + name: 'man office worker: medium-dark skin tone', + char: '\u{1F468}\u{1F3FE}\u{200D}\u{1F4BC}', + shortName: 'man_office_worker_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'architect', + 'business', + 'man', + 'manager', + 'medium-dark skin tone', + 'office', + 'white-collar', + 'uc8', + 'diversity', + 'men', + 'job', + 'business', + 'nerd', + 'work', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'profession', + 'boss', + 'career', + 'smart', + 'geek', + 'serious', + 'office' + ], + modifiable: true), + Emoji( + name: 'man office worker: dark skin tone', + char: '\u{1F468}\u{1F3FF}\u{200D}\u{1F4BC}', + shortName: 'man_office_worker_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'architect', + 'business', + 'dark skin tone', + 'man', + 'manager', + 'office', + 'white-collar', + 'uc8', + 'diversity', + 'men', + 'job', + 'business', + 'nerd', + 'work', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'profession', + 'boss', + 'career', + 'smart', + 'geek', + 'serious', + 'office' + ], + modifiable: true), + Emoji( + name: 'mechanic', + char: '\u{1F9D1}\u{200D}\u{1F527}', + shortName: 'mechanic', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: ['uc12', 'job', 'profession', 'boss', 'career']), + Emoji( + name: 'mechanic: light skin tone', + char: '\u{1F9D1}\u{1F3FB}\u{200D}\u{1F527}', + shortName: 'mechanic_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: ['uc12', 'job', 'profession', 'boss', 'career'], + modifiable: true), + Emoji( + name: 'mechanic: medium-light skin tone', + char: '\u{1F9D1}\u{1F3FC}\u{200D}\u{1F527}', + shortName: 'mechanic_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: ['uc12', 'job', 'profession', 'boss', 'career'], + modifiable: true), + Emoji( + name: 'mechanic: medium skin tone', + char: '\u{1F9D1}\u{1F3FD}\u{200D}\u{1F527}', + shortName: 'mechanic_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: ['uc12', 'job', 'profession', 'boss', 'career'], + modifiable: true), + Emoji( + name: 'mechanic: medium-dark skin tone', + char: '\u{1F9D1}\u{1F3FE}\u{200D}\u{1F527}', + shortName: 'mechanic_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: ['uc12', 'job', 'profession', 'boss', 'career'], + modifiable: true), + Emoji( + name: 'mechanic: dark skin tone', + char: '\u{1F9D1}\u{1F3FF}\u{200D}\u{1F527}', + shortName: 'mechanic_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: ['uc12', 'job', 'profession', 'boss', 'career'], + modifiable: true), + Emoji( + name: 'woman mechanic', + char: '\u{1F469}\u{200D}\u{1F527}', + shortName: 'woman_mechanic', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'electrician', + 'mechanic', + 'plumber', + 'tradesperson', + 'woman', + 'uc6', + 'diversity', + 'job', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'profession', + 'boss', + 'career' + ]), + Emoji( + name: 'woman mechanic: light skin tone', + char: '\u{1F469}\u{1F3FB}\u{200D}\u{1F527}', + shortName: 'woman_mechanic_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'electrician', + 'light skin tone', + 'mechanic', + 'plumber', + 'tradesperson', + 'woman', + 'uc8', + 'diversity', + 'job', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'profession', + 'boss', + 'career' + ], + modifiable: true), + Emoji( + name: 'woman mechanic: medium-light skin tone', + char: '\u{1F469}\u{1F3FC}\u{200D}\u{1F527}', + shortName: 'woman_mechanic_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'electrician', + 'mechanic', + 'medium-light skin tone', + 'plumber', + 'tradesperson', + 'woman', + 'uc8', + 'diversity', + 'job', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'profession', + 'boss', + 'career' + ], + modifiable: true), + Emoji( + name: 'woman mechanic: medium skin tone', + char: '\u{1F469}\u{1F3FD}\u{200D}\u{1F527}', + shortName: 'woman_mechanic_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'electrician', + 'mechanic', + 'medium skin tone', + 'plumber', + 'tradesperson', + 'woman', + 'uc8', + 'diversity', + 'job', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'profession', + 'boss', + 'career' + ], + modifiable: true), + Emoji( + name: 'woman mechanic: medium-dark skin tone', + char: '\u{1F469}\u{1F3FE}\u{200D}\u{1F527}', + shortName: 'woman_mechanic_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'electrician', + 'mechanic', + 'medium-dark skin tone', + 'plumber', + 'tradesperson', + 'woman', + 'uc8', + 'diversity', + 'job', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'profession', + 'boss', + 'career' + ], + modifiable: true), + Emoji( + name: 'woman mechanic: dark skin tone', + char: '\u{1F469}\u{1F3FF}\u{200D}\u{1F527}', + shortName: 'woman_mechanic_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'dark skin tone', + 'electrician', + 'mechanic', + 'plumber', + 'tradesperson', + 'woman', + 'uc8', + 'diversity', + 'job', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'profession', + 'boss', + 'career' + ], + modifiable: true), + Emoji( + name: 'man mechanic', + char: '\u{1F468}\u{200D}\u{1F527}', + shortName: 'man_mechanic', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'electrician', + 'man', + 'mechanic', + 'plumber', + 'tradesperson', + 'uc6', + 'diversity', + 'job', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'profession', + 'boss', + 'career' + ]), + Emoji( + name: 'man mechanic: light skin tone', + char: '\u{1F468}\u{1F3FB}\u{200D}\u{1F527}', + shortName: 'man_mechanic_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'electrician', + 'light skin tone', + 'man', + 'mechanic', + 'plumber', + 'tradesperson', + 'uc8', + 'diversity', + 'job', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'profession', + 'boss', + 'career' + ], + modifiable: true), + Emoji( + name: 'man mechanic: medium-light skin tone', + char: '\u{1F468}\u{1F3FC}\u{200D}\u{1F527}', + shortName: 'man_mechanic_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'electrician', + 'man', + 'mechanic', + 'medium-light skin tone', + 'plumber', + 'tradesperson', + 'uc8', + 'diversity', + 'job', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'profession', + 'boss', + 'career' + ], + modifiable: true), + Emoji( + name: 'man mechanic: medium skin tone', + char: '\u{1F468}\u{1F3FD}\u{200D}\u{1F527}', + shortName: 'man_mechanic_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'electrician', + 'man', + 'mechanic', + 'medium skin tone', + 'plumber', + 'tradesperson', + 'uc8', + 'diversity', + 'job', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'profession', + 'boss', + 'career' + ], + modifiable: true), + Emoji( + name: 'man mechanic: medium-dark skin tone', + char: '\u{1F468}\u{1F3FE}\u{200D}\u{1F527}', + shortName: 'man_mechanic_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'electrician', + 'man', + 'mechanic', + 'medium-dark skin tone', + 'plumber', + 'tradesperson', + 'uc8', + 'diversity', + 'job', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'profession', + 'boss', + 'career' + ], + modifiable: true), + Emoji( + name: 'man mechanic: dark skin tone', + char: '\u{1F468}\u{1F3FF}\u{200D}\u{1F527}', + shortName: 'man_mechanic_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'dark skin tone', + 'electrician', + 'man', + 'mechanic', + 'plumber', + 'tradesperson', + 'uc8', + 'diversity', + 'job', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'profession', + 'boss', + 'career' + ], + modifiable: true), + Emoji( + name: 'scientist', + char: '\u{1F9D1}\u{200D}\u{1F52C}', + shortName: 'scientist', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'uc12', + 'science', + 'job', + 'nerd', + 'lab', + 'profession', + 'boss', + 'career', + 'smart', + 'geek', + 'serious' + ]), + Emoji( + name: 'scientist: light skin tone', + char: '\u{1F9D1}\u{1F3FB}\u{200D}\u{1F52C}', + shortName: 'scientist_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'uc12', + 'science', + 'job', + 'nerd', + 'lab', + 'profession', + 'boss', + 'career', + 'smart', + 'geek', + 'serious' + ], + modifiable: true), + Emoji( + name: 'scientist: medium-light skin tone', + char: '\u{1F9D1}\u{1F3FC}\u{200D}\u{1F52C}', + shortName: 'scientist_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'uc12', + 'science', + 'job', + 'nerd', + 'lab', + 'profession', + 'boss', + 'career', + 'smart', + 'geek', + 'serious' + ], + modifiable: true), + Emoji( + name: 'scientist: medium skin tone', + char: '\u{1F9D1}\u{1F3FD}\u{200D}\u{1F52C}', + shortName: 'scientist_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'uc12', + 'science', + 'job', + 'nerd', + 'lab', + 'profession', + 'boss', + 'career', + 'smart', + 'geek', + 'serious' + ], + modifiable: true), + Emoji( + name: 'scientist: medium-dark skin tone', + char: '\u{1F9D1}\u{1F3FE}\u{200D}\u{1F52C}', + shortName: 'scientist_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'uc12', + 'science', + 'job', + 'nerd', + 'lab', + 'profession', + 'boss', + 'career', + 'smart', + 'geek', + 'serious' + ], + modifiable: true), + Emoji( + name: 'scientist: dark skin tone', + char: '\u{1F9D1}\u{1F3FF}\u{200D}\u{1F52C}', + shortName: 'scientist_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'uc12', + 'science', + 'job', + 'nerd', + 'lab', + 'profession', + 'boss', + 'career', + 'smart', + 'geek', + 'serious' + ], + modifiable: true), + Emoji( + name: 'woman scientist', + char: '\u{1F469}\u{200D}\u{1F52C}', + shortName: 'woman_scientist', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'biologist', + 'chemist', + 'engineer', + 'mathematician', + 'physicist', + 'scientist', + 'woman', + 'uc6', + 'diversity', + 'science', + 'job', + 'nerd', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'lab', + 'profession', + 'boss', + 'career', + 'smart', + 'geek', + 'serious' + ]), + Emoji( + name: 'woman scientist: light skin tone', + char: '\u{1F469}\u{1F3FB}\u{200D}\u{1F52C}', + shortName: 'woman_scientist_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'biologist', + 'chemist', + 'engineer', + 'light skin tone', + 'mathematician', + 'physicist', + 'scientist', + 'woman', + 'uc8', + 'diversity', + 'science', + 'job', + 'nerd', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'lab', + 'profession', + 'boss', + 'career', + 'smart', + 'geek', + 'serious' + ], + modifiable: true), + Emoji( + name: 'woman scientist: medium-light skin tone', + char: '\u{1F469}\u{1F3FC}\u{200D}\u{1F52C}', + shortName: 'woman_scientist_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'biologist', + 'chemist', + 'engineer', + 'mathematician', + 'medium-light skin tone', + 'physicist', + 'scientist', + 'woman', + 'uc8', + 'diversity', + 'science', + 'job', + 'nerd', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'lab', + 'profession', + 'boss', + 'career', + 'smart', + 'geek', + 'serious' + ], + modifiable: true), + Emoji( + name: 'woman scientist: medium skin tone', + char: '\u{1F469}\u{1F3FD}\u{200D}\u{1F52C}', + shortName: 'woman_scientist_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'biologist', + 'chemist', + 'engineer', + 'mathematician', + 'medium skin tone', + 'physicist', + 'scientist', + 'woman', + 'uc8', + 'diversity', + 'science', + 'job', + 'nerd', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'lab', + 'profession', + 'boss', + 'career', + 'smart', + 'geek', + 'serious' + ], + modifiable: true), + Emoji( + name: 'woman scientist: medium-dark skin tone', + char: '\u{1F469}\u{1F3FE}\u{200D}\u{1F52C}', + shortName: 'woman_scientist_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'biologist', + 'chemist', + 'engineer', + 'mathematician', + 'medium-dark skin tone', + 'physicist', + 'scientist', + 'woman', + 'uc8', + 'diversity', + 'science', + 'job', + 'nerd', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'lab', + 'profession', + 'boss', + 'career', + 'smart', + 'geek', + 'serious' + ], + modifiable: true), + Emoji( + name: 'woman scientist: dark skin tone', + char: '\u{1F469}\u{1F3FF}\u{200D}\u{1F52C}', + shortName: 'woman_scientist_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'biologist', + 'chemist', + 'dark skin tone', + 'engineer', + 'mathematician', + 'physicist', + 'scientist', + 'woman', + 'uc8', + 'diversity', + 'science', + 'job', + 'nerd', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'lab', + 'profession', + 'boss', + 'career', + 'smart', + 'geek', + 'serious' + ], + modifiable: true), + Emoji( + name: 'man scientist', + char: '\u{1F468}\u{200D}\u{1F52C}', + shortName: 'man_scientist', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'biologist', + 'chemist', + 'engineer', + 'man', + 'mathematician', + 'physicist', + 'scientist', + 'uc6', + 'diversity', + 'science', + 'job', + 'nerd', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'lab', + 'profession', + 'boss', + 'career', + 'smart', + 'geek', + 'serious' + ]), + Emoji( + name: 'man scientist: light skin tone', + char: '\u{1F468}\u{1F3FB}\u{200D}\u{1F52C}', + shortName: 'man_scientist_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'biologist', + 'chemist', + 'engineer', + 'light skin tone', + 'man', + 'mathematician', + 'physicist', + 'scientist', + 'uc8', + 'diversity', + 'science', + 'job', + 'nerd', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'lab', + 'profession', + 'boss', + 'career', + 'smart', + 'geek', + 'serious' + ], + modifiable: true), + Emoji( + name: 'man scientist: medium-light skin tone', + char: '\u{1F468}\u{1F3FC}\u{200D}\u{1F52C}', + shortName: 'man_scientist_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'biologist', + 'chemist', + 'engineer', + 'man', + 'mathematician', + 'medium-light skin tone', + 'physicist', + 'scientist', + 'uc8', + 'diversity', + 'science', + 'job', + 'nerd', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'lab', + 'profession', + 'boss', + 'career', + 'smart', + 'geek', + 'serious' + ], + modifiable: true), + Emoji( + name: 'man scientist: medium skin tone', + char: '\u{1F468}\u{1F3FD}\u{200D}\u{1F52C}', + shortName: 'man_scientist_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'biologist', + 'chemist', + 'engineer', + 'man', + 'mathematician', + 'medium skin tone', + 'physicist', + 'scientist', + 'uc8', + 'diversity', + 'science', + 'job', + 'nerd', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'lab', + 'profession', + 'boss', + 'career', + 'smart', + 'geek', + 'serious' + ], + modifiable: true), + Emoji( + name: 'man scientist: medium-dark skin tone', + char: '\u{1F468}\u{1F3FE}\u{200D}\u{1F52C}', + shortName: 'man_scientist_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'biologist', + 'chemist', + 'engineer', + 'man', + 'mathematician', + 'medium-dark skin tone', + 'physicist', + 'scientist', + 'uc8', + 'diversity', + 'science', + 'job', + 'nerd', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'lab', + 'profession', + 'boss', + 'career', + 'smart', + 'geek', + 'serious' + ], + modifiable: true), + Emoji( + name: 'man scientist: dark skin tone', + char: '\u{1F468}\u{1F3FF}\u{200D}\u{1F52C}', + shortName: 'man_scientist_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'biologist', + 'chemist', + 'dark skin tone', + 'engineer', + 'man', + 'mathematician', + 'physicist', + 'scientist', + 'uc8', + 'diversity', + 'science', + 'job', + 'nerd', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'lab', + 'profession', + 'boss', + 'career', + 'smart', + 'geek', + 'serious' + ], + modifiable: true), + Emoji( + name: 'artist', + char: '\u{1F9D1}\u{200D}\u{1F3A8}', + shortName: 'artist', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'uc12', + 'job', + 'painting', + 'artist', + 'profession', + 'boss', + 'career', + 'painter', + 'arts' + ]), + Emoji( + name: 'artist: light skin tone', + char: '\u{1F9D1}\u{1F3FB}\u{200D}\u{1F3A8}', + shortName: 'artist_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'uc12', + 'job', + 'painting', + 'artist', + 'profession', + 'boss', + 'career', + 'painter', + 'arts' + ], + modifiable: true), + Emoji( + name: 'artist: medium-light skin tone', + char: '\u{1F9D1}\u{1F3FC}\u{200D}\u{1F3A8}', + shortName: 'artist_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'uc12', + 'job', + 'painting', + 'artist', + 'profession', + 'boss', + 'career', + 'painter', + 'arts' + ], + modifiable: true), + Emoji( + name: 'artist: medium skin tone', + char: '\u{1F9D1}\u{1F3FD}\u{200D}\u{1F3A8}', + shortName: 'artist_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'uc12', + 'job', + 'painting', + 'artist', + 'profession', + 'boss', + 'career', + 'painter', + 'arts' + ], + modifiable: true), + Emoji( + name: 'artist: medium-dark skin tone', + char: '\u{1F9D1}\u{1F3FE}\u{200D}\u{1F3A8}', + shortName: 'artist_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'uc12', + 'job', + 'painting', + 'artist', + 'profession', + 'boss', + 'career', + 'painter', + 'arts' + ], + modifiable: true), + Emoji( + name: 'artist: dark skin tone', + char: '\u{1F9D1}\u{1F3FF}\u{200D}\u{1F3A8}', + shortName: 'artist_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'uc12', + 'job', + 'painting', + 'artist', + 'profession', + 'boss', + 'career', + 'painter', + 'arts' + ], + modifiable: true), + Emoji( + name: 'woman artist', + char: '\u{1F469}\u{200D}\u{1F3A8}', + shortName: 'woman_artist', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'artist', + 'palette', + 'woman', + 'uc6', + 'diversity', + 'job', + 'painting', + 'artist', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'profession', + 'boss', + 'career', + 'painter', + 'arts' + ]), + Emoji( + name: 'woman artist: light skin tone', + char: '\u{1F469}\u{1F3FB}\u{200D}\u{1F3A8}', + shortName: 'woman_artist_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'artist', + 'light skin tone', + 'palette', + 'woman', + 'uc8', + 'diversity', + 'job', + 'painting', + 'artist', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'profession', + 'boss', + 'career', + 'painter', + 'arts' + ], + modifiable: true), + Emoji( + name: 'woman artist: medium-light skin tone', + char: '\u{1F469}\u{1F3FC}\u{200D}\u{1F3A8}', + shortName: 'woman_artist_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'artist', + 'medium-light skin tone', + 'palette', + 'woman', + 'uc8', + 'diversity', + 'job', + 'painting', + 'artist', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'profession', + 'boss', + 'career', + 'painter', + 'arts' + ], + modifiable: true), + Emoji( + name: 'woman artist: medium skin tone', + char: '\u{1F469}\u{1F3FD}\u{200D}\u{1F3A8}', + shortName: 'woman_artist_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'artist', + 'medium skin tone', + 'palette', + 'woman', + 'uc8', + 'diversity', + 'job', + 'painting', + 'artist', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'profession', + 'boss', + 'career', + 'painter', + 'arts' + ], + modifiable: true), + Emoji( + name: 'woman artist: medium-dark skin tone', + char: '\u{1F469}\u{1F3FE}\u{200D}\u{1F3A8}', + shortName: 'woman_artist_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'artist', + 'medium-dark skin tone', + 'palette', + 'woman', + 'uc8', + 'diversity', + 'job', + 'painting', + 'artist', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'profession', + 'boss', + 'career', + 'painter', + 'arts' + ], + modifiable: true), + Emoji( + name: 'woman artist: dark skin tone', + char: '\u{1F469}\u{1F3FF}\u{200D}\u{1F3A8}', + shortName: 'woman_artist_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'artist', + 'dark skin tone', + 'palette', + 'woman', + 'uc8', + 'diversity', + 'job', + 'painting', + 'artist', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'profession', + 'boss', + 'career', + 'painter', + 'arts' + ], + modifiable: true), + Emoji( + name: 'man artist', + char: '\u{1F468}\u{200D}\u{1F3A8}', + shortName: 'man_artist', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'artist', + 'man', + 'palette', + 'uc6', + 'diversity', + 'job', + 'painting', + 'artist', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'profession', + 'boss', + 'career', + 'painter', + 'arts' + ]), + Emoji( + name: 'man artist: light skin tone', + char: '\u{1F468}\u{1F3FB}\u{200D}\u{1F3A8}', + shortName: 'man_artist_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'artist', + 'light skin tone', + 'man', + 'palette', + 'uc8', + 'diversity', + 'job', + 'painting', + 'artist', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'profession', + 'boss', + 'career', + 'painter', + 'arts' + ], + modifiable: true), + Emoji( + name: 'man artist: medium-light skin tone', + char: '\u{1F468}\u{1F3FC}\u{200D}\u{1F3A8}', + shortName: 'man_artist_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'artist', + 'man', + 'medium-light skin tone', + 'palette', + 'uc8', + 'diversity', + 'job', + 'painting', + 'artist', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'profession', + 'boss', + 'career', + 'painter', + 'arts' + ], + modifiable: true), + Emoji( + name: 'man artist: medium skin tone', + char: '\u{1F468}\u{1F3FD}\u{200D}\u{1F3A8}', + shortName: 'man_artist_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'artist', + 'man', + 'medium skin tone', + 'palette', + 'uc8', + 'diversity', + 'job', + 'painting', + 'artist', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'profession', + 'boss', + 'career', + 'painter', + 'arts' + ], + modifiable: true), + Emoji( + name: 'man artist: medium-dark skin tone', + char: '\u{1F468}\u{1F3FE}\u{200D}\u{1F3A8}', + shortName: 'man_artist_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'artist', + 'man', + 'medium-dark skin tone', + 'palette', + 'uc8', + 'diversity', + 'job', + 'painting', + 'artist', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'profession', + 'boss', + 'career', + 'painter', + 'arts' + ], + modifiable: true), + Emoji( + name: 'man artist: dark skin tone', + char: '\u{1F468}\u{1F3FF}\u{200D}\u{1F3A8}', + shortName: 'man_artist_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'artist', + 'dark skin tone', + 'man', + 'palette', + 'uc8', + 'diversity', + 'job', + 'painting', + 'artist', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'profession', + 'boss', + 'career', + 'painter', + 'arts' + ], + modifiable: true), + Emoji( + name: 'firefighter', + char: '\u{1F9D1}\u{200D}\u{1F692}', + shortName: 'firefighter', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'uc12', + 'job', + '911', + 'help', + 'handsome', + 'fires', + 'helmet', + 'disguise', + 'profession', + 'boss', + 'career', + 'emergency', + 'injury', + 'stud' + ]), + Emoji( + name: 'firefighter: light skin tone', + char: '\u{1F9D1}\u{1F3FB}\u{200D}\u{1F692}', + shortName: 'firefighter_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'uc12', + 'job', + '911', + 'help', + 'handsome', + 'fires', + 'helmet', + 'disguise', + 'profession', + 'boss', + 'career', + 'emergency', + 'injury', + 'stud' + ], + modifiable: true), + Emoji( + name: 'firefighter: medium-light skin tone', + char: '\u{1F9D1}\u{1F3FC}\u{200D}\u{1F692}', + shortName: 'firefighter_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'uc12', + 'job', + '911', + 'help', + 'handsome', + 'fires', + 'helmet', + 'disguise', + 'profession', + 'boss', + 'career', + 'emergency', + 'injury', + 'stud' + ], + modifiable: true), + Emoji( + name: 'firefighter: medium skin tone', + char: '\u{1F9D1}\u{1F3FD}\u{200D}\u{1F692}', + shortName: 'firefighter_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'uc12', + 'job', + '911', + 'help', + 'handsome', + 'fires', + 'helmet', + 'disguise', + 'profession', + 'boss', + 'career', + 'emergency', + 'injury', + 'stud' + ], + modifiable: true), + Emoji( + name: 'firefighter: medium-dark skin tone', + char: '\u{1F9D1}\u{1F3FE}\u{200D}\u{1F692}', + shortName: 'firefighter_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'uc12', + 'job', + '911', + 'help', + 'handsome', + 'fires', + 'helmet', + 'disguise', + 'profession', + 'boss', + 'career', + 'emergency', + 'injury', + 'stud' + ], + modifiable: true), + Emoji( + name: 'firefighter: dark skin tone', + char: '\u{1F9D1}\u{1F3FF}\u{200D}\u{1F692}', + shortName: 'firefighter_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'uc12', + 'job', + '911', + 'help', + 'handsome', + 'fires', + 'helmet', + 'disguise', + 'profession', + 'boss', + 'career', + 'emergency', + 'injury', + 'stud' + ], + modifiable: true), + Emoji( + name: 'woman firefighter', + char: '\u{1F469}\u{200D}\u{1F692}', + shortName: 'woman_firefighter', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'firefighter', + 'firetruck', + 'woman', + 'uc6', + 'diversity', + 'job', + '911', + 'help', + 'fires', + 'helmet', + 'disguise', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'profession', + 'boss', + 'career', + 'emergency', + 'injury' + ]), + Emoji( + name: 'woman firefighter: light skin tone', + char: '\u{1F469}\u{1F3FB}\u{200D}\u{1F692}', + shortName: 'woman_firefighter_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'firefighter', + 'firetruck', + 'light skin tone', + 'woman', + 'uc8', + 'diversity', + 'job', + '911', + 'help', + 'fires', + 'helmet', + 'disguise', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'profession', + 'boss', + 'career', + 'emergency', + 'injury' + ], + modifiable: true), + Emoji( + name: 'woman firefighter: medium-light skin tone', + char: '\u{1F469}\u{1F3FC}\u{200D}\u{1F692}', + shortName: 'woman_firefighter_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'firefighter', + 'firetruck', + 'medium-light skin tone', + 'woman', + 'uc8', + 'diversity', + 'job', + '911', + 'help', + 'fires', + 'helmet', + 'disguise', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'profession', + 'boss', + 'career', + 'emergency', + 'injury' + ], + modifiable: true), + Emoji( + name: 'woman firefighter: medium skin tone', + char: '\u{1F469}\u{1F3FD}\u{200D}\u{1F692}', + shortName: 'woman_firefighter_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'firefighter', + 'firetruck', + 'medium skin tone', + 'woman', + 'uc8', + 'diversity', + 'job', + '911', + 'help', + 'fires', + 'helmet', + 'disguise', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'profession', + 'boss', + 'career', + 'emergency', + 'injury' + ], + modifiable: true), + Emoji( + name: 'woman firefighter: medium-dark skin tone', + char: '\u{1F469}\u{1F3FE}\u{200D}\u{1F692}', + shortName: 'woman_firefighter_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'firefighter', + 'firetruck', + 'medium-dark skin tone', + 'woman', + 'uc8', + 'diversity', + 'job', + '911', + 'help', + 'fires', + 'helmet', + 'disguise', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'profession', + 'boss', + 'career', + 'emergency', + 'injury' + ], + modifiable: true), + Emoji( + name: 'woman firefighter: dark skin tone', + char: '\u{1F469}\u{1F3FF}\u{200D}\u{1F692}', + shortName: 'woman_firefighter_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'dark skin tone', + 'firefighter', + 'firetruck', + 'woman', + 'uc8', + 'diversity', + 'job', + '911', + 'help', + 'fires', + 'helmet', + 'disguise', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'profession', + 'boss', + 'career', + 'emergency', + 'injury' + ], + modifiable: true), + Emoji( + name: 'man firefighter', + char: '\u{1F468}\u{200D}\u{1F692}', + shortName: 'man_firefighter', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'firefighter', + 'firetruck', + 'man', + 'uc6', + 'diversity', + 'job', + '911', + 'help', + 'handsome', + 'fires', + 'helmet', + 'disguise', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'profession', + 'boss', + 'career', + 'emergency', + 'injury', + 'stud' + ]), + Emoji( + name: 'man firefighter: light skin tone', + char: '\u{1F468}\u{1F3FB}\u{200D}\u{1F692}', + shortName: 'man_firefighter_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'firefighter', + 'firetruck', + 'light skin tone', + 'man', + 'uc8', + 'diversity', + 'job', + '911', + 'help', + 'handsome', + 'fires', + 'helmet', + 'disguise', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'profession', + 'boss', + 'career', + 'emergency', + 'injury', + 'stud' + ], + modifiable: true), + Emoji( + name: 'man firefighter: medium-light skin tone', + char: '\u{1F468}\u{1F3FC}\u{200D}\u{1F692}', + shortName: 'man_firefighter_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'firefighter', + 'firetruck', + 'man', + 'medium-light skin tone', + 'uc8', + 'diversity', + 'job', + '911', + 'help', + 'handsome', + 'fires', + 'helmet', + 'disguise', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'profession', + 'boss', + 'career', + 'emergency', + 'injury', + 'stud' + ], + modifiable: true), + Emoji( + name: 'man firefighter: medium skin tone', + char: '\u{1F468}\u{1F3FD}\u{200D}\u{1F692}', + shortName: 'man_firefighter_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'firefighter', + 'firetruck', + 'man', + 'medium skin tone', + 'uc8', + 'diversity', + 'job', + '911', + 'help', + 'handsome', + 'fires', + 'helmet', + 'disguise', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'profession', + 'boss', + 'career', + 'emergency', + 'injury', + 'stud' + ], + modifiable: true), + Emoji( + name: 'man firefighter: medium-dark skin tone', + char: '\u{1F468}\u{1F3FE}\u{200D}\u{1F692}', + shortName: 'man_firefighter_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'firefighter', + 'firetruck', + 'man', + 'medium-dark skin tone', + 'uc8', + 'diversity', + 'job', + '911', + 'help', + 'handsome', + 'fires', + 'helmet', + 'disguise', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'profession', + 'boss', + 'career', + 'emergency', + 'injury', + 'stud' + ], + modifiable: true), + Emoji( + name: 'man firefighter: dark skin tone', + char: '\u{1F468}\u{1F3FF}\u{200D}\u{1F692}', + shortName: 'man_firefighter_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'dark skin tone', + 'firefighter', + 'firetruck', + 'man', + 'uc8', + 'diversity', + 'job', + '911', + 'help', + 'handsome', + 'fires', + 'helmet', + 'disguise', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'profession', + 'boss', + 'career', + 'emergency', + 'injury', + 'stud' + ], + modifiable: true), + Emoji( + name: 'pilot', + char: '\u{1F9D1}\u{200D}\u{2708}\u{FE0F}', + shortName: 'pilot', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'uc12', + 'fly', + 'job', + 'airplane', + 'handsome', + 'disguise', + 'flight', + 'flying', + 'flights', + 'avion', + 'profession', + 'boss', + 'career', + 'airline', + 'aircraft', + 'airforce', + 'air force', + 'airport', + 'stud' + ]), + Emoji( + name: 'pilot: light skin tone', + char: '\u{1F9D1}\u{1F3FB}\u{200D}\u{2708}\u{FE0F}', + shortName: 'pilot_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'uc12', + 'fly', + 'job', + 'airplane', + 'handsome', + 'disguise', + 'flight', + 'flying', + 'flights', + 'avion', + 'profession', + 'boss', + 'career', + 'airline', + 'aircraft', + 'airforce', + 'air force', + 'airport', + 'stud' + ], + modifiable: true), + Emoji( + name: 'pilot: medium-light skin tone', + char: '\u{1F9D1}\u{1F3FC}\u{200D}\u{2708}\u{FE0F}', + shortName: 'pilot_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'uc12', + 'fly', + 'job', + 'airplane', + 'handsome', + 'disguise', + 'flight', + 'flying', + 'flights', + 'avion', + 'profession', + 'boss', + 'career', + 'airline', + 'aircraft', + 'airforce', + 'air force', + 'airport', + 'stud' + ], + modifiable: true), + Emoji( + name: 'pilot: medium skin tone', + char: '\u{1F9D1}\u{1F3FD}\u{200D}\u{2708}\u{FE0F}', + shortName: 'pilot_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'uc12', + 'fly', + 'job', + 'airplane', + 'handsome', + 'disguise', + 'flight', + 'flying', + 'flights', + 'avion', + 'profession', + 'boss', + 'career', + 'airline', + 'aircraft', + 'airforce', + 'air force', + 'airport', + 'stud' + ], + modifiable: true), + Emoji( + name: 'pilot: medium-dark skin tone', + char: '\u{1F9D1}\u{1F3FE}\u{200D}\u{2708}\u{FE0F}', + shortName: 'pilot_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'uc12', + 'fly', + 'job', + 'airplane', + 'handsome', + 'disguise', + 'flight', + 'flying', + 'flights', + 'avion', + 'profession', + 'boss', + 'career', + 'airline', + 'aircraft', + 'airforce', + 'air force', + 'airport', + 'stud' + ], + modifiable: true), + Emoji( + name: 'pilot: dark skin tone', + char: '\u{1F9D1}\u{1F3FF}\u{200D}\u{2708}\u{FE0F}', + shortName: 'pilot_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'uc12', + 'fly', + 'job', + 'airplane', + 'handsome', + 'disguise', + 'flight', + 'flying', + 'flights', + 'avion', + 'profession', + 'boss', + 'career', + 'airline', + 'aircraft', + 'airforce', + 'air force', + 'airport', + 'stud' + ], + modifiable: true), + Emoji( + name: 'woman pilot', + char: '\u{1F469}\u{200D}\u{2708}\u{FE0F}', + shortName: 'woman_pilot', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'pilot', + 'plane', + 'woman', + 'uc6', + 'diversity', + 'fly', + 'job', + 'airplane', + 'disguise', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'flight', + 'flying', + 'flights', + 'avion', + 'profession', + 'boss', + 'career', + 'airline', + 'aircraft', + 'airforce', + 'air force', + 'airport' + ]), + Emoji( + name: 'woman pilot: light skin tone', + char: '\u{1F469}\u{1F3FB}\u{200D}\u{2708}\u{FE0F}', + shortName: 'woman_pilot_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'light skin tone', + 'pilot', + 'plane', + 'woman', + 'uc8', + 'diversity', + 'fly', + 'job', + 'airplane', + 'disguise', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'flight', + 'flying', + 'flights', + 'avion', + 'profession', + 'boss', + 'career', + 'airline', + 'aircraft', + 'airforce', + 'air force', + 'airport' + ], + modifiable: true), + Emoji( + name: 'woman pilot: medium-light skin tone', + char: '\u{1F469}\u{1F3FC}\u{200D}\u{2708}\u{FE0F}', + shortName: 'woman_pilot_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'medium-light skin tone', + 'pilot', + 'plane', + 'woman', + 'uc8', + 'diversity', + 'fly', + 'job', + 'airplane', + 'disguise', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'flight', + 'flying', + 'flights', + 'avion', + 'profession', + 'boss', + 'career', + 'airline', + 'aircraft', + 'airforce', + 'air force', + 'airport' + ], + modifiable: true), + Emoji( + name: 'woman pilot: medium skin tone', + char: '\u{1F469}\u{1F3FD}\u{200D}\u{2708}\u{FE0F}', + shortName: 'woman_pilot_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'medium skin tone', + 'pilot', + 'plane', + 'woman', + 'uc8', + 'diversity', + 'fly', + 'job', + 'airplane', + 'disguise', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'flight', + 'flying', + 'flights', + 'avion', + 'profession', + 'boss', + 'career', + 'airline', + 'aircraft', + 'airforce', + 'air force', + 'airport' + ], + modifiable: true), + Emoji( + name: 'woman pilot: medium-dark skin tone', + char: '\u{1F469}\u{1F3FE}\u{200D}\u{2708}\u{FE0F}', + shortName: 'woman_pilot_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'medium-dark skin tone', + 'pilot', + 'plane', + 'woman', + 'uc8', + 'diversity', + 'fly', + 'job', + 'airplane', + 'disguise', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'flight', + 'flying', + 'flights', + 'avion', + 'profession', + 'boss', + 'career', + 'airline', + 'aircraft', + 'airforce', + 'air force', + 'airport' + ], + modifiable: true), + Emoji( + name: 'woman pilot: dark skin tone', + char: '\u{1F469}\u{1F3FF}\u{200D}\u{2708}\u{FE0F}', + shortName: 'woman_pilot_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'dark skin tone', + 'pilot', + 'plane', + 'woman', + 'uc8', + 'diversity', + 'fly', + 'job', + 'airplane', + 'disguise', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'flight', + 'flying', + 'flights', + 'avion', + 'profession', + 'boss', + 'career', + 'airline', + 'aircraft', + 'airforce', + 'air force', + 'airport' + ], + modifiable: true), + Emoji( + name: 'man pilot', + char: '\u{1F468}\u{200D}\u{2708}\u{FE0F}', + shortName: 'man_pilot', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'man', + 'pilot', + 'plane', + 'uc6', + 'diversity', + 'fly', + 'job', + 'airplane', + 'handsome', + 'disguise', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'flight', + 'flying', + 'flights', + 'avion', + 'profession', + 'boss', + 'career', + 'airline', + 'aircraft', + 'airforce', + 'air force', + 'airport', + 'stud' + ]), + Emoji( + name: 'man pilot: light skin tone', + char: '\u{1F468}\u{1F3FB}\u{200D}\u{2708}\u{FE0F}', + shortName: 'man_pilot_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'light skin tone', + 'man', + 'pilot', + 'plane', + 'uc8', + 'diversity', + 'fly', + 'job', + 'airplane', + 'handsome', + 'disguise', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'flight', + 'flying', + 'flights', + 'avion', + 'profession', + 'boss', + 'career', + 'airline', + 'aircraft', + 'airforce', + 'air force', + 'airport', + 'stud' + ], + modifiable: true), + Emoji( + name: 'man pilot: medium-light skin tone', + char: '\u{1F468}\u{1F3FC}\u{200D}\u{2708}\u{FE0F}', + shortName: 'man_pilot_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'man', + 'medium-light skin tone', + 'pilot', + 'plane', + 'uc8', + 'diversity', + 'fly', + 'job', + 'airplane', + 'handsome', + 'disguise', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'flight', + 'flying', + 'flights', + 'avion', + 'profession', + 'boss', + 'career', + 'airline', + 'aircraft', + 'airforce', + 'air force', + 'airport', + 'stud' + ], + modifiable: true), + Emoji( + name: 'man pilot: medium skin tone', + char: '\u{1F468}\u{1F3FD}\u{200D}\u{2708}\u{FE0F}', + shortName: 'man_pilot_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'man', + 'medium skin tone', + 'pilot', + 'plane', + 'uc8', + 'diversity', + 'fly', + 'job', + 'airplane', + 'handsome', + 'disguise', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'flight', + 'flying', + 'flights', + 'avion', + 'profession', + 'boss', + 'career', + 'airline', + 'aircraft', + 'airforce', + 'air force', + 'airport', + 'stud' + ], + modifiable: true), + Emoji( + name: 'man pilot: medium-dark skin tone', + char: '\u{1F468}\u{1F3FE}\u{200D}\u{2708}\u{FE0F}', + shortName: 'man_pilot_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'man', + 'medium-dark skin tone', + 'pilot', + 'plane', + 'uc8', + 'diversity', + 'fly', + 'job', + 'airplane', + 'handsome', + 'disguise', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'flight', + 'flying', + 'flights', + 'avion', + 'profession', + 'boss', + 'career', + 'airline', + 'aircraft', + 'airforce', + 'air force', + 'airport', + 'stud' + ], + modifiable: true), + Emoji( + name: 'man pilot: dark skin tone', + char: '\u{1F468}\u{1F3FF}\u{200D}\u{2708}\u{FE0F}', + shortName: 'man_pilot_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'dark skin tone', + 'man', + 'pilot', + 'plane', + 'uc8', + 'diversity', + 'fly', + 'job', + 'airplane', + 'handsome', + 'disguise', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'flight', + 'flying', + 'flights', + 'avion', + 'profession', + 'boss', + 'career', + 'airline', + 'aircraft', + 'airforce', + 'air force', + 'airport', + 'stud' + ], + modifiable: true), + Emoji( + name: 'astronaut', + char: '\u{1F9D1}\u{200D}\u{1F680}', + shortName: 'astronaut', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'uc12', + 'space', + 'job', + 'helmet', + 'disguise', + 'outer space', + 'galaxy', + 'universe', + 'nasa', + 'spaceship', + 'profession', + 'boss', + 'career' + ]), + Emoji( + name: 'astronaut: light skin tone', + char: '\u{1F9D1}\u{1F3FB}\u{200D}\u{1F680}', + shortName: 'astronaut_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'uc12', + 'space', + 'job', + 'helmet', + 'disguise', + 'outer space', + 'galaxy', + 'universe', + 'nasa', + 'spaceship', + 'profession', + 'boss', + 'career' + ], + modifiable: true), + Emoji( + name: 'astronaut: medium-light skin tone', + char: '\u{1F9D1}\u{1F3FC}\u{200D}\u{1F680}', + shortName: 'astronaut_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'uc12', + 'space', + 'job', + 'helmet', + 'disguise', + 'outer space', + 'galaxy', + 'universe', + 'nasa', + 'spaceship', + 'profession', + 'boss', + 'career' + ], + modifiable: true), + Emoji( + name: 'astronaut: medium skin tone', + char: '\u{1F9D1}\u{1F3FD}\u{200D}\u{1F680}', + shortName: 'astronaut_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'uc12', + 'space', + 'job', + 'helmet', + 'disguise', + 'outer space', + 'galaxy', + 'universe', + 'nasa', + 'spaceship', + 'profession', + 'boss', + 'career' + ], + modifiable: true), + Emoji( + name: 'astronaut: medium-dark skin tone', + char: '\u{1F9D1}\u{1F3FE}\u{200D}\u{1F680}', + shortName: 'astronaut_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'uc12', + 'space', + 'job', + 'helmet', + 'disguise', + 'outer space', + 'galaxy', + 'universe', + 'nasa', + 'spaceship', + 'profession', + 'boss', + 'career' + ], + modifiable: true), + Emoji( + name: 'astronaut: dark skin tone', + char: '\u{1F9D1}\u{1F3FF}\u{200D}\u{1F680}', + shortName: 'astronaut_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'uc12', + 'space', + 'job', + 'helmet', + 'disguise', + 'outer space', + 'galaxy', + 'universe', + 'nasa', + 'spaceship', + 'profession', + 'boss', + 'career' + ], + modifiable: true), + Emoji( + name: 'woman astronaut', + char: '\u{1F469}\u{200D}\u{1F680}', + shortName: 'woman_astronaut', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'astronaut', + 'rocket', + 'woman', + 'uc6', + 'diversity', + 'space', + 'job', + 'helmet', + 'disguise', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'outer space', + 'galaxy', + 'universe', + 'nasa', + 'spaceship', + 'profession', + 'boss', + 'career' + ]), + Emoji( + name: 'woman astronaut: light skin tone', + char: '\u{1F469}\u{1F3FB}\u{200D}\u{1F680}', + shortName: 'woman_astronaut_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'astronaut', + 'light skin tone', + 'rocket', + 'woman', + 'uc8', + 'diversity', + 'space', + 'job', + 'helmet', + 'disguise', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'outer space', + 'galaxy', + 'universe', + 'nasa', + 'spaceship', + 'profession', + 'boss', + 'career' + ], + modifiable: true), + Emoji( + name: 'woman astronaut: medium-light skin tone', + char: '\u{1F469}\u{1F3FC}\u{200D}\u{1F680}', + shortName: 'woman_astronaut_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'astronaut', + 'medium-light skin tone', + 'rocket', + 'woman', + 'uc8', + 'diversity', + 'space', + 'job', + 'helmet', + 'disguise', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'outer space', + 'galaxy', + 'universe', + 'nasa', + 'spaceship', + 'profession', + 'boss', + 'career' + ], + modifiable: true), + Emoji( + name: 'woman astronaut: medium skin tone', + char: '\u{1F469}\u{1F3FD}\u{200D}\u{1F680}', + shortName: 'woman_astronaut_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'astronaut', + 'medium skin tone', + 'rocket', + 'woman', + 'uc8', + 'diversity', + 'space', + 'job', + 'helmet', + 'disguise', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'outer space', + 'galaxy', + 'universe', + 'nasa', + 'spaceship', + 'profession', + 'boss', + 'career' + ], + modifiable: true), + Emoji( + name: 'woman astronaut: medium-dark skin tone', + char: '\u{1F469}\u{1F3FE}\u{200D}\u{1F680}', + shortName: 'woman_astronaut_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'astronaut', + 'medium-dark skin tone', + 'rocket', + 'woman', + 'uc8', + 'diversity', + 'space', + 'job', + 'helmet', + 'disguise', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'outer space', + 'galaxy', + 'universe', + 'nasa', + 'spaceship', + 'profession', + 'boss', + 'career' + ], + modifiable: true), + Emoji( + name: 'woman astronaut: dark skin tone', + char: '\u{1F469}\u{1F3FF}\u{200D}\u{1F680}', + shortName: 'woman_astronaut_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'astronaut', + 'dark skin tone', + 'rocket', + 'woman', + 'uc8', + 'diversity', + 'space', + 'job', + 'helmet', + 'disguise', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'outer space', + 'galaxy', + 'universe', + 'nasa', + 'spaceship', + 'profession', + 'boss', + 'career' + ], + modifiable: true), + Emoji( + name: 'man astronaut', + char: '\u{1F468}\u{200D}\u{1F680}', + shortName: 'man_astronaut', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'astronaut', + 'man', + 'rocket', + 'uc6', + 'diversity', + 'space', + 'job', + 'helmet', + 'disguise', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'outer space', + 'galaxy', + 'universe', + 'nasa', + 'spaceship', + 'profession', + 'boss', + 'career' + ]), + Emoji( + name: 'man astronaut: light skin tone', + char: '\u{1F468}\u{1F3FB}\u{200D}\u{1F680}', + shortName: 'man_astronaut_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'astronaut', + 'light skin tone', + 'man', + 'rocket', + 'uc8', + 'diversity', + 'space', + 'job', + 'helmet', + 'disguise', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'outer space', + 'galaxy', + 'universe', + 'nasa', + 'spaceship', + 'profession', + 'boss', + 'career' + ], + modifiable: true), + Emoji( + name: 'man astronaut: medium-light skin tone', + char: '\u{1F468}\u{1F3FC}\u{200D}\u{1F680}', + shortName: 'man_astronaut_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'astronaut', + 'man', + 'medium-light skin tone', + 'rocket', + 'uc8', + 'diversity', + 'space', + 'job', + 'helmet', + 'disguise', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'outer space', + 'galaxy', + 'universe', + 'nasa', + 'spaceship', + 'profession', + 'boss', + 'career' + ], + modifiable: true), + Emoji( + name: 'man astronaut: medium skin tone', + char: '\u{1F468}\u{1F3FD}\u{200D}\u{1F680}', + shortName: 'man_astronaut_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'astronaut', + 'man', + 'medium skin tone', + 'rocket', + 'uc8', + 'diversity', + 'space', + 'job', + 'helmet', + 'disguise', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'outer space', + 'galaxy', + 'universe', + 'nasa', + 'spaceship', + 'profession', + 'boss', + 'career' + ], + modifiable: true), + Emoji( + name: 'man astronaut: medium-dark skin tone', + char: '\u{1F468}\u{1F3FE}\u{200D}\u{1F680}', + shortName: 'man_astronaut_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'astronaut', + 'man', + 'medium-dark skin tone', + 'rocket', + 'uc8', + 'diversity', + 'space', + 'job', + 'helmet', + 'disguise', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'outer space', + 'galaxy', + 'universe', + 'nasa', + 'spaceship', + 'profession', + 'boss', + 'career' + ], + modifiable: true), + Emoji( + name: 'man astronaut: dark skin tone', + char: '\u{1F468}\u{1F3FF}\u{200D}\u{1F680}', + shortName: 'man_astronaut_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'astronaut', + 'dark skin tone', + 'man', + 'rocket', + 'uc8', + 'diversity', + 'space', + 'job', + 'helmet', + 'disguise', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'outer space', + 'galaxy', + 'universe', + 'nasa', + 'spaceship', + 'profession', + 'boss', + 'career' + ], + modifiable: true), + Emoji( + name: 'judge', + char: '\u{1F9D1}\u{200D}\u{2696}\u{FE0F}', + shortName: 'judge', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'uc12', + 'job', + 'nerd', + 'court', + 'profession', + 'boss', + 'career', + 'smart', + 'geek', + 'serious' + ]), + Emoji( + name: 'judge: light skin tone', + char: '\u{1F9D1}\u{1F3FB}\u{200D}\u{2696}\u{FE0F}', + shortName: 'judge_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'uc12', + 'job', + 'nerd', + 'court', + 'profession', + 'boss', + 'career', + 'smart', + 'geek', + 'serious' + ], + modifiable: true), + Emoji( + name: 'judge: medium-light skin tone', + char: '\u{1F9D1}\u{1F3FC}\u{200D}\u{2696}\u{FE0F}', + shortName: 'judge_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'uc12', + 'job', + 'nerd', + 'court', + 'profession', + 'boss', + 'career', + 'smart', + 'geek', + 'serious' + ], + modifiable: true), + Emoji( + name: 'judge: medium skin tone', + char: '\u{1F9D1}\u{1F3FD}\u{200D}\u{2696}\u{FE0F}', + shortName: 'judge_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'uc12', + 'job', + 'nerd', + 'court', + 'profession', + 'boss', + 'career', + 'smart', + 'geek', + 'serious' + ], + modifiable: true), + Emoji( + name: 'judge: medium-dark skin tone', + char: '\u{1F9D1}\u{1F3FE}\u{200D}\u{2696}\u{FE0F}', + shortName: 'judge_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'uc12', + 'job', + 'nerd', + 'court', + 'profession', + 'boss', + 'career', + 'smart', + 'geek', + 'serious' + ], + modifiable: true), + Emoji( + name: 'judge: dark skin tone', + char: '\u{1F9D1}\u{1F3FF}\u{200D}\u{2696}\u{FE0F}', + shortName: 'judge_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'uc12', + 'job', + 'nerd', + 'court', + 'profession', + 'boss', + 'career', + 'smart', + 'geek', + 'serious' + ], + modifiable: true), + Emoji( + name: 'woman judge', + char: '\u{1F469}\u{200D}\u{2696}\u{FE0F}', + shortName: 'woman_judge', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'judge', + 'scales', + 'woman', + 'uc6', + 'diversity', + 'job', + 'nerd', + 'court', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'profession', + 'boss', + 'career', + 'smart', + 'geek', + 'serious' + ]), + Emoji( + name: 'woman judge: light skin tone', + char: '\u{1F469}\u{1F3FB}\u{200D}\u{2696}\u{FE0F}', + shortName: 'woman_judge_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'judge', + 'light skin tone', + 'scales', + 'woman', + 'uc8', + 'diversity', + 'job', + 'nerd', + 'court', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'profession', + 'boss', + 'career', + 'smart', + 'geek', + 'serious' + ], + modifiable: true), + Emoji( + name: 'woman judge: medium-light skin tone', + char: '\u{1F469}\u{1F3FC}\u{200D}\u{2696}\u{FE0F}', + shortName: 'woman_judge_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'judge', + 'medium-light skin tone', + 'scales', + 'woman', + 'uc8', + 'diversity', + 'job', + 'nerd', + 'court', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'profession', + 'boss', + 'career', + 'smart', + 'geek', + 'serious' + ], + modifiable: true), + Emoji( + name: 'woman judge: medium skin tone', + char: '\u{1F469}\u{1F3FD}\u{200D}\u{2696}\u{FE0F}', + shortName: 'woman_judge_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'judge', + 'medium skin tone', + 'scales', + 'woman', + 'uc8', + 'diversity', + 'job', + 'nerd', + 'court', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'profession', + 'boss', + 'career', + 'smart', + 'geek', + 'serious' + ], + modifiable: true), + Emoji( + name: 'woman judge: medium-dark skin tone', + char: '\u{1F469}\u{1F3FE}\u{200D}\u{2696}\u{FE0F}', + shortName: 'woman_judge_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'judge', + 'medium-dark skin tone', + 'scales', + 'woman', + 'uc8', + 'diversity', + 'job', + 'nerd', + 'court', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'profession', + 'boss', + 'career', + 'smart', + 'geek', + 'serious' + ], + modifiable: true), + Emoji( + name: 'woman judge: dark skin tone', + char: '\u{1F469}\u{1F3FF}\u{200D}\u{2696}\u{FE0F}', + shortName: 'woman_judge_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'dark skin tone', + 'judge', + 'scales', + 'woman', + 'uc8', + 'diversity', + 'job', + 'nerd', + 'court', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'profession', + 'boss', + 'career', + 'smart', + 'geek', + 'serious' + ], + modifiable: true), + Emoji( + name: 'man judge', + char: '\u{1F468}\u{200D}\u{2696}\u{FE0F}', + shortName: 'man_judge', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'justice', + 'man', + 'scales', + 'uc6', + 'diversity', + 'job', + 'nerd', + 'court', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'profession', + 'boss', + 'career', + 'smart', + 'geek', + 'serious' + ]), + Emoji( + name: 'man judge: light skin tone', + char: '\u{1F468}\u{1F3FB}\u{200D}\u{2696}\u{FE0F}', + shortName: 'man_judge_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'justice', + 'light skin tone', + 'man', + 'scales', + 'uc8', + 'diversity', + 'job', + 'nerd', + 'court', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'profession', + 'boss', + 'career', + 'smart', + 'geek', + 'serious' + ], + modifiable: true), + Emoji( + name: 'man judge: medium-light skin tone', + char: '\u{1F468}\u{1F3FC}\u{200D}\u{2696}\u{FE0F}', + shortName: 'man_judge_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'justice', + 'man', + 'medium-light skin tone', + 'scales', + 'uc8', + 'diversity', + 'job', + 'nerd', + 'court', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'profession', + 'boss', + 'career', + 'smart', + 'geek', + 'serious' + ], + modifiable: true), + Emoji( + name: 'man judge: medium skin tone', + char: '\u{1F468}\u{1F3FD}\u{200D}\u{2696}\u{FE0F}', + shortName: 'man_judge_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'justice', + 'man', + 'medium skin tone', + 'scales', + 'uc8', + 'diversity', + 'job', + 'nerd', + 'court', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'profession', + 'boss', + 'career', + 'smart', + 'geek', + 'serious' + ], + modifiable: true), + Emoji( + name: 'man judge: medium-dark skin tone', + char: '\u{1F468}\u{1F3FE}\u{200D}\u{2696}\u{FE0F}', + shortName: 'man_judge_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'justice', + 'man', + 'medium-dark skin tone', + 'scales', + 'uc8', + 'diversity', + 'job', + 'nerd', + 'court', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'profession', + 'boss', + 'career', + 'smart', + 'geek', + 'serious' + ], + modifiable: true), + Emoji( + name: 'man judge: dark skin tone', + char: '\u{1F468}\u{1F3FF}\u{200D}\u{2696}\u{FE0F}', + shortName: 'man_judge_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'dark skin tone', + 'justice', + 'man', + 'scales', + 'uc8', + 'diversity', + 'job', + 'nerd', + 'court', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'profession', + 'boss', + 'career', + 'smart', + 'geek', + 'serious' + ], + modifiable: true), + Emoji( + name: 'person with veil', + char: '\u{1F470}', + shortName: 'person_with_veil', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'person', + 'veil', + 'wedding', + 'uc6', + 'diversity', + 'wedding', + 'women', + 'beautiful', + 'las vegas', + 'wife', + 'dress', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'weddings', + 'marriage', + 'newlywed', + 'bride', + 'groome', + 'groom', + 'married', + 'marry', + 'woman', + 'female', + 'cute', + 'pretty', + 'adorable', + 'adore', + 'beauty', + 'cutie', + 'babe', + 'lovely', + 'vegas' + ]), + Emoji( + name: 'person with veil: light skin tone', + char: '\u{1F470}\u{1F3FB}', + shortName: 'person_with_veil_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'person', + 'light skin tone', + 'veil', + 'wedding', + 'uc8', + 'diversity', + 'wedding', + 'women', + 'beautiful', + 'las vegas', + 'wife', + 'dress', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'weddings', + 'marriage', + 'newlywed', + 'bride', + 'groome', + 'groom', + 'married', + 'marry', + 'woman', + 'female', + 'cute', + 'pretty', + 'adorable', + 'adore', + 'beauty', + 'cutie', + 'babe', + 'lovely', + 'vegas' + ], + modifiable: true), + Emoji( + name: 'person with veil: medium-light skin tone', + char: '\u{1F470}\u{1F3FC}', + shortName: 'person_with_veil_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'person', + 'medium-light skin tone', + 'veil', + 'wedding', + 'uc8', + 'diversity', + 'wedding', + 'women', + 'beautiful', + 'las vegas', + 'wife', + 'dress', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'weddings', + 'marriage', + 'newlywed', + 'bride', + 'groome', + 'groom', + 'married', + 'marry', + 'woman', + 'female', + 'cute', + 'pretty', + 'adorable', + 'adore', + 'beauty', + 'cutie', + 'babe', + 'lovely', + 'vegas' + ], + modifiable: true), + Emoji( + name: 'person with veil: medium skin tone', + char: '\u{1F470}\u{1F3FD}', + shortName: 'person_with_veil_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'person', + 'medium skin tone', + 'veil', + 'wedding', + 'uc8', + 'diversity', + 'wedding', + 'women', + 'beautiful', + 'las vegas', + 'wife', + 'dress', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'weddings', + 'marriage', + 'newlywed', + 'bride', + 'groome', + 'groom', + 'married', + 'marry', + 'woman', + 'female', + 'cute', + 'pretty', + 'adorable', + 'adore', + 'beauty', + 'cutie', + 'babe', + 'lovely', + 'vegas' + ], + modifiable: true), + Emoji( + name: 'person with veil: medium-dark skin tone', + char: '\u{1F470}\u{1F3FE}', + shortName: 'person_with_veil_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'person', + 'medium-dark skin tone', + 'veil', + 'wedding', + 'uc8', + 'diversity', + 'wedding', + 'women', + 'beautiful', + 'las vegas', + 'wife', + 'dress', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'weddings', + 'marriage', + 'newlywed', + 'bride', + 'groome', + 'groom', + 'married', + 'marry', + 'woman', + 'female', + 'cute', + 'pretty', + 'adorable', + 'adore', + 'beauty', + 'cutie', + 'babe', + 'lovely', + 'vegas' + ], + modifiable: true), + Emoji( + name: 'person with veil: dark skin tone', + char: '\u{1F470}\u{1F3FF}', + shortName: 'person_with_veil_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'person', + 'dark skin tone', + 'veil', + 'wedding', + 'uc8', + 'diversity', + 'wedding', + 'women', + 'beautiful', + 'las vegas', + 'wife', + 'dress', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'weddings', + 'marriage', + 'newlywed', + 'bride', + 'groome', + 'groom', + 'married', + 'marry', + 'woman', + 'female', + 'cute', + 'pretty', + 'adorable', + 'adore', + 'beauty', + 'cutie', + 'babe', + 'lovely', + 'vegas' + ], + modifiable: true), + Emoji( + name: 'woman with veil', + char: '\u{1F470}\u{200D}\u{2640}\u{FE0F}', + shortName: 'woman_with_veil', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'uc13', + 'wedding', + 'beautiful', + 'wife', + 'dress', + 'weddings', + 'marriage', + 'newlywed', + 'bride', + 'groome', + 'groom', + 'married', + 'marry', + 'cute', + 'pretty', + 'adorable', + 'adore', + 'beauty', + 'cutie', + 'babe', + 'lovely' + ]), + Emoji( + name: 'woman with veil: light skin tone', + char: '\u{1F470}\u{1F3FB}\u{200D}\u{2640}\u{FE0F}', + shortName: 'woman_with_veil_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'uc13', + 'wedding', + 'beautiful', + 'wife', + 'dress', + 'weddings', + 'marriage', + 'newlywed', + 'bride', + 'groome', + 'groom', + 'married', + 'marry', + 'cute', + 'pretty', + 'adorable', + 'adore', + 'beauty', + 'cutie', + 'babe', + 'lovely' + ], + modifiable: true), + Emoji( + name: 'woman with veil: medium-light skin tone', + char: '\u{1F470}\u{1F3FC}\u{200D}\u{2640}\u{FE0F}', + shortName: 'woman_with_veil_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'uc13', + 'wedding', + 'beautiful', + 'wife', + 'dress', + 'weddings', + 'marriage', + 'newlywed', + 'bride', + 'groome', + 'groom', + 'married', + 'marry', + 'cute', + 'pretty', + 'adorable', + 'adore', + 'beauty', + 'cutie', + 'babe', + 'lovely' + ], + modifiable: true), + Emoji( + name: 'woman with veil: medium skin tone', + char: '\u{1F470}\u{1F3FD}\u{200D}\u{2640}\u{FE0F}', + shortName: 'woman_with_veil_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'uc13', + 'wedding', + 'beautiful', + 'wife', + 'dress', + 'weddings', + 'marriage', + 'newlywed', + 'bride', + 'groome', + 'groom', + 'married', + 'marry', + 'cute', + 'pretty', + 'adorable', + 'adore', + 'beauty', + 'cutie', + 'babe', + 'lovely' + ], + modifiable: true), + Emoji( + name: 'woman with veil: medium-dark skin tone', + char: '\u{1F470}\u{1F3FE}\u{200D}\u{2640}\u{FE0F}', + shortName: 'woman_with_veil_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'uc13', + 'wedding', + 'beautiful', + 'wife', + 'dress', + 'weddings', + 'marriage', + 'newlywed', + 'bride', + 'groome', + 'groom', + 'married', + 'marry', + 'cute', + 'pretty', + 'adorable', + 'adore', + 'beauty', + 'cutie', + 'babe', + 'lovely' + ], + modifiable: true), + Emoji( + name: 'woman with veil: dark skin tone', + char: '\u{1F470}\u{1F3FF}\u{200D}\u{2640}\u{FE0F}', + shortName: 'woman_with_veil_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'uc13', + 'wedding', + 'beautiful', + 'wife', + 'dress', + 'weddings', + 'marriage', + 'newlywed', + 'bride', + 'groome', + 'groom', + 'married', + 'marry', + 'cute', + 'pretty', + 'adorable', + 'adore', + 'beauty', + 'cutie', + 'babe', + 'lovely' + ], + modifiable: true), + Emoji( + name: 'man with veil', + char: '\u{1F470}\u{200D}\u{2642}\u{FE0F}', + shortName: 'man_with_veil', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'uc13', + 'wedding', + 'wife', + 'weddings', + 'marriage', + 'newlywed', + 'bride', + 'groome', + 'groom', + 'married', + 'marry' + ]), + Emoji( + name: 'man with veil: light skin tone', + char: '\u{1F470}\u{1F3FB}\u{200D}\u{2642}\u{FE0F}', + shortName: 'man_with_veil_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'uc13', + 'wedding', + 'wife', + 'weddings', + 'marriage', + 'newlywed', + 'bride', + 'groome', + 'groom', + 'married', + 'marry' + ], + modifiable: true), + Emoji( + name: 'man with veil: medium-light skin tone', + char: '\u{1F470}\u{1F3FC}\u{200D}\u{2642}\u{FE0F}', + shortName: 'man_with_veil_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'uc13', + 'wedding', + 'wife', + 'weddings', + 'marriage', + 'newlywed', + 'bride', + 'groome', + 'groom', + 'married', + 'marry' + ], + modifiable: true), + Emoji( + name: 'man with veil: medium skin tone', + char: '\u{1F470}\u{1F3FD}\u{200D}\u{2642}\u{FE0F}', + shortName: 'man_with_veil_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'uc13', + 'wedding', + 'wife', + 'weddings', + 'marriage', + 'newlywed', + 'bride', + 'groome', + 'groom', + 'married', + 'marry' + ], + modifiable: true), + Emoji( + name: 'man with veil: medium-dark skin tone', + char: '\u{1F470}\u{1F3FE}\u{200D}\u{2642}\u{FE0F}', + shortName: 'man_with_veil_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'uc13', + 'wedding', + 'wife', + 'weddings', + 'marriage', + 'newlywed', + 'bride', + 'groome', + 'groom', + 'married', + 'marry' + ], + modifiable: true), + Emoji( + name: 'man with veil: dark skin tone', + char: '\u{1F470}\u{1F3FF}\u{200D}\u{2642}\u{FE0F}', + shortName: 'man_with_veil_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'uc13', + 'wedding', + 'wife', + 'weddings', + 'marriage', + 'newlywed', + 'bride', + 'groome', + 'groom', + 'married', + 'marry' + ], + modifiable: true), + Emoji( + name: 'person in tuxedo', + char: '\u{1F935}', + shortName: 'person_in_tuxedo', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'person', + 'tuxedo', + 'uc9', + 'diversity', + 'wedding', + 'men', + 'boys night', + 'donald trump', + 'fame', + 'vampire', + 'las vegas', + 'rich', + 'jacket', + 'handsome', + 'costume', + 'husband', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'weddings', + 'marriage', + 'newlywed', + 'bride', + 'groome', + 'groom', + 'married', + 'marry', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'guys night', + 'trump', + 'famous', + 'celebrity', + 'dracula', + 'vegas', + 'grand', + 'expensive', + 'fancy', + 'veste', + 'stud' + ]), + Emoji( + name: 'person in tuxedo: light skin tone', + char: '\u{1F935}\u{1F3FB}', + shortName: 'person_in_tuxedo_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'light skin tone', + 'person', + 'tuxedo', + 'uc9', + 'diversity', + 'wedding', + 'men', + 'boys night', + 'donald trump', + 'fame', + 'vampire', + 'las vegas', + 'rich', + 'jacket', + 'handsome', + 'costume', + 'husband', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'weddings', + 'marriage', + 'newlywed', + 'bride', + 'groome', + 'groom', + 'married', + 'marry', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'guys night', + 'trump', + 'famous', + 'celebrity', + 'dracula', + 'vegas', + 'grand', + 'expensive', + 'fancy', + 'veste', + 'stud' + ], + modifiable: true), + Emoji( + name: 'person in tuxedo: medium-light skin tone', + char: '\u{1F935}\u{1F3FC}', + shortName: 'person_in_tuxedo_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'person', + 'medium-light skin tone', + 'tuxedo', + 'uc9', + 'diversity', + 'wedding', + 'men', + 'boys night', + 'donald trump', + 'fame', + 'vampire', + 'las vegas', + 'rich', + 'jacket', + 'handsome', + 'costume', + 'husband', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'weddings', + 'marriage', + 'newlywed', + 'bride', + 'groome', + 'groom', + 'married', + 'marry', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'guys night', + 'trump', + 'famous', + 'celebrity', + 'dracula', + 'vegas', + 'grand', + 'expensive', + 'fancy', + 'veste', + 'stud' + ], + modifiable: true), + Emoji( + name: 'person in tuxedo: medium skin tone', + char: '\u{1F935}\u{1F3FD}', + shortName: 'person_in_tuxedo_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'person', + 'medium skin tone', + 'tuxedo', + 'uc9', + 'diversity', + 'wedding', + 'men', + 'boys night', + 'donald trump', + 'fame', + 'vampire', + 'las vegas', + 'rich', + 'jacket', + 'handsome', + 'costume', + 'husband', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'weddings', + 'marriage', + 'newlywed', + 'bride', + 'groome', + 'groom', + 'married', + 'marry', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'guys night', + 'trump', + 'famous', + 'celebrity', + 'dracula', + 'vegas', + 'grand', + 'expensive', + 'fancy', + 'veste', + 'stud' + ], + modifiable: true), + Emoji( + name: 'person in tuxedo: medium-dark skin tone', + char: '\u{1F935}\u{1F3FE}', + shortName: 'person_in_tuxedo_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'person', + 'medium-dark skin tone', + 'tuxedo', + 'uc9', + 'diversity', + 'wedding', + 'men', + 'boys night', + 'donald trump', + 'fame', + 'vampire', + 'las vegas', + 'rich', + 'jacket', + 'handsome', + 'costume', + 'husband', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'weddings', + 'marriage', + 'newlywed', + 'bride', + 'groome', + 'groom', + 'married', + 'marry', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'guys night', + 'trump', + 'famous', + 'celebrity', + 'dracula', + 'vegas', + 'grand', + 'expensive', + 'fancy', + 'veste', + 'stud' + ], + modifiable: true), + Emoji( + name: 'person in tuxedo: dark skin tone', + char: '\u{1F935}\u{1F3FF}', + shortName: 'person_in_tuxedo_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'dark skin tone', + 'person', + 'tuxedo', + 'uc9', + 'diversity', + 'wedding', + 'men', + 'boys night', + 'donald trump', + 'fame', + 'vampire', + 'las vegas', + 'rich', + 'jacket', + 'handsome', + 'costume', + 'husband', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'weddings', + 'marriage', + 'newlywed', + 'bride', + 'groome', + 'groom', + 'married', + 'marry', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'guys night', + 'trump', + 'famous', + 'celebrity', + 'dracula', + 'vegas', + 'grand', + 'expensive', + 'fancy', + 'veste', + 'stud' + ], + modifiable: true), + Emoji( + name: 'woman in tuxedo', + char: '\u{1F935}\u{200D}\u{2640}\u{FE0F}', + shortName: 'woman_in_tuxedo', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'uc13', + 'wedding', + 'fame', + 'weddings', + 'marriage', + 'newlywed', + 'bride', + 'groome', + 'groom', + 'married', + 'marry', + 'famous', + 'celebrity' + ]), + Emoji( + name: 'woman in tuxedo: light skin tone', + char: '\u{1F935}\u{1F3FB}\u{200D}\u{2640}\u{FE0F}', + shortName: 'woman_in_tuxedo_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'uc13', + 'wedding', + 'fame', + 'weddings', + 'marriage', + 'newlywed', + 'bride', + 'groome', + 'groom', + 'married', + 'marry', + 'famous', + 'celebrity' + ], + modifiable: true), + Emoji( + name: 'woman in tuxedo: medium-light skin tone', + char: '\u{1F935}\u{1F3FC}\u{200D}\u{2640}\u{FE0F}', + shortName: 'woman_in_tuxedo_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'uc13', + 'wedding', + 'fame', + 'weddings', + 'marriage', + 'newlywed', + 'bride', + 'groome', + 'groom', + 'married', + 'marry', + 'famous', + 'celebrity' + ], + modifiable: true), + Emoji( + name: 'woman in tuxedo: medium skin tone', + char: '\u{1F935}\u{1F3FD}\u{200D}\u{2640}\u{FE0F}', + shortName: 'woman_in_tuxedo_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'uc13', + 'wedding', + 'fame', + 'weddings', + 'marriage', + 'newlywed', + 'bride', + 'groome', + 'groom', + 'married', + 'marry', + 'famous', + 'celebrity' + ], + modifiable: true), + Emoji( + name: 'woman in tuxedo: medium-dark skin tone', + char: '\u{1F935}\u{1F3FE}\u{200D}\u{2640}\u{FE0F}', + shortName: 'woman_in_tuxedo_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'uc13', + 'wedding', + 'fame', + 'weddings', + 'marriage', + 'newlywed', + 'bride', + 'groome', + 'groom', + 'married', + 'marry', + 'famous', + 'celebrity' + ], + modifiable: true), + Emoji( + name: 'woman in tuxedo: dark skin tone', + char: '\u{1F935}\u{1F3FF}\u{200D}\u{2640}\u{FE0F}', + shortName: 'woman_in_tuxedo_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'uc13', + 'wedding', + 'fame', + 'weddings', + 'marriage', + 'newlywed', + 'bride', + 'groome', + 'groom', + 'married', + 'marry', + 'famous', + 'celebrity' + ], + modifiable: true), + Emoji( + name: 'man in tuxedo', + char: '\u{1F935}\u{200D}\u{2642}\u{FE0F}', + shortName: 'man_in_tuxedo', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'uc13', + 'wedding', + 'men', + 'boys night', + 'fame', + 'jacket', + 'handsome', + 'costume', + 'husband', + 'weddings', + 'marriage', + 'newlywed', + 'bride', + 'groome', + 'groom', + 'married', + 'marry', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'guys night', + 'famous', + 'celebrity', + 'veste', + 'stud' + ]), + Emoji( + name: 'man in tuxedo: light skin tone', + char: '\u{1F935}\u{1F3FB}\u{200D}\u{2642}\u{FE0F}', + shortName: 'man_in_tuxedo_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'uc13', + 'wedding', + 'men', + 'boys night', + 'fame', + 'jacket', + 'handsome', + 'costume', + 'husband', + 'weddings', + 'marriage', + 'newlywed', + 'bride', + 'groome', + 'groom', + 'married', + 'marry', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'guys night', + 'famous', + 'celebrity', + 'veste', + 'stud' + ], + modifiable: true), + Emoji( + name: 'man in tuxedo: medium-light skin tone', + char: '\u{1F935}\u{1F3FC}\u{200D}\u{2642}\u{FE0F}', + shortName: 'man_in_tuxedo_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'uc13', + 'wedding', + 'men', + 'boys night', + 'fame', + 'jacket', + 'handsome', + 'costume', + 'husband', + 'weddings', + 'marriage', + 'newlywed', + 'bride', + 'groome', + 'groom', + 'married', + 'marry', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'guys night', + 'famous', + 'celebrity', + 'veste', + 'stud' + ], + modifiable: true), + Emoji( + name: 'man in tuxedo: medium skin tone', + char: '\u{1F935}\u{1F3FD}\u{200D}\u{2642}\u{FE0F}', + shortName: 'man_in_tuxedo_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'uc13', + 'wedding', + 'men', + 'boys night', + 'fame', + 'jacket', + 'handsome', + 'costume', + 'husband', + 'weddings', + 'marriage', + 'newlywed', + 'bride', + 'groome', + 'groom', + 'married', + 'marry', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'guys night', + 'famous', + 'celebrity', + 'veste', + 'stud' + ], + modifiable: true), + Emoji( + name: 'man in tuxedo: medium-dark skin tone', + char: '\u{1F935}\u{1F3FE}\u{200D}\u{2642}\u{FE0F}', + shortName: 'man_in_tuxedo_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'uc13', + 'wedding', + 'men', + 'boys night', + 'fame', + 'jacket', + 'handsome', + 'costume', + 'husband', + 'weddings', + 'marriage', + 'newlywed', + 'bride', + 'groome', + 'groom', + 'married', + 'marry', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'guys night', + 'famous', + 'celebrity', + 'veste', + 'stud' + ], + modifiable: true), + Emoji( + name: 'man in tuxedo: dark skin tone', + char: '\u{1F935}\u{1F3FF}\u{200D}\u{2642}\u{FE0F}', + shortName: 'man_in_tuxedo_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'uc13', + 'wedding', + 'men', + 'boys night', + 'fame', + 'jacket', + 'handsome', + 'costume', + 'husband', + 'weddings', + 'marriage', + 'newlywed', + 'bride', + 'groome', + 'groom', + 'married', + 'marry', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'guys night', + 'famous', + 'celebrity', + 'veste', + 'stud' + ], + modifiable: true), + Emoji( + name: 'princess', + char: '\u{1F478}', + shortName: 'princess', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'fairy tale', + 'fantasy', + 'uc6', + 'diversity', + 'wedding', + 'women', + 'halloween', + 'beautiful', + 'girls night', + 'power', + 'queen', + 'disney', + 'bling', + 'fame', + 'crown', + 'rich', + 'dress', + 'disguise', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'weddings', + 'marriage', + 'newlywed', + 'bride', + 'groome', + 'groom', + 'married', + 'marry', + 'woman', + 'female', + 'samhain', + 'cute', + 'pretty', + 'adorable', + 'adore', + 'beauty', + 'cutie', + 'babe', + 'lovely', + 'ladies night', + 'girls only', + 'girlfriend', + 'king', + 'prince', + 'princess', + 'cartoon', + 'jewels', + 'gems', + 'jewel', + 'jewelry', + 'treasure', + 'famous', + 'celebrity', + 'tiara', + 'grand', + 'expensive', + 'fancy' + ]), + Emoji( + name: 'princess: light skin tone', + char: '\u{1F478}\u{1F3FB}', + shortName: 'princess_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'fairy tale', + 'fantasy', + 'light skin tone', + 'uc8', + 'diversity', + 'wedding', + 'women', + 'halloween', + 'beautiful', + 'girls night', + 'power', + 'queen', + 'disney', + 'bling', + 'fame', + 'crown', + 'rich', + 'dress', + 'disguise', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'weddings', + 'marriage', + 'newlywed', + 'bride', + 'groome', + 'groom', + 'married', + 'marry', + 'woman', + 'female', + 'samhain', + 'cute', + 'pretty', + 'adorable', + 'adore', + 'beauty', + 'cutie', + 'babe', + 'lovely', + 'ladies night', + 'girls only', + 'girlfriend', + 'king', + 'prince', + 'princess', + 'cartoon', + 'jewels', + 'gems', + 'jewel', + 'jewelry', + 'treasure', + 'famous', + 'celebrity', + 'tiara', + 'grand', + 'expensive', + 'fancy' + ], + modifiable: true), + Emoji( + name: 'princess: medium-light skin tone', + char: '\u{1F478}\u{1F3FC}', + shortName: 'princess_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'fairy tale', + 'fantasy', + 'medium-light skin tone', + 'uc8', + 'diversity', + 'wedding', + 'women', + 'halloween', + 'beautiful', + 'girls night', + 'power', + 'queen', + 'disney', + 'bling', + 'fame', + 'crown', + 'rich', + 'dress', + 'disguise', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'weddings', + 'marriage', + 'newlywed', + 'bride', + 'groome', + 'groom', + 'married', + 'marry', + 'woman', + 'female', + 'samhain', + 'cute', + 'pretty', + 'adorable', + 'adore', + 'beauty', + 'cutie', + 'babe', + 'lovely', + 'ladies night', + 'girls only', + 'girlfriend', + 'king', + 'prince', + 'princess', + 'cartoon', + 'jewels', + 'gems', + 'jewel', + 'jewelry', + 'treasure', + 'famous', + 'celebrity', + 'tiara', + 'grand', + 'expensive', + 'fancy' + ], + modifiable: true), + Emoji( + name: 'princess: medium skin tone', + char: '\u{1F478}\u{1F3FD}', + shortName: 'princess_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'fairy tale', + 'fantasy', + 'medium skin tone', + 'uc8', + 'diversity', + 'wedding', + 'women', + 'halloween', + 'beautiful', + 'girls night', + 'power', + 'queen', + 'disney', + 'bling', + 'fame', + 'crown', + 'rich', + 'dress', + 'disguise', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'weddings', + 'marriage', + 'newlywed', + 'bride', + 'groome', + 'groom', + 'married', + 'marry', + 'woman', + 'female', + 'samhain', + 'cute', + 'pretty', + 'adorable', + 'adore', + 'beauty', + 'cutie', + 'babe', + 'lovely', + 'ladies night', + 'girls only', + 'girlfriend', + 'king', + 'prince', + 'princess', + 'cartoon', + 'jewels', + 'gems', + 'jewel', + 'jewelry', + 'treasure', + 'famous', + 'celebrity', + 'tiara', + 'grand', + 'expensive', + 'fancy' + ], + modifiable: true), + Emoji( + name: 'princess: medium-dark skin tone', + char: '\u{1F478}\u{1F3FE}', + shortName: 'princess_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'fairy tale', + 'fantasy', + 'medium-dark skin tone', + 'uc8', + 'diversity', + 'wedding', + 'women', + 'halloween', + 'beautiful', + 'girls night', + 'power', + 'queen', + 'disney', + 'bling', + 'fame', + 'crown', + 'rich', + 'dress', + 'disguise', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'weddings', + 'marriage', + 'newlywed', + 'bride', + 'groome', + 'groom', + 'married', + 'marry', + 'woman', + 'female', + 'samhain', + 'cute', + 'pretty', + 'adorable', + 'adore', + 'beauty', + 'cutie', + 'babe', + 'lovely', + 'ladies night', + 'girls only', + 'girlfriend', + 'king', + 'prince', + 'princess', + 'cartoon', + 'jewels', + 'gems', + 'jewel', + 'jewelry', + 'treasure', + 'famous', + 'celebrity', + 'tiara', + 'grand', + 'expensive', + 'fancy' + ], + modifiable: true), + Emoji( + name: 'princess: dark skin tone', + char: '\u{1F478}\u{1F3FF}', + shortName: 'princess_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'dark skin tone', + 'fairy tale', + 'fantasy', + 'uc8', + 'diversity', + 'wedding', + 'women', + 'halloween', + 'beautiful', + 'girls night', + 'power', + 'queen', + 'disney', + 'bling', + 'fame', + 'crown', + 'rich', + 'dress', + 'disguise', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'weddings', + 'marriage', + 'newlywed', + 'bride', + 'groome', + 'groom', + 'married', + 'marry', + 'woman', + 'female', + 'samhain', + 'cute', + 'pretty', + 'adorable', + 'adore', + 'beauty', + 'cutie', + 'babe', + 'lovely', + 'ladies night', + 'girls only', + 'girlfriend', + 'king', + 'prince', + 'princess', + 'cartoon', + 'jewels', + 'gems', + 'jewel', + 'jewelry', + 'treasure', + 'famous', + 'celebrity', + 'tiara', + 'grand', + 'expensive', + 'fancy' + ], + modifiable: true), + Emoji( + name: 'prince', + char: '\u{1F934}', + shortName: 'prince', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'prince', + 'uc9', + 'diversity', + 'men', + 'power', + 'queen', + 'disney', + 'bling', + 'fame', + 'crown', + 'rich', + 'handsome', + 'disguise', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'king', + 'prince', + 'princess', + 'cartoon', + 'jewels', + 'gems', + 'jewel', + 'jewelry', + 'treasure', + 'famous', + 'celebrity', + 'tiara', + 'grand', + 'expensive', + 'fancy', + 'stud' + ]), + Emoji( + name: 'prince: light skin tone', + char: '\u{1F934}\u{1F3FB}', + shortName: 'prince_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'light skin tone', + 'prince', + 'uc9', + 'diversity', + 'men', + 'power', + 'queen', + 'disney', + 'bling', + 'fame', + 'crown', + 'rich', + 'handsome', + 'disguise', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'king', + 'prince', + 'princess', + 'cartoon', + 'jewels', + 'gems', + 'jewel', + 'jewelry', + 'treasure', + 'famous', + 'celebrity', + 'tiara', + 'grand', + 'expensive', + 'fancy', + 'stud' + ], + modifiable: true), + Emoji( + name: 'prince: medium-light skin tone', + char: '\u{1F934}\u{1F3FC}', + shortName: 'prince_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'medium-light skin tone', + 'prince', + 'uc9', + 'diversity', + 'men', + 'power', + 'queen', + 'disney', + 'bling', + 'fame', + 'crown', + 'rich', + 'handsome', + 'disguise', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'king', + 'prince', + 'princess', + 'cartoon', + 'jewels', + 'gems', + 'jewel', + 'jewelry', + 'treasure', + 'famous', + 'celebrity', + 'tiara', + 'grand', + 'expensive', + 'fancy', + 'stud' + ], + modifiable: true), + Emoji( + name: 'prince: medium skin tone', + char: '\u{1F934}\u{1F3FD}', + shortName: 'prince_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'medium skin tone', + 'prince', + 'uc9', + 'diversity', + 'men', + 'power', + 'queen', + 'disney', + 'bling', + 'fame', + 'crown', + 'rich', + 'handsome', + 'disguise', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'king', + 'prince', + 'princess', + 'cartoon', + 'jewels', + 'gems', + 'jewel', + 'jewelry', + 'treasure', + 'famous', + 'celebrity', + 'tiara', + 'grand', + 'expensive', + 'fancy', + 'stud' + ], + modifiable: true), + Emoji( + name: 'prince: medium-dark skin tone', + char: '\u{1F934}\u{1F3FE}', + shortName: 'prince_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'medium-dark skin tone', + 'prince', + 'uc9', + 'diversity', + 'men', + 'power', + 'queen', + 'disney', + 'bling', + 'fame', + 'crown', + 'rich', + 'handsome', + 'disguise', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'king', + 'prince', + 'princess', + 'cartoon', + 'jewels', + 'gems', + 'jewel', + 'jewelry', + 'treasure', + 'famous', + 'celebrity', + 'tiara', + 'grand', + 'expensive', + 'fancy', + 'stud' + ], + modifiable: true), + Emoji( + name: 'prince: dark skin tone', + char: '\u{1F934}\u{1F3FF}', + shortName: 'prince_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'dark skin tone', + 'prince', + 'uc9', + 'diversity', + 'men', + 'power', + 'queen', + 'disney', + 'bling', + 'fame', + 'crown', + 'rich', + 'handsome', + 'disguise', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'king', + 'prince', + 'princess', + 'cartoon', + 'jewels', + 'gems', + 'jewel', + 'jewelry', + 'treasure', + 'famous', + 'celebrity', + 'tiara', + 'grand', + 'expensive', + 'fancy', + 'stud' + ], + modifiable: true), + Emoji( + name: 'superhero', + char: '\u{1F9B8}', + shortName: 'superhero', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personFantasy, + keywords: [ + 'uc11', + 'diversity', + 'peace', + 'halloween', + 'men', + '911', + 'power', + 'daddy', + 'fame', + 'help', + 'super hero', + 'costume', + 'mask', + 'fantasy', + 'proud', + 'disguise', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'peace out', + 'peace sign', + 'samhain', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'emergency', + 'injury', + 'dad', + 'papa', + 'pere', + 'father', + 'famous', + 'celebrity', + 'superhero', + 'superman', + 'batman' + ]), + Emoji( + name: 'superhero: light skin tone', + char: '\u{1F9B8}\u{1F3FB}', + shortName: 'superhero_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personFantasy, + keywords: [ + 'uc11', + 'diversity', + 'peace', + 'halloween', + 'men', + '911', + 'power', + 'daddy', + 'fame', + 'help', + 'super hero', + 'costume', + 'mask', + 'fantasy', + 'proud', + 'disguise', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'peace out', + 'peace sign', + 'samhain', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'emergency', + 'injury', + 'dad', + 'papa', + 'pere', + 'father', + 'famous', + 'celebrity', + 'superhero', + 'superman', + 'batman' + ], + modifiable: true), + Emoji( + name: 'superhero: medium-light skin tone', + char: '\u{1F9B8}\u{1F3FC}', + shortName: 'superhero_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personFantasy, + keywords: [ + 'uc11', + 'diversity', + 'peace', + 'halloween', + 'men', + '911', + 'power', + 'daddy', + 'fame', + 'help', + 'super hero', + 'costume', + 'mask', + 'fantasy', + 'proud', + 'disguise', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'peace out', + 'peace sign', + 'samhain', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'emergency', + 'injury', + 'dad', + 'papa', + 'pere', + 'father', + 'famous', + 'celebrity', + 'superhero', + 'superman', + 'batman' + ], + modifiable: true), + Emoji( + name: 'superhero: medium skin tone', + char: '\u{1F9B8}\u{1F3FD}', + shortName: 'superhero_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personFantasy, + keywords: [ + 'uc11', + 'diversity', + 'peace', + 'halloween', + 'men', + '911', + 'power', + 'daddy', + 'fame', + 'help', + 'super hero', + 'costume', + 'mask', + 'fantasy', + 'proud', + 'disguise', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'peace out', + 'peace sign', + 'samhain', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'emergency', + 'injury', + 'dad', + 'papa', + 'pere', + 'father', + 'famous', + 'celebrity', + 'superhero', + 'superman', + 'batman' + ], + modifiable: true), + Emoji( + name: 'superhero: medium-dark skin tone', + char: '\u{1F9B8}\u{1F3FE}', + shortName: 'superhero_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personFantasy, + keywords: [ + 'uc11', + 'diversity', + 'peace', + 'halloween', + 'men', + '911', + 'power', + 'daddy', + 'fame', + 'help', + 'super hero', + 'costume', + 'mask', + 'fantasy', + 'proud', + 'disguise', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'peace out', + 'peace sign', + 'samhain', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'emergency', + 'injury', + 'dad', + 'papa', + 'pere', + 'father', + 'famous', + 'celebrity', + 'superhero', + 'superman', + 'batman' + ], + modifiable: true), + Emoji( + name: 'superhero: dark skin tone', + char: '\u{1F9B8}\u{1F3FF}', + shortName: 'superhero_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personFantasy, + keywords: [ + 'uc11', + 'diversity', + 'peace', + 'halloween', + 'men', + '911', + 'power', + 'daddy', + 'fame', + 'help', + 'super hero', + 'costume', + 'mask', + 'fantasy', + 'proud', + 'disguise', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'peace out', + 'peace sign', + 'samhain', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'emergency', + 'injury', + 'dad', + 'papa', + 'pere', + 'father', + 'famous', + 'celebrity', + 'superhero', + 'superman', + 'batman' + ], + modifiable: true), + Emoji( + name: 'woman superhero', + char: '\u{1F9B8}\u{200D}\u{2640}\u{FE0F}', + shortName: 'woman_superhero', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personFantasy, + keywords: [ + 'uc11', + 'diversity', + 'peace', + 'halloween', + '911', + 'power', + 'fame', + 'help', + 'super hero', + 'costume', + 'mask', + 'fantasy', + 'mom', + 'proud', + 'disguise', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'peace out', + 'peace sign', + 'samhain', + 'emergency', + 'injury', + 'famous', + 'celebrity', + 'superhero', + 'superman', + 'batman', + 'maman', + 'mommy', + 'mama', + 'mother' + ]), + Emoji( + name: 'woman superhero: light skin tone', + char: '\u{1F9B8}\u{1F3FB}\u{200D}\u{2640}\u{FE0F}', + shortName: 'woman_superhero_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personFantasy, + keywords: [ + 'uc11', + 'diversity', + 'peace', + 'halloween', + '911', + 'power', + 'fame', + 'help', + 'super hero', + 'costume', + 'mask', + 'fantasy', + 'mom', + 'proud', + 'disguise', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'peace out', + 'peace sign', + 'samhain', + 'emergency', + 'injury', + 'famous', + 'celebrity', + 'superhero', + 'superman', + 'batman', + 'maman', + 'mommy', + 'mama', + 'mother' + ], + modifiable: true), + Emoji( + name: 'woman superhero: medium-light skin tone', + char: '\u{1F9B8}\u{1F3FC}\u{200D}\u{2640}\u{FE0F}', + shortName: 'woman_superhero_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personFantasy, + keywords: [ + 'uc11', + 'diversity', + 'peace', + 'halloween', + '911', + 'power', + 'fame', + 'help', + 'super hero', + 'costume', + 'mask', + 'fantasy', + 'mom', + 'proud', + 'disguise', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'peace out', + 'peace sign', + 'samhain', + 'emergency', + 'injury', + 'famous', + 'celebrity', + 'superhero', + 'superman', + 'batman', + 'maman', + 'mommy', + 'mama', + 'mother' + ], + modifiable: true), + Emoji( + name: 'woman superhero: medium skin tone', + char: '\u{1F9B8}\u{1F3FD}\u{200D}\u{2640}\u{FE0F}', + shortName: 'woman_superhero_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personFantasy, + keywords: [ + 'uc11', + 'diversity', + 'peace', + 'halloween', + '911', + 'power', + 'fame', + 'help', + 'super hero', + 'costume', + 'mask', + 'fantasy', + 'mom', + 'proud', + 'disguise', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'peace out', + 'peace sign', + 'samhain', + 'emergency', + 'injury', + 'famous', + 'celebrity', + 'superhero', + 'superman', + 'batman', + 'maman', + 'mommy', + 'mama', + 'mother' + ], + modifiable: true), + Emoji( + name: 'woman superhero: medium-dark skin tone', + char: '\u{1F9B8}\u{1F3FE}\u{200D}\u{2640}\u{FE0F}', + shortName: 'woman_superhero_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personFantasy, + keywords: [ + 'uc11', + 'diversity', + 'peace', + 'halloween', + '911', + 'power', + 'fame', + 'help', + 'super hero', + 'costume', + 'mask', + 'fantasy', + 'mom', + 'proud', + 'disguise', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'peace out', + 'peace sign', + 'samhain', + 'emergency', + 'injury', + 'famous', + 'celebrity', + 'superhero', + 'superman', + 'batman', + 'maman', + 'mommy', + 'mama', + 'mother' + ], + modifiable: true), + Emoji( + name: 'woman superhero: dark skin tone', + char: '\u{1F9B8}\u{1F3FF}\u{200D}\u{2640}\u{FE0F}', + shortName: 'woman_superhero_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personFantasy, + keywords: [ + 'uc11', + 'diversity', + 'peace', + 'halloween', + '911', + 'power', + 'fame', + 'help', + 'super hero', + 'costume', + 'mask', + 'fantasy', + 'mom', + 'proud', + 'disguise', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'peace out', + 'peace sign', + 'samhain', + 'emergency', + 'injury', + 'famous', + 'celebrity', + 'superhero', + 'superman', + 'batman', + 'maman', + 'mommy', + 'mama', + 'mother' + ], + modifiable: true), + Emoji( + name: 'man superhero', + char: '\u{1F9B8}\u{200D}\u{2642}\u{FE0F}', + shortName: 'man_superhero', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personFantasy, + keywords: [ + 'uc11', + 'diversity', + 'peace', + 'halloween', + 'men', + '911', + 'power', + 'daddy', + 'fame', + 'help', + 'super hero', + 'costume', + 'mask', + 'fantasy', + 'proud', + 'disguise', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'peace out', + 'peace sign', + 'samhain', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'emergency', + 'injury', + 'dad', + 'papa', + 'pere', + 'father', + 'famous', + 'celebrity', + 'superhero', + 'superman', + 'batman' + ]), + Emoji( + name: 'man superhero: light skin tone', + char: '\u{1F9B8}\u{1F3FB}\u{200D}\u{2642}\u{FE0F}', + shortName: 'man_superhero_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personFantasy, + keywords: [ + 'uc11', + 'diversity', + 'peace', + 'halloween', + 'men', + '911', + 'power', + 'daddy', + 'fame', + 'help', + 'super hero', + 'costume', + 'mask', + 'fantasy', + 'proud', + 'disguise', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'peace out', + 'peace sign', + 'samhain', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'emergency', + 'injury', + 'dad', + 'papa', + 'pere', + 'father', + 'famous', + 'celebrity', + 'superhero', + 'superman', + 'batman' + ], + modifiable: true), + Emoji( + name: 'man superhero: medium-light skin tone', + char: '\u{1F9B8}\u{1F3FC}\u{200D}\u{2642}\u{FE0F}', + shortName: 'man_superhero_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personFantasy, + keywords: [ + 'uc11', + 'diversity', + 'peace', + 'halloween', + 'men', + '911', + 'power', + 'daddy', + 'fame', + 'help', + 'super hero', + 'costume', + 'mask', + 'fantasy', + 'proud', + 'disguise', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'peace out', + 'peace sign', + 'samhain', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'emergency', + 'injury', + 'dad', + 'papa', + 'pere', + 'father', + 'famous', + 'celebrity', + 'superhero', + 'superman', + 'batman' + ], + modifiable: true), + Emoji( + name: 'man superhero: medium skin tone', + char: '\u{1F9B8}\u{1F3FD}\u{200D}\u{2642}\u{FE0F}', + shortName: 'man_superhero_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personFantasy, + keywords: [ + 'uc11', + 'diversity', + 'peace', + 'halloween', + 'men', + '911', + 'power', + 'daddy', + 'fame', + 'help', + 'super hero', + 'costume', + 'mask', + 'fantasy', + 'proud', + 'disguise', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'peace out', + 'peace sign', + 'samhain', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'emergency', + 'injury', + 'dad', + 'papa', + 'pere', + 'father', + 'famous', + 'celebrity', + 'superhero', + 'superman', + 'batman' + ], + modifiable: true), + Emoji( + name: 'man superhero: medium-dark skin tone', + char: '\u{1F9B8}\u{1F3FE}\u{200D}\u{2642}\u{FE0F}', + shortName: 'man_superhero_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personFantasy, + keywords: [ + 'uc11', + 'diversity', + 'peace', + 'halloween', + 'men', + '911', + 'power', + 'daddy', + 'fame', + 'help', + 'super hero', + 'costume', + 'mask', + 'fantasy', + 'proud', + 'disguise', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'peace out', + 'peace sign', + 'samhain', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'emergency', + 'injury', + 'dad', + 'papa', + 'pere', + 'father', + 'famous', + 'celebrity', + 'superhero', + 'superman', + 'batman' + ], + modifiable: true), + Emoji( + name: 'man superhero: dark skin tone', + char: '\u{1F9B8}\u{1F3FF}\u{200D}\u{2642}\u{FE0F}', + shortName: 'man_superhero_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personFantasy, + keywords: [ + 'uc11', + 'diversity', + 'peace', + 'halloween', + 'men', + '911', + 'power', + 'daddy', + 'fame', + 'help', + 'super hero', + 'costume', + 'mask', + 'fantasy', + 'proud', + 'disguise', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'peace out', + 'peace sign', + 'samhain', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'emergency', + 'injury', + 'dad', + 'papa', + 'pere', + 'father', + 'famous', + 'celebrity', + 'superhero', + 'superman', + 'batman' + ], + modifiable: true), + Emoji( + name: 'supervillain', + char: '\u{1F9B9}', + shortName: 'supervillain', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personFantasy, + keywords: [ + 'uc11', + 'diversity', + 'halloween', + 'men', + 'power', + 'evil', + 'fame', + 'guilty', + 'super hero', + 'killer', + 'costume', + 'mask', + 'fantasy', + 'proud', + 'greed', + 'disguise', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'samhain', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'imp', + 'demon', + 'devil', + 'naughty', + 'devilish', + 'diablo', + 'diable', + 'satan', + 'famous', + 'celebrity', + 'superhero', + 'superman', + 'batman', + 'savage', + 'scary clown', + 'selfish' + ]), + Emoji( + name: 'supervillain: light skin tone', + char: '\u{1F9B9}\u{1F3FB}', + shortName: 'supervillain_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personFantasy, + keywords: [ + 'uc11', + 'diversity', + 'halloween', + 'men', + 'power', + 'evil', + 'fame', + 'guilty', + 'super hero', + 'killer', + 'costume', + 'mask', + 'fantasy', + 'proud', + 'greed', + 'disguise', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'samhain', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'imp', + 'demon', + 'devil', + 'naughty', + 'devilish', + 'diablo', + 'diable', + 'satan', + 'famous', + 'celebrity', + 'superhero', + 'superman', + 'batman', + 'savage', + 'scary clown', + 'selfish' + ], + modifiable: true), + Emoji( + name: 'supervillain: medium-light skin tone', + char: '\u{1F9B9}\u{1F3FC}', + shortName: 'supervillain_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personFantasy, + keywords: [ + 'uc11', + 'diversity', + 'halloween', + 'men', + 'power', + 'evil', + 'fame', + 'guilty', + 'super hero', + 'killer', + 'costume', + 'mask', + 'fantasy', + 'proud', + 'greed', + 'disguise', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'samhain', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'imp', + 'demon', + 'devil', + 'naughty', + 'devilish', + 'diablo', + 'diable', + 'satan', + 'famous', + 'celebrity', + 'superhero', + 'superman', + 'batman', + 'savage', + 'scary clown', + 'selfish' + ], + modifiable: true), + Emoji( + name: 'supervillain: medium skin tone', + char: '\u{1F9B9}\u{1F3FD}', + shortName: 'supervillain_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personFantasy, + keywords: [ + 'uc11', + 'diversity', + 'halloween', + 'men', + 'power', + 'evil', + 'fame', + 'guilty', + 'super hero', + 'killer', + 'costume', + 'mask', + 'fantasy', + 'proud', + 'greed', + 'disguise', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'samhain', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'imp', + 'demon', + 'devil', + 'naughty', + 'devilish', + 'diablo', + 'diable', + 'satan', + 'famous', + 'celebrity', + 'superhero', + 'superman', + 'batman', + 'savage', + 'scary clown', + 'selfish' + ], + modifiable: true), + Emoji( + name: 'supervillain: medium-dark skin tone', + char: '\u{1F9B9}\u{1F3FE}', + shortName: 'supervillain_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personFantasy, + keywords: [ + 'uc11', + 'diversity', + 'halloween', + 'men', + 'power', + 'evil', + 'fame', + 'guilty', + 'super hero', + 'killer', + 'costume', + 'mask', + 'fantasy', + 'proud', + 'greed', + 'disguise', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'samhain', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'imp', + 'demon', + 'devil', + 'naughty', + 'devilish', + 'diablo', + 'diable', + 'satan', + 'famous', + 'celebrity', + 'superhero', + 'superman', + 'batman', + 'savage', + 'scary clown', + 'selfish' + ], + modifiable: true), + Emoji( + name: 'supervillain: dark skin tone', + char: '\u{1F9B9}\u{1F3FF}', + shortName: 'supervillain_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personFantasy, + keywords: [ + 'uc11', + 'diversity', + 'halloween', + 'men', + 'power', + 'evil', + 'fame', + 'guilty', + 'super hero', + 'killer', + 'costume', + 'mask', + 'fantasy', + 'proud', + 'greed', + 'disguise', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'samhain', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'imp', + 'demon', + 'devil', + 'naughty', + 'devilish', + 'diablo', + 'diable', + 'satan', + 'famous', + 'celebrity', + 'superhero', + 'superman', + 'batman', + 'savage', + 'scary clown', + 'selfish' + ], + modifiable: true), + Emoji( + name: 'woman supervillain: light skin tone', + char: '\u{1F9B9}\u{1F3FB}\u{200D}\u{2640}\u{FE0F}', + shortName: 'woman_supervillain_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personFantasy, + keywords: [ + 'uc11', + 'diversity', + 'halloween', + 'power', + 'evil', + 'fame', + 'guilty', + 'super hero', + 'killer', + 'costume', + 'mask', + 'fantasy', + 'proud', + 'greed', + 'disguise', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'samhain', + 'imp', + 'demon', + 'devil', + 'naughty', + 'devilish', + 'diablo', + 'diable', + 'satan', + 'famous', + 'celebrity', + 'superhero', + 'superman', + 'batman', + 'savage', + 'scary clown', + 'selfish' + ], + modifiable: true), + Emoji( + name: 'woman supervillain', + char: '\u{1F9B9}\u{200D}\u{2640}\u{FE0F}', + shortName: 'woman_supervillain', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personFantasy, + keywords: [ + 'uc11', + 'diversity', + 'halloween', + 'power', + 'evil', + 'fame', + 'guilty', + 'super hero', + 'killer', + 'costume', + 'mask', + 'fantasy', + 'proud', + 'greed', + 'disguise', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'samhain', + 'imp', + 'demon', + 'devil', + 'naughty', + 'devilish', + 'diablo', + 'diable', + 'satan', + 'famous', + 'celebrity', + 'superhero', + 'superman', + 'batman', + 'savage', + 'scary clown', + 'selfish' + ]), + Emoji( + name: 'woman supervillain: medium-light skin tone', + char: '\u{1F9B9}\u{1F3FC}\u{200D}\u{2640}\u{FE0F}', + shortName: 'woman_supervillain_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personFantasy, + keywords: [ + 'uc11', + 'diversity', + 'halloween', + 'power', + 'evil', + 'fame', + 'guilty', + 'super hero', + 'killer', + 'costume', + 'mask', + 'fantasy', + 'proud', + 'greed', + 'disguise', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'samhain', + 'imp', + 'demon', + 'devil', + 'naughty', + 'devilish', + 'diablo', + 'diable', + 'satan', + 'famous', + 'celebrity', + 'superhero', + 'superman', + 'batman', + 'savage', + 'scary clown', + 'selfish' + ], + modifiable: true), + Emoji( + name: 'woman supervillain: medium skin tone', + char: '\u{1F9B9}\u{1F3FD}\u{200D}\u{2640}\u{FE0F}', + shortName: 'woman_supervillain_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personFantasy, + keywords: [ + 'uc11', + 'diversity', + 'halloween', + 'power', + 'evil', + 'fame', + 'guilty', + 'super hero', + 'killer', + 'costume', + 'mask', + 'fantasy', + 'proud', + 'greed', + 'disguise', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'samhain', + 'imp', + 'demon', + 'devil', + 'naughty', + 'devilish', + 'diablo', + 'diable', + 'satan', + 'famous', + 'celebrity', + 'superhero', + 'superman', + 'batman', + 'savage', + 'scary clown', + 'selfish' + ], + modifiable: true), + Emoji( + name: 'woman supervillain: medium-dark skin tone', + char: '\u{1F9B9}\u{1F3FE}\u{200D}\u{2640}\u{FE0F}', + shortName: 'woman_supervillain_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personFantasy, + keywords: [ + 'uc11', + 'diversity', + 'halloween', + 'power', + 'evil', + 'fame', + 'guilty', + 'super hero', + 'killer', + 'costume', + 'mask', + 'fantasy', + 'proud', + 'greed', + 'disguise', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'samhain', + 'imp', + 'demon', + 'devil', + 'naughty', + 'devilish', + 'diablo', + 'diable', + 'satan', + 'famous', + 'celebrity', + 'superhero', + 'superman', + 'batman', + 'savage', + 'scary clown', + 'selfish' + ], + modifiable: true), + Emoji( + name: 'woman supervillain: dark skin tone', + char: '\u{1F9B9}\u{1F3FF}\u{200D}\u{2640}\u{FE0F}', + shortName: 'woman_supervillain_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personFantasy, + keywords: [ + 'uc11', + 'diversity', + 'halloween', + 'power', + 'evil', + 'fame', + 'guilty', + 'super hero', + 'killer', + 'costume', + 'mask', + 'fantasy', + 'proud', + 'greed', + 'disguise', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'samhain', + 'imp', + 'demon', + 'devil', + 'naughty', + 'devilish', + 'diablo', + 'diable', + 'satan', + 'famous', + 'celebrity', + 'superhero', + 'superman', + 'batman', + 'savage', + 'scary clown', + 'selfish' + ], + modifiable: true), + Emoji( + name: 'man supervillain', + char: '\u{1F9B9}\u{200D}\u{2642}\u{FE0F}', + shortName: 'man_supervillain', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personFantasy, + keywords: [ + 'uc11', + 'diversity', + 'halloween', + 'men', + 'power', + 'evil', + 'fame', + 'guilty', + 'super hero', + 'killer', + 'costume', + 'mask', + 'fantasy', + 'proud', + 'greed', + 'disguise', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'samhain', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'imp', + 'demon', + 'devil', + 'naughty', + 'devilish', + 'diablo', + 'diable', + 'satan', + 'famous', + 'celebrity', + 'superhero', + 'superman', + 'batman', + 'savage', + 'scary clown', + 'selfish' + ]), + Emoji( + name: 'man supervillain: light skin tone', + char: '\u{1F9B9}\u{1F3FB}\u{200D}\u{2642}\u{FE0F}', + shortName: 'man_supervillain_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personFantasy, + keywords: [ + 'uc11', + 'diversity', + 'halloween', + 'men', + 'power', + 'evil', + 'fame', + 'guilty', + 'super hero', + 'killer', + 'costume', + 'mask', + 'fantasy', + 'proud', + 'greed', + 'disguise', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'samhain', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'imp', + 'demon', + 'devil', + 'naughty', + 'devilish', + 'diablo', + 'diable', + 'satan', + 'famous', + 'celebrity', + 'superhero', + 'superman', + 'batman', + 'savage', + 'scary clown', + 'selfish' + ], + modifiable: true), + Emoji( + name: 'man supervillain: medium-light skin tone', + char: '\u{1F9B9}\u{1F3FC}\u{200D}\u{2642}\u{FE0F}', + shortName: 'man_supervillain_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personFantasy, + keywords: [ + 'uc11', + 'diversity', + 'halloween', + 'men', + 'power', + 'evil', + 'fame', + 'guilty', + 'super hero', + 'killer', + 'costume', + 'mask', + 'fantasy', + 'proud', + 'greed', + 'disguise', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'samhain', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'imp', + 'demon', + 'devil', + 'naughty', + 'devilish', + 'diablo', + 'diable', + 'satan', + 'famous', + 'celebrity', + 'superhero', + 'superman', + 'batman', + 'savage', + 'scary clown', + 'selfish' + ], + modifiable: true), + Emoji( + name: 'man supervillain: medium skin tone', + char: '\u{1F9B9}\u{1F3FD}\u{200D}\u{2642}\u{FE0F}', + shortName: 'man_supervillain_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personFantasy, + keywords: [ + 'uc11', + 'diversity', + 'halloween', + 'men', + 'power', + 'evil', + 'fame', + 'guilty', + 'super hero', + 'killer', + 'costume', + 'mask', + 'fantasy', + 'proud', + 'greed', + 'disguise', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'samhain', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'imp', + 'demon', + 'devil', + 'naughty', + 'devilish', + 'diablo', + 'diable', + 'satan', + 'famous', + 'celebrity', + 'superhero', + 'superman', + 'batman', + 'savage', + 'scary clown', + 'selfish' + ], + modifiable: true), + Emoji( + name: 'man supervillain: medium-dark skin tone', + char: '\u{1F9B9}\u{1F3FE}\u{200D}\u{2642}\u{FE0F}', + shortName: 'man_supervillain_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personFantasy, + keywords: [ + 'uc11', + 'diversity', + 'halloween', + 'men', + 'power', + 'evil', + 'fame', + 'guilty', + 'super hero', + 'killer', + 'costume', + 'mask', + 'fantasy', + 'proud', + 'greed', + 'disguise', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'samhain', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'imp', + 'demon', + 'devil', + 'naughty', + 'devilish', + 'diablo', + 'diable', + 'satan', + 'famous', + 'celebrity', + 'superhero', + 'superman', + 'batman', + 'savage', + 'scary clown', + 'selfish' + ], + modifiable: true), + Emoji( + name: 'man supervillain: dark skin tone', + char: '\u{1F9B9}\u{1F3FF}\u{200D}\u{2642}\u{FE0F}', + shortName: 'man_supervillain_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personFantasy, + keywords: [ + 'uc11', + 'diversity', + 'halloween', + 'men', + 'power', + 'evil', + 'fame', + 'guilty', + 'super hero', + 'killer', + 'costume', + 'mask', + 'fantasy', + 'proud', + 'greed', + 'disguise', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'samhain', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'imp', + 'demon', + 'devil', + 'naughty', + 'devilish', + 'diablo', + 'diable', + 'satan', + 'famous', + 'celebrity', + 'superhero', + 'superman', + 'batman', + 'savage', + 'scary clown', + 'selfish' + ], + modifiable: true), + Emoji( + name: 'ninja', + char: '\u{1F977}', + shortName: 'ninja', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'uc13', + 'japan', + 'evil', + 'chinese', + 'super hero', + 'killer', + 'mask', + 'disguise', + 'shinobi', + 'japanese', + 'ninja', + 'imp', + 'demon', + 'devil', + 'naughty', + 'devilish', + 'diablo', + 'diable', + 'satan', + 'chinois', + 'asian', + 'chine', + 'superhero', + 'superman', + 'batman', + 'savage', + 'scary clown', + 'samurai' + ]), + Emoji( + name: 'ninja: light skin tone', + char: '\u{1F977}\u{1F3FB}', + shortName: 'ninja_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'uc13', + 'japan', + 'evil', + 'chinese', + 'super hero', + 'killer', + 'mask', + 'disguise', + 'shinobi', + 'japanese', + 'ninja', + 'imp', + 'demon', + 'devil', + 'naughty', + 'devilish', + 'diablo', + 'diable', + 'satan', + 'chinois', + 'asian', + 'chine', + 'superhero', + 'superman', + 'batman', + 'savage', + 'scary clown', + 'samurai' + ], + modifiable: true), + Emoji( + name: 'ninja: medium-light skin tone', + char: '\u{1F977}\u{1F3FC}', + shortName: 'ninja_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'uc13', + 'japan', + 'evil', + 'chinese', + 'super hero', + 'killer', + 'mask', + 'disguise', + 'shinobi', + 'japanese', + 'ninja', + 'imp', + 'demon', + 'devil', + 'naughty', + 'devilish', + 'diablo', + 'diable', + 'satan', + 'chinois', + 'asian', + 'chine', + 'superhero', + 'superman', + 'batman', + 'savage', + 'scary clown', + 'samurai' + ], + modifiable: true), + Emoji( + name: 'ninja: medium skin tone', + char: '\u{1F977}\u{1F3FD}', + shortName: 'ninja_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'uc13', + 'japan', + 'evil', + 'chinese', + 'super hero', + 'killer', + 'mask', + 'disguise', + 'shinobi', + 'japanese', + 'ninja', + 'imp', + 'demon', + 'devil', + 'naughty', + 'devilish', + 'diablo', + 'diable', + 'satan', + 'chinois', + 'asian', + 'chine', + 'superhero', + 'superman', + 'batman', + 'savage', + 'scary clown', + 'samurai' + ], + modifiable: true), + Emoji( + name: 'ninja: medium-dark skin tone', + char: '\u{1F977}\u{1F3FE}', + shortName: 'ninja_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'uc13', + 'japan', + 'evil', + 'chinese', + 'super hero', + 'killer', + 'mask', + 'disguise', + 'shinobi', + 'japanese', + 'ninja', + 'imp', + 'demon', + 'devil', + 'naughty', + 'devilish', + 'diablo', + 'diable', + 'satan', + 'chinois', + 'asian', + 'chine', + 'superhero', + 'superman', + 'batman', + 'savage', + 'scary clown', + 'samurai' + ], + modifiable: true), + Emoji( + name: 'ninja: dark skin tone', + char: '\u{1F977}\u{1F3FF}', + shortName: 'ninja_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'uc13', + 'japan', + 'evil', + 'chinese', + 'super hero', + 'killer', + 'mask', + 'disguise', + 'shinobi', + 'japanese', + 'ninja', + 'imp', + 'demon', + 'devil', + 'naughty', + 'devilish', + 'diablo', + 'diable', + 'satan', + 'chinois', + 'asian', + 'chine', + 'superhero', + 'superman', + 'batman', + 'savage', + 'scary clown', + 'samurai' + ], + modifiable: true), + Emoji( + name: 'mx claus', + char: '\u{1F9D1}\u{200D}\u{1F384}', + shortName: 'mx_claus', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personFantasy, + keywords: [ + 'uc13', + 'holidays', + 'winter', + 'christmas', + 'advent', + 'fantasy', + 'disguise', + 'holiday', + 'navidad', + 'xmas', + 'noel', + 'merry christmas' + ]), + Emoji( + name: 'mx claus: light skin tone', + char: '\u{1F9D1}\u{1F3FB}\u{200D}\u{1F384}', + shortName: 'mx_claus_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personFantasy, + keywords: [ + 'uc13', + 'holidays', + 'winter', + 'christmas', + 'advent', + 'fantasy', + 'disguise', + 'holiday', + 'navidad', + 'xmas', + 'noel', + 'merry christmas' + ], + modifiable: true), + Emoji( + name: 'mx claus: medium-light skin tone', + char: '\u{1F9D1}\u{1F3FC}\u{200D}\u{1F384}', + shortName: 'mx_claus_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personFantasy, + keywords: [ + 'uc13', + 'holidays', + 'winter', + 'christmas', + 'advent', + 'fantasy', + 'disguise', + 'holiday', + 'navidad', + 'xmas', + 'noel', + 'merry christmas' + ], + modifiable: true), + Emoji( + name: 'mx claus: medium skin tone', + char: '\u{1F9D1}\u{1F3FD}\u{200D}\u{1F384}', + shortName: 'mx_claus_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personFantasy, + keywords: [ + 'uc13', + 'holidays', + 'winter', + 'christmas', + 'advent', + 'fantasy', + 'disguise', + 'holiday', + 'navidad', + 'xmas', + 'noel', + 'merry christmas' + ], + modifiable: true), + Emoji( + name: 'mx claus: medium-dark skin tone', + char: '\u{1F9D1}\u{1F3FE}\u{200D}\u{1F384}', + shortName: 'mx_claus_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personFantasy, + keywords: [ + 'uc13', + 'holidays', + 'winter', + 'christmas', + 'advent', + 'fantasy', + 'disguise', + 'holiday', + 'navidad', + 'xmas', + 'noel', + 'merry christmas' + ], + modifiable: true), + Emoji( + name: 'mx claus: dark skin tone', + char: '\u{1F9D1}\u{1F3FF}\u{200D}\u{1F384}', + shortName: 'mx_claus_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personFantasy, + keywords: [ + 'uc13', + 'holidays', + 'winter', + 'christmas', + 'advent', + 'fantasy', + 'disguise', + 'holiday', + 'navidad', + 'xmas', + 'noel', + 'merry christmas' + ], + modifiable: true), + Emoji( + name: 'Mrs. Claus', + char: '\u{1F936}', + shortName: 'mrs_claus', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personFantasy, + keywords: [ + 'Christmas', + 'Mrs.', + 'celebration', + 'claus', + 'mother', + 'uc9', + 'holidays', + 'diversity', + 'winter', + 'christmas', + 'santa', + 'advent', + 'fantasy', + 'disguise', + 'holiday', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'navidad', + 'xmas', + 'noel', + 'merry christmas', + 'santa clause', + 'santa claus' + ]), + Emoji( + name: 'Mrs. Claus: light skin tone', + char: '\u{1F936}\u{1F3FB}', + shortName: 'mrs_claus_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personFantasy, + keywords: [ + 'Christmas', + 'Mrs.', + 'celebration', + 'claus', + 'light skin tone', + 'mother', + 'uc9', + 'holidays', + 'diversity', + 'winter', + 'christmas', + 'santa', + 'advent', + 'fantasy', + 'disguise', + 'holiday', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'navidad', + 'xmas', + 'noel', + 'merry christmas', + 'santa clause', + 'santa claus' + ], + modifiable: true), + Emoji( + name: 'Mrs. Claus: medium-light skin tone', + char: '\u{1F936}\u{1F3FC}', + shortName: 'mrs_claus_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personFantasy, + keywords: [ + 'Christmas', + 'Mrs.', + 'celebration', + 'claus', + 'medium-light skin tone', + 'mother', + 'uc9', + 'holidays', + 'diversity', + 'winter', + 'christmas', + 'santa', + 'advent', + 'fantasy', + 'disguise', + 'holiday', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'navidad', + 'xmas', + 'noel', + 'merry christmas', + 'santa clause', + 'santa claus' + ], + modifiable: true), + Emoji( + name: 'Mrs. Claus: medium skin tone', + char: '\u{1F936}\u{1F3FD}', + shortName: 'mrs_claus_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personFantasy, + keywords: [ + 'Christmas', + 'Mrs.', + 'celebration', + 'claus', + 'medium skin tone', + 'mother', + 'uc9', + 'holidays', + 'diversity', + 'winter', + 'christmas', + 'santa', + 'advent', + 'fantasy', + 'disguise', + 'holiday', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'navidad', + 'xmas', + 'noel', + 'merry christmas', + 'santa clause', + 'santa claus' + ], + modifiable: true), + Emoji( + name: 'Mrs. Claus: medium-dark skin tone', + char: '\u{1F936}\u{1F3FE}', + shortName: 'mrs_claus_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personFantasy, + keywords: [ + 'Christmas', + 'Mrs.', + 'celebration', + 'claus', + 'medium-dark skin tone', + 'mother', + 'uc9', + 'holidays', + 'diversity', + 'winter', + 'christmas', + 'santa', + 'advent', + 'fantasy', + 'disguise', + 'holiday', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'navidad', + 'xmas', + 'noel', + 'merry christmas', + 'santa clause', + 'santa claus' + ], + modifiable: true), + Emoji( + name: 'Mrs. Claus: dark skin tone', + char: '\u{1F936}\u{1F3FF}', + shortName: 'mrs_claus_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personFantasy, + keywords: [ + 'Christmas', + 'Mrs.', + 'celebration', + 'claus', + 'dark skin tone', + 'mother', + 'uc9', + 'holidays', + 'diversity', + 'winter', + 'christmas', + 'santa', + 'advent', + 'fantasy', + 'disguise', + 'holiday', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'navidad', + 'xmas', + 'noel', + 'merry christmas', + 'santa clause', + 'santa claus' + ], + modifiable: true), + Emoji( + name: 'Santa Claus', + char: '\u{1F385}', + shortName: 'santa', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personFantasy, + keywords: [ + 'Christmas', + 'celebration', + 'claus', + 'father', + 'santa', + 'uc6', + 'holidays', + 'diversity', + 'winter', + 'christmas', + 'santa', + 'mustache', + 'advent', + 'beard', + 'fantasy', + 'disguise', + 'holiday', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'navidad', + 'xmas', + 'noel', + 'merry christmas', + 'santa clause', + 'santa claus' + ]), + Emoji( + name: 'Santa Claus: light skin tone', + char: '\u{1F385}\u{1F3FB}', + shortName: 'santa_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personFantasy, + keywords: [ + 'Christmas', + 'celebration', + 'claus', + 'father', + 'light skin tone', + 'santa', + 'uc8', + 'holidays', + 'diversity', + 'winter', + 'christmas', + 'santa', + 'mustache', + 'advent', + 'beard', + 'fantasy', + 'disguise', + 'holiday', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'navidad', + 'xmas', + 'noel', + 'merry christmas', + 'santa clause', + 'santa claus' + ], + modifiable: true), + Emoji( + name: 'Santa Claus: medium-light skin tone', + char: '\u{1F385}\u{1F3FC}', + shortName: 'santa_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personFantasy, + keywords: [ + 'Christmas', + 'celebration', + 'claus', + 'father', + 'medium-light skin tone', + 'santa', + 'uc8', + 'holidays', + 'diversity', + 'winter', + 'christmas', + 'santa', + 'mustache', + 'advent', + 'beard', + 'fantasy', + 'disguise', + 'holiday', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'navidad', + 'xmas', + 'noel', + 'merry christmas', + 'santa clause', + 'santa claus' + ], + modifiable: true), + Emoji( + name: 'Santa Claus: medium skin tone', + char: '\u{1F385}\u{1F3FD}', + shortName: 'santa_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personFantasy, + keywords: [ + 'Christmas', + 'celebration', + 'claus', + 'father', + 'medium skin tone', + 'santa', + 'uc8', + 'holidays', + 'diversity', + 'winter', + 'christmas', + 'santa', + 'mustache', + 'advent', + 'beard', + 'fantasy', + 'disguise', + 'holiday', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'navidad', + 'xmas', + 'noel', + 'merry christmas', + 'santa clause', + 'santa claus' + ], + modifiable: true), + Emoji( + name: 'Santa Claus: medium-dark skin tone', + char: '\u{1F385}\u{1F3FE}', + shortName: 'santa_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personFantasy, + keywords: [ + 'Christmas', + 'celebration', + 'claus', + 'father', + 'medium-dark skin tone', + 'santa', + 'uc8', + 'holidays', + 'diversity', + 'winter', + 'christmas', + 'santa', + 'mustache', + 'advent', + 'beard', + 'fantasy', + 'disguise', + 'holiday', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'navidad', + 'xmas', + 'noel', + 'merry christmas', + 'santa clause', + 'santa claus' + ], + modifiable: true), + Emoji( + name: 'Santa Claus: dark skin tone', + char: '\u{1F385}\u{1F3FF}', + shortName: 'santa_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personFantasy, + keywords: [ + 'Christmas', + 'celebration', + 'claus', + 'dark skin tone', + 'father', + 'santa', + 'uc8', + 'holidays', + 'diversity', + 'winter', + 'christmas', + 'santa', + 'mustache', + 'advent', + 'beard', + 'fantasy', + 'disguise', + 'holiday', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'navidad', + 'xmas', + 'noel', + 'merry christmas', + 'santa clause', + 'santa claus' + ], + modifiable: true), + Emoji( + name: 'mage', + char: '\u{1F9D9}', + shortName: 'mage', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personFantasy, + keywords: [ + 'sorcerer', + 'sorceress', + 'witch', + 'wizard', + 'uc10', + 'diversity', + 'halloween', + 'magic', + 'wizard', + 'fantasy', + 'disguise', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'samhain', + 'spell', + 'genie', + 'magical', + 'Sorcerer', + 'Sorceress', + 'witch' + ]), + Emoji( + name: 'mage: light skin tone', + char: '\u{1F9D9}\u{1F3FB}', + shortName: 'mage_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personFantasy, + keywords: [ + 'light skin tone', + 'sorcerer', + 'sorceress', + 'witch', + 'wizard', + 'uc10', + 'diversity', + 'halloween', + 'magic', + 'wizard', + 'fantasy', + 'disguise', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'samhain', + 'spell', + 'genie', + 'magical', + 'Sorcerer', + 'Sorceress', + 'witch' + ], + modifiable: true), + Emoji( + name: 'mage: medium-light skin tone', + char: '\u{1F9D9}\u{1F3FC}', + shortName: 'mage_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personFantasy, + keywords: [ + 'medium-light skin tone', + 'sorcerer', + 'sorceress', + 'witch', + 'wizard', + 'uc10', + 'diversity', + 'halloween', + 'magic', + 'wizard', + 'fantasy', + 'disguise', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'samhain', + 'spell', + 'genie', + 'magical', + 'Sorcerer', + 'Sorceress', + 'witch' + ], + modifiable: true), + Emoji( + name: 'mage: medium skin tone', + char: '\u{1F9D9}\u{1F3FD}', + shortName: 'mage_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personFantasy, + keywords: [ + 'medium skin tone', + 'sorcerer', + 'sorceress', + 'witch', + 'wizard', + 'uc10', + 'diversity', + 'halloween', + 'magic', + 'wizard', + 'fantasy', + 'disguise', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'samhain', + 'spell', + 'genie', + 'magical', + 'Sorcerer', + 'Sorceress', + 'witch' + ], + modifiable: true), + Emoji( + name: 'mage: medium-dark skin tone', + char: '\u{1F9D9}\u{1F3FE}', + shortName: 'mage_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personFantasy, + keywords: [ + 'medium-dark skin tone', + 'sorcerer', + 'sorceress', + 'witch', + 'wizard', + 'uc10', + 'diversity', + 'halloween', + 'magic', + 'wizard', + 'fantasy', + 'disguise', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'samhain', + 'spell', + 'genie', + 'magical', + 'Sorcerer', + 'Sorceress', + 'witch' + ], + modifiable: true), + Emoji( + name: 'mage: dark skin tone', + char: '\u{1F9D9}\u{1F3FF}', + shortName: 'mage_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personFantasy, + keywords: [ + 'dark skin tone', + 'sorcerer', + 'sorceress', + 'witch', + 'wizard', + 'uc10', + 'diversity', + 'halloween', + 'magic', + 'wizard', + 'fantasy', + 'disguise', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'samhain', + 'spell', + 'genie', + 'magical', + 'Sorcerer', + 'Sorceress', + 'witch' + ], + modifiable: true), + Emoji( + name: 'woman mage', + char: '\u{1F9D9}\u{200D}\u{2640}\u{FE0F}', + shortName: 'woman_mage', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personFantasy, + keywords: [ + 'sorceress', + 'witch', + 'uc10', + 'diversity', + 'halloween', + 'magic', + 'wizard', + 'fantasy', + 'disguise', + 'snow white', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'samhain', + 'spell', + 'genie', + 'magical', + 'Sorcerer', + 'Sorceress', + 'witch' + ]), + Emoji( + name: 'woman mage: light skin tone', + char: '\u{1F9D9}\u{1F3FB}\u{200D}\u{2640}\u{FE0F}', + shortName: 'woman_mage_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personFantasy, + keywords: [ + 'light skin tone', + 'sorceress', + 'witch', + 'uc10', + 'diversity', + 'halloween', + 'magic', + 'wizard', + 'fantasy', + 'disguise', + 'snow white', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'samhain', + 'spell', + 'genie', + 'magical', + 'Sorcerer', + 'Sorceress', + 'witch' + ], + modifiable: true), + Emoji( + name: 'woman mage: medium-light skin tone', + char: '\u{1F9D9}\u{1F3FC}\u{200D}\u{2640}\u{FE0F}', + shortName: 'woman_mage_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personFantasy, + keywords: [ + 'medium-light skin tone', + 'sorceress', + 'witch', + 'uc10', + 'diversity', + 'halloween', + 'magic', + 'wizard', + 'fantasy', + 'disguise', + 'snow white', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'samhain', + 'spell', + 'genie', + 'magical', + 'Sorcerer', + 'Sorceress', + 'witch' + ], + modifiable: true), + Emoji( + name: 'woman mage: medium skin tone', + char: '\u{1F9D9}\u{1F3FD}\u{200D}\u{2640}\u{FE0F}', + shortName: 'woman_mage_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personFantasy, + keywords: [ + 'medium skin tone', + 'sorceress', + 'witch', + 'uc10', + 'diversity', + 'halloween', + 'magic', + 'wizard', + 'fantasy', + 'disguise', + 'snow white', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'samhain', + 'spell', + 'genie', + 'magical', + 'Sorcerer', + 'Sorceress', + 'witch' + ], + modifiable: true), + Emoji( + name: 'woman mage: medium-dark skin tone', + char: '\u{1F9D9}\u{1F3FE}\u{200D}\u{2640}\u{FE0F}', + shortName: 'woman_mage_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personFantasy, + keywords: [ + 'medium-dark skin tone', + 'sorceress', + 'witch', + 'uc10', + 'diversity', + 'halloween', + 'magic', + 'wizard', + 'fantasy', + 'disguise', + 'snow white', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'samhain', + 'spell', + 'genie', + 'magical', + 'Sorcerer', + 'Sorceress', + 'witch' + ], + modifiable: true), + Emoji( + name: 'woman mage: dark skin tone', + char: '\u{1F9D9}\u{1F3FF}\u{200D}\u{2640}\u{FE0F}', + shortName: 'woman_mage_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personFantasy, + keywords: [ + 'dark skin tone', + 'sorceress', + 'witch', + 'uc10', + 'diversity', + 'halloween', + 'magic', + 'wizard', + 'fantasy', + 'disguise', + 'snow white', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'samhain', + 'spell', + 'genie', + 'magical', + 'Sorcerer', + 'Sorceress', + 'witch' + ], + modifiable: true), + Emoji( + name: 'man mage', + char: '\u{1F9D9}\u{200D}\u{2642}\u{FE0F}', + shortName: 'man_mage', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personFantasy, + keywords: [ + 'sorcerer', + 'wizard', + 'uc10', + 'diversity', + 'halloween', + 'magic', + 'beard', + 'wizard', + 'fantasy', + 'disguise', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'samhain', + 'spell', + 'genie', + 'magical', + 'Sorcerer', + 'Sorceress', + 'witch' + ]), + Emoji( + name: 'man mage: light skin tone', + char: '\u{1F9D9}\u{1F3FB}\u{200D}\u{2642}\u{FE0F}', + shortName: 'man_mage_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personFantasy, + keywords: [ + 'light skin tone', + 'sorcerer', + 'wizard', + 'uc10', + 'diversity', + 'halloween', + 'magic', + 'beard', + 'wizard', + 'fantasy', + 'disguise', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'samhain', + 'spell', + 'genie', + 'magical', + 'Sorcerer', + 'Sorceress', + 'witch' + ], + modifiable: true), + Emoji( + name: 'man mage: medium-light skin tone', + char: '\u{1F9D9}\u{1F3FC}\u{200D}\u{2642}\u{FE0F}', + shortName: 'man_mage_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personFantasy, + keywords: [ + 'medium-light skin tone', + 'sorcerer', + 'wizard', + 'uc10', + 'diversity', + 'halloween', + 'magic', + 'beard', + 'wizard', + 'fantasy', + 'disguise', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'samhain', + 'spell', + 'genie', + 'magical', + 'Sorcerer', + 'Sorceress', + 'witch' + ], + modifiable: true), + Emoji( + name: 'man mage: medium skin tone', + char: '\u{1F9D9}\u{1F3FD}\u{200D}\u{2642}\u{FE0F}', + shortName: 'man_mage_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personFantasy, + keywords: [ + 'medium skin tone', + 'sorcerer', + 'wizard', + 'uc10', + 'diversity', + 'halloween', + 'magic', + 'beard', + 'wizard', + 'fantasy', + 'disguise', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'samhain', + 'spell', + 'genie', + 'magical', + 'Sorcerer', + 'Sorceress', + 'witch' + ], + modifiable: true), + Emoji( + name: 'man mage: medium-dark skin tone', + char: '\u{1F9D9}\u{1F3FE}\u{200D}\u{2642}\u{FE0F}', + shortName: 'man_mage_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personFantasy, + keywords: [ + 'medium-dark skin tone', + 'sorcerer', + 'wizard', + 'uc10', + 'diversity', + 'halloween', + 'magic', + 'beard', + 'wizard', + 'fantasy', + 'disguise', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'samhain', + 'spell', + 'genie', + 'magical', + 'Sorcerer', + 'Sorceress', + 'witch' + ], + modifiable: true), + Emoji( + name: 'man mage: dark skin tone', + char: '\u{1F9D9}\u{1F3FF}\u{200D}\u{2642}\u{FE0F}', + shortName: 'man_mage_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personFantasy, + keywords: [ + 'dark skin tone', + 'sorcerer', + 'wizard', + 'uc10', + 'diversity', + 'halloween', + 'magic', + 'beard', + 'wizard', + 'fantasy', + 'disguise', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'samhain', + 'spell', + 'genie', + 'magical', + 'Sorcerer', + 'Sorceress', + 'witch' + ], + modifiable: true), + Emoji( + name: 'elf', + char: '\u{1F9DD}', + shortName: 'elf', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personFantasy, + keywords: [ + 'uc10', + 'diversity', + 'halloween', + 'christmas', + 'magic', + 'legolas', + 'fantasy', + 'disguise', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'samhain', + 'navidad', + 'xmas', + 'noel', + 'merry christmas', + 'spell', + 'genie', + 'magical' + ]), + Emoji( + name: 'elf: light skin tone', + char: '\u{1F9DD}\u{1F3FB}', + shortName: 'elf_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personFantasy, + keywords: [ + 'light skin tone', + 'magical', + 'uc10', + 'diversity', + 'halloween', + 'christmas', + 'magic', + 'legolas', + 'fantasy', + 'disguise', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'samhain', + 'navidad', + 'xmas', + 'noel', + 'merry christmas', + 'spell', + 'genie', + 'magical' + ], + modifiable: true), + Emoji( + name: 'elf: medium-light skin tone', + char: '\u{1F9DD}\u{1F3FC}', + shortName: 'elf_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personFantasy, + keywords: [ + 'magical', + 'medium-light skin tone', + 'uc10', + 'diversity', + 'halloween', + 'christmas', + 'magic', + 'legolas', + 'fantasy', + 'disguise', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'samhain', + 'navidad', + 'xmas', + 'noel', + 'merry christmas', + 'spell', + 'genie', + 'magical' + ], + modifiable: true), + Emoji( + name: 'elf: medium skin tone', + char: '\u{1F9DD}\u{1F3FD}', + shortName: 'elf_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personFantasy, + keywords: [ + 'magical', + 'medium skin tone', + 'uc10', + 'diversity', + 'halloween', + 'christmas', + 'magic', + 'legolas', + 'fantasy', + 'disguise', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'samhain', + 'navidad', + 'xmas', + 'noel', + 'merry christmas', + 'spell', + 'genie', + 'magical' + ], + modifiable: true), + Emoji( + name: 'elf: medium-dark skin tone', + char: '\u{1F9DD}\u{1F3FE}', + shortName: 'elf_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personFantasy, + keywords: [ + 'magical', + 'medium-dark skin tone', + 'uc10', + 'diversity', + 'halloween', + 'christmas', + 'magic', + 'legolas', + 'fantasy', + 'disguise', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'samhain', + 'navidad', + 'xmas', + 'noel', + 'merry christmas', + 'spell', + 'genie', + 'magical' + ], + modifiable: true), + Emoji( + name: 'elf: dark skin tone', + char: '\u{1F9DD}\u{1F3FF}', + shortName: 'elf_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personFantasy, + keywords: [ + 'dark skin tone', + 'magical', + 'uc10', + 'diversity', + 'halloween', + 'christmas', + 'magic', + 'legolas', + 'fantasy', + 'disguise', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'samhain', + 'navidad', + 'xmas', + 'noel', + 'merry christmas', + 'spell', + 'genie', + 'magical' + ], + modifiable: true), + Emoji( + name: 'woman elf', + char: '\u{1F9DD}\u{200D}\u{2640}\u{FE0F}', + shortName: 'woman_elf', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personFantasy, + keywords: [ + 'magical', + 'uc10', + 'diversity', + 'halloween', + 'christmas', + 'magic', + 'legolas', + 'fantasy', + 'disguise', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'samhain', + 'navidad', + 'xmas', + 'noel', + 'merry christmas', + 'spell', + 'genie', + 'magical' + ]), + Emoji( + name: 'woman elf: light skin tone', + char: '\u{1F9DD}\u{1F3FB}\u{200D}\u{2640}\u{FE0F}', + shortName: 'woman_elf_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personFantasy, + keywords: [ + 'light skin tone', + 'magical', + 'uc10', + 'diversity', + 'halloween', + 'christmas', + 'magic', + 'legolas', + 'fantasy', + 'disguise', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'samhain', + 'navidad', + 'xmas', + 'noel', + 'merry christmas', + 'spell', + 'genie', + 'magical' + ], + modifiable: true), + Emoji( + name: 'woman elf: medium-light skin tone', + char: '\u{1F9DD}\u{1F3FC}\u{200D}\u{2640}\u{FE0F}', + shortName: 'woman_elf_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personFantasy, + keywords: [ + 'magical', + 'medium-light skin tone', + 'uc10', + 'diversity', + 'halloween', + 'christmas', + 'magic', + 'legolas', + 'fantasy', + 'disguise', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'samhain', + 'navidad', + 'xmas', + 'noel', + 'merry christmas', + 'spell', + 'genie', + 'magical' + ], + modifiable: true), + Emoji( + name: 'woman elf: medium skin tone', + char: '\u{1F9DD}\u{1F3FD}\u{200D}\u{2640}\u{FE0F}', + shortName: 'woman_elf_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personFantasy, + keywords: [ + 'magical', + 'medium skin tone', + 'uc10', + 'diversity', + 'halloween', + 'christmas', + 'magic', + 'legolas', + 'fantasy', + 'disguise', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'samhain', + 'navidad', + 'xmas', + 'noel', + 'merry christmas', + 'spell', + 'genie', + 'magical' + ], + modifiable: true), + Emoji( + name: 'woman elf: medium-dark skin tone', + char: '\u{1F9DD}\u{1F3FE}\u{200D}\u{2640}\u{FE0F}', + shortName: 'woman_elf_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personFantasy, + keywords: [ + 'magical', + 'medium-dark skin tone', + 'uc10', + 'diversity', + 'halloween', + 'christmas', + 'magic', + 'legolas', + 'fantasy', + 'disguise', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'samhain', + 'navidad', + 'xmas', + 'noel', + 'merry christmas', + 'spell', + 'genie', + 'magical' + ], + modifiable: true), + Emoji( + name: 'woman elf: dark skin tone', + char: '\u{1F9DD}\u{1F3FF}\u{200D}\u{2640}\u{FE0F}', + shortName: 'woman_elf_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personFantasy, + keywords: [ + 'dark skin tone', + 'magical', + 'uc10', + 'diversity', + 'halloween', + 'christmas', + 'magic', + 'legolas', + 'fantasy', + 'disguise', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'samhain', + 'navidad', + 'xmas', + 'noel', + 'merry christmas', + 'spell', + 'genie', + 'magical' + ], + modifiable: true), + Emoji( + name: 'man elf', + char: '\u{1F9DD}\u{200D}\u{2642}\u{FE0F}', + shortName: 'man_elf', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personFantasy, + keywords: [ + 'magical', + 'uc10', + 'diversity', + 'halloween', + 'christmas', + 'magic', + 'legolas', + 'fantasy', + 'disguise', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'samhain', + 'navidad', + 'xmas', + 'noel', + 'merry christmas', + 'spell', + 'genie', + 'magical' + ]), + Emoji( + name: 'man elf: light skin tone', + char: '\u{1F9DD}\u{1F3FB}\u{200D}\u{2642}\u{FE0F}', + shortName: 'man_elf_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personFantasy, + keywords: [ + 'light skin tone', + 'magical', + 'uc10', + 'diversity', + 'halloween', + 'christmas', + 'magic', + 'legolas', + 'fantasy', + 'disguise', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'samhain', + 'navidad', + 'xmas', + 'noel', + 'merry christmas', + 'spell', + 'genie', + 'magical' + ], + modifiable: true), + Emoji( + name: 'man elf: medium-light skin tone', + char: '\u{1F9DD}\u{1F3FC}\u{200D}\u{2642}\u{FE0F}', + shortName: 'man_elf_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personFantasy, + keywords: [ + 'magical', + 'medium-light skin tone', + 'uc10', + 'diversity', + 'halloween', + 'christmas', + 'magic', + 'legolas', + 'fantasy', + 'disguise', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'samhain', + 'navidad', + 'xmas', + 'noel', + 'merry christmas', + 'spell', + 'genie', + 'magical' + ], + modifiable: true), + Emoji( + name: 'man elf: medium skin tone', + char: '\u{1F9DD}\u{1F3FD}\u{200D}\u{2642}\u{FE0F}', + shortName: 'man_elf_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personFantasy, + keywords: [ + 'magical', + 'medium skin tone', + 'uc10', + 'diversity', + 'halloween', + 'christmas', + 'magic', + 'legolas', + 'fantasy', + 'disguise', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'samhain', + 'navidad', + 'xmas', + 'noel', + 'merry christmas', + 'spell', + 'genie', + 'magical' + ], + modifiable: true), + Emoji( + name: 'man elf: medium-dark skin tone', + char: '\u{1F9DD}\u{1F3FE}\u{200D}\u{2642}\u{FE0F}', + shortName: 'man_elf_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personFantasy, + keywords: [ + 'magical', + 'medium-dark skin tone', + 'uc10', + 'diversity', + 'halloween', + 'christmas', + 'magic', + 'legolas', + 'fantasy', + 'disguise', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'samhain', + 'navidad', + 'xmas', + 'noel', + 'merry christmas', + 'spell', + 'genie', + 'magical' + ], + modifiable: true), + Emoji( + name: 'man elf: dark skin tone', + char: '\u{1F9DD}\u{1F3FF}\u{200D}\u{2642}\u{FE0F}', + shortName: 'man_elf_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personFantasy, + keywords: [ + 'dark skin tone', + 'magical', + 'uc10', + 'diversity', + 'halloween', + 'christmas', + 'magic', + 'legolas', + 'fantasy', + 'disguise', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'samhain', + 'navidad', + 'xmas', + 'noel', + 'merry christmas', + 'spell', + 'genie', + 'magical' + ], + modifiable: true), + Emoji( + name: 'vampire', + char: '\u{1F9DB}', + shortName: 'vampire', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personFantasy, + keywords: [ + 'Dracula', + 'undead', + 'uc10', + 'diversity', + 'halloween', + 'vampire', + 'fantasy', + 'disguise', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'samhain', + 'dracula' + ]), + Emoji( + name: 'vampire: light skin tone', + char: '\u{1F9DB}\u{1F3FB}', + shortName: 'vampire_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personFantasy, + keywords: [ + 'Dracula', + 'light skin tone', + 'undead', + 'uc10', + 'diversity', + 'halloween', + 'vampire', + 'fantasy', + 'disguise', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'samhain', + 'dracula' + ], + modifiable: true), + Emoji( + name: 'vampire: medium-light skin tone', + char: '\u{1F9DB}\u{1F3FC}', + shortName: 'vampire_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personFantasy, + keywords: [ + 'Dracula', + 'medium-light skin tone', + 'undead', + 'uc10', + 'diversity', + 'halloween', + 'vampire', + 'fantasy', + 'disguise', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'samhain', + 'dracula' + ], + modifiable: true), + Emoji( + name: 'vampire: medium skin tone', + char: '\u{1F9DB}\u{1F3FD}', + shortName: 'vampire_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personFantasy, + keywords: [ + 'Dracula', + 'medium skin tone', + 'undead', + 'uc10', + 'diversity', + 'halloween', + 'vampire', + 'fantasy', + 'disguise', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'samhain', + 'dracula' + ], + modifiable: true), + Emoji( + name: 'vampire: medium-dark skin tone', + char: '\u{1F9DB}\u{1F3FE}', + shortName: 'vampire_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personFantasy, + keywords: [ + 'Dracula', + 'medium-dark skin tone', + 'undead', + 'uc10', + 'diversity', + 'halloween', + 'vampire', + 'fantasy', + 'disguise', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'samhain', + 'dracula' + ], + modifiable: true), + Emoji( + name: 'vampire: dark skin tone', + char: '\u{1F9DB}\u{1F3FF}', + shortName: 'vampire_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personFantasy, + keywords: [ + 'Dracula', + 'dark skin tone', + 'undead', + 'uc10', + 'diversity', + 'halloween', + 'vampire', + 'fantasy', + 'disguise', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'samhain', + 'dracula' + ], + modifiable: true), + Emoji( + name: 'woman vampire', + char: '\u{1F9DB}\u{200D}\u{2640}\u{FE0F}', + shortName: 'woman_vampire', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personFantasy, + keywords: [ + 'undead', + 'uc10', + 'diversity', + 'halloween', + 'vampire', + 'fantasy', + 'disguise', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'samhain', + 'dracula' + ]), + Emoji( + name: 'woman vampire: light skin tone', + char: '\u{1F9DB}\u{1F3FB}\u{200D}\u{2640}\u{FE0F}', + shortName: 'woman_vampire_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personFantasy, + keywords: [ + 'light skin tone', + 'undead', + 'uc10', + 'diversity', + 'halloween', + 'vampire', + 'fantasy', + 'disguise', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'samhain', + 'dracula' + ], + modifiable: true), + Emoji( + name: 'woman vampire: medium-light skin tone', + char: '\u{1F9DB}\u{1F3FC}\u{200D}\u{2640}\u{FE0F}', + shortName: 'woman_vampire_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personFantasy, + keywords: [ + 'medium-light skin tone', + 'undead', + 'uc10', + 'diversity', + 'halloween', + 'vampire', + 'fantasy', + 'disguise', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'samhain', + 'dracula' + ], + modifiable: true), + Emoji( + name: 'woman vampire: medium skin tone', + char: '\u{1F9DB}\u{1F3FD}\u{200D}\u{2640}\u{FE0F}', + shortName: 'woman_vampire_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personFantasy, + keywords: [ + 'medium skin tone', + 'undead', + 'uc10', + 'diversity', + 'halloween', + 'vampire', + 'fantasy', + 'disguise', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'samhain', + 'dracula' + ], + modifiable: true), + Emoji( + name: 'woman vampire: medium-dark skin tone', + char: '\u{1F9DB}\u{1F3FE}\u{200D}\u{2640}\u{FE0F}', + shortName: 'woman_vampire_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personFantasy, + keywords: [ + 'medium-dark skin tone', + 'undead', + 'uc10', + 'diversity', + 'halloween', + 'vampire', + 'fantasy', + 'disguise', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'samhain', + 'dracula' + ], + modifiable: true), + Emoji( + name: 'woman vampire: dark skin tone', + char: '\u{1F9DB}\u{1F3FF}\u{200D}\u{2640}\u{FE0F}', + shortName: 'woman_vampire_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personFantasy, + keywords: [ + 'dark skin tone', + 'undead', + 'uc10', + 'diversity', + 'halloween', + 'vampire', + 'fantasy', + 'disguise', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'samhain', + 'dracula' + ], + modifiable: true), + Emoji( + name: 'man vampire', + char: '\u{1F9DB}\u{200D}\u{2642}\u{FE0F}', + shortName: 'man_vampire', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personFantasy, + keywords: [ + 'Dracula', + 'undead', + 'uc10', + 'diversity', + 'halloween', + 'vampire', + 'fantasy', + 'disguise', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'samhain', + 'dracula' + ]), + Emoji( + name: 'man vampire: light skin tone', + char: '\u{1F9DB}\u{1F3FB}\u{200D}\u{2642}\u{FE0F}', + shortName: 'man_vampire_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personFantasy, + keywords: [ + 'Dracula', + 'light skin tone', + 'undead', + 'uc10', + 'diversity', + 'halloween', + 'vampire', + 'fantasy', + 'disguise', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'samhain', + 'dracula' + ], + modifiable: true), + Emoji( + name: 'man vampire: medium-light skin tone', + char: '\u{1F9DB}\u{1F3FC}\u{200D}\u{2642}\u{FE0F}', + shortName: 'man_vampire_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personFantasy, + keywords: [ + 'Dracula', + 'medium-light skin tone', + 'undead', + 'uc10', + 'diversity', + 'halloween', + 'vampire', + 'fantasy', + 'disguise', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'samhain', + 'dracula' + ], + modifiable: true), + Emoji( + name: 'man vampire: medium skin tone', + char: '\u{1F9DB}\u{1F3FD}\u{200D}\u{2642}\u{FE0F}', + shortName: 'man_vampire_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personFantasy, + keywords: [ + 'Dracula', + 'medium skin tone', + 'undead', + 'uc10', + 'diversity', + 'halloween', + 'vampire', + 'fantasy', + 'disguise', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'samhain', + 'dracula' + ], + modifiable: true), + Emoji( + name: 'man vampire: medium-dark skin tone', + char: '\u{1F9DB}\u{1F3FE}\u{200D}\u{2642}\u{FE0F}', + shortName: 'man_vampire_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personFantasy, + keywords: [ + 'Dracula', + 'medium-dark skin tone', + 'undead', + 'uc10', + 'diversity', + 'halloween', + 'vampire', + 'fantasy', + 'disguise', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'samhain', + 'dracula' + ], + modifiable: true), + Emoji( + name: 'man vampire: dark skin tone', + char: '\u{1F9DB}\u{1F3FF}\u{200D}\u{2642}\u{FE0F}', + shortName: 'man_vampire_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personFantasy, + keywords: [ + 'Dracula', + 'dark skin tone', + 'undead', + 'uc10', + 'diversity', + 'halloween', + 'vampire', + 'fantasy', + 'disguise', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'samhain', + 'dracula' + ], + modifiable: true), + Emoji( + name: 'zombie', + char: '\u{1F9DF}', + shortName: 'zombie', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personFantasy, + keywords: [ + 'uc10', + 'halloween', + 'monster', + 'disguise', + 'samhain', + 'monsters', + 'beast' + ]), + Emoji( + name: 'woman zombie', + char: '\u{1F9DF}\u{200D}\u{2640}\u{FE0F}', + shortName: 'woman_zombie', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personFantasy, + keywords: [ + 'undead', + 'walking dead', + 'uc10', + 'halloween', + 'monster', + 'fantasy', + 'disguise', + 'samhain', + 'monsters', + 'beast' + ]), + Emoji( + name: 'man zombie', + char: '\u{1F9DF}\u{200D}\u{2642}\u{FE0F}', + shortName: 'man_zombie', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personFantasy, + keywords: [ + 'undead', + 'walking dead', + 'uc10', + 'halloween', + 'monster', + 'fantasy', + 'disguise', + 'samhain', + 'monsters', + 'beast' + ]), + Emoji( + name: 'genie', + char: '\u{1F9DE}', + shortName: 'genie', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personFantasy, + keywords: [ + 'uc10', + 'halloween', + 'magic', + 'djinni', + 'fantasy', + 'disguise', + 'samhain', + 'spell', + 'genie', + 'magical', + 'jinni' + ]), + Emoji( + name: 'woman genie', + char: '\u{1F9DE}\u{200D}\u{2640}\u{FE0F}', + shortName: 'woman_genie', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personFantasy, + keywords: [ + 'djinn', + 'uc10', + 'halloween', + 'magic', + 'djinni', + 'fantasy', + 'disguise', + 'samhain', + 'spell', + 'genie', + 'magical', + 'jinni' + ]), + Emoji( + name: 'man genie', + char: '\u{1F9DE}\u{200D}\u{2642}\u{FE0F}', + shortName: 'man_genie', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personFantasy, + keywords: [ + 'djinn', + 'uc10', + 'halloween', + 'magic', + 'disney', + 'djinni', + 'fantasy', + 'disguise', + 'samhain', + 'spell', + 'genie', + 'magical', + 'cartoon', + 'jinni' + ]), + Emoji( + name: 'merperson', + char: '\u{1F9DC}', + shortName: 'merperson', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personFantasy, + keywords: [ + 'mermaid', + 'merman', + 'merwoman', + 'uc10', + 'diversity', + 'halloween', + 'mermaid', + 'fantasy', + 'disguise', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'samhain', + 'merboy', + 'mergirl', + 'merman', + 'merperson', + 'selkie', + 'undine', + 'atargatis', + 'siren' + ]), + Emoji( + name: 'merperson: light skin tone', + char: '\u{1F9DC}\u{1F3FB}', + shortName: 'merperson_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personFantasy, + keywords: [ + 'light skin tone', + 'mermaid', + 'merman', + 'merwoman', + 'uc10', + 'diversity', + 'halloween', + 'mermaid', + 'fantasy', + 'disguise', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'samhain', + 'merboy', + 'mergirl', + 'merman', + 'merperson', + 'selkie', + 'undine', + 'atargatis', + 'siren' + ], + modifiable: true), + Emoji( + name: 'merperson: medium-light skin tone', + char: '\u{1F9DC}\u{1F3FC}', + shortName: 'merperson_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personFantasy, + keywords: [ + 'medium-light skin tone', + 'mermaid', + 'merman', + 'merwoman', + 'uc10', + 'diversity', + 'halloween', + 'mermaid', + 'fantasy', + 'disguise', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'samhain', + 'merboy', + 'mergirl', + 'merman', + 'merperson', + 'selkie', + 'undine', + 'atargatis', + 'siren' + ], + modifiable: true), + Emoji( + name: 'merperson: medium skin tone', + char: '\u{1F9DC}\u{1F3FD}', + shortName: 'merperson_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personFantasy, + keywords: [ + 'medium skin tone', + 'mermaid', + 'merman', + 'merwoman', + 'uc10', + 'diversity', + 'halloween', + 'mermaid', + 'fantasy', + 'disguise', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'samhain', + 'merboy', + 'mergirl', + 'merman', + 'merperson', + 'selkie', + 'undine', + 'atargatis', + 'siren' + ], + modifiable: true), + Emoji( + name: 'merperson: medium-dark skin tone', + char: '\u{1F9DC}\u{1F3FE}', + shortName: 'merperson_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personFantasy, + keywords: [ + 'medium-dark skin tone', + 'mermaid', + 'merman', + 'merwoman', + 'uc10', + 'diversity', + 'halloween', + 'mermaid', + 'fantasy', + 'disguise', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'samhain', + 'merboy', + 'mergirl', + 'merman', + 'merperson', + 'selkie', + 'undine', + 'atargatis', + 'siren' + ], + modifiable: true), + Emoji( + name: 'merperson: dark skin tone', + char: '\u{1F9DC}\u{1F3FF}', + shortName: 'merperson_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personFantasy, + keywords: [ + 'dark skin tone', + 'mermaid', + 'merman', + 'merwoman', + 'uc10', + 'diversity', + 'halloween', + 'mermaid', + 'fantasy', + 'disguise', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'samhain', + 'merboy', + 'mergirl', + 'merman', + 'merperson', + 'selkie', + 'undine', + 'atargatis', + 'siren' + ], + modifiable: true), + Emoji( + name: 'mermaid', + char: '\u{1F9DC}\u{200D}\u{2640}\u{FE0F}', + shortName: 'mermaid', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personFantasy, + keywords: [ + 'merwoman', + 'uc10', + 'diversity', + 'halloween', + 'disney', + 'mermaid', + 'fantasy', + 'disguise', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'samhain', + 'cartoon', + 'merboy', + 'mergirl', + 'merman', + 'merperson', + 'selkie', + 'undine', + 'atargatis', + 'siren' + ]), + Emoji( + name: 'mermaid: light skin tone', + char: '\u{1F9DC}\u{1F3FB}\u{200D}\u{2640}\u{FE0F}', + shortName: 'mermaid_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personFantasy, + keywords: [ + 'light skin tone', + 'merwoman', + 'uc10', + 'diversity', + 'halloween', + 'disney', + 'mermaid', + 'fantasy', + 'disguise', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'samhain', + 'cartoon', + 'merboy', + 'mergirl', + 'merman', + 'merperson', + 'selkie', + 'undine', + 'atargatis', + 'siren' + ], + modifiable: true), + Emoji( + name: 'mermaid: medium-light skin tone', + char: '\u{1F9DC}\u{1F3FC}\u{200D}\u{2640}\u{FE0F}', + shortName: 'mermaid_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personFantasy, + keywords: [ + 'medium-light skin tone', + 'merwoman', + 'uc10', + 'diversity', + 'halloween', + 'disney', + 'mermaid', + 'fantasy', + 'disguise', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'samhain', + 'cartoon', + 'merboy', + 'mergirl', + 'merman', + 'merperson', + 'selkie', + 'undine', + 'atargatis', + 'siren' + ], + modifiable: true), + Emoji( + name: 'mermaid: medium skin tone', + char: '\u{1F9DC}\u{1F3FD}\u{200D}\u{2640}\u{FE0F}', + shortName: 'mermaid_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personFantasy, + keywords: [ + 'medium skin tone', + 'merwoman', + 'uc10', + 'diversity', + 'halloween', + 'disney', + 'mermaid', + 'fantasy', + 'disguise', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'samhain', + 'cartoon', + 'merboy', + 'mergirl', + 'merman', + 'merperson', + 'selkie', + 'undine', + 'atargatis', + 'siren' + ], + modifiable: true), + Emoji( + name: 'mermaid: medium-dark skin tone', + char: '\u{1F9DC}\u{1F3FE}\u{200D}\u{2640}\u{FE0F}', + shortName: 'mermaid_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personFantasy, + keywords: [ + 'medium-dark skin tone', + 'merwoman', + 'uc10', + 'diversity', + 'halloween', + 'disney', + 'mermaid', + 'fantasy', + 'disguise', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'samhain', + 'cartoon', + 'merboy', + 'mergirl', + 'merman', + 'merperson', + 'selkie', + 'undine', + 'atargatis', + 'siren' + ], + modifiable: true), + Emoji( + name: 'mermaid: dark skin tone', + char: '\u{1F9DC}\u{1F3FF}\u{200D}\u{2640}\u{FE0F}', + shortName: 'mermaid_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personFantasy, + keywords: [ + 'dark skin tone', + 'merwoman', + 'uc10', + 'diversity', + 'halloween', + 'disney', + 'mermaid', + 'fantasy', + 'disguise', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'samhain', + 'cartoon', + 'merboy', + 'mergirl', + 'merman', + 'merperson', + 'selkie', + 'undine', + 'atargatis', + 'siren' + ], + modifiable: true), + Emoji( + name: 'merman', + char: '\u{1F9DC}\u{200D}\u{2642}\u{FE0F}', + shortName: 'merman', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personFantasy, + keywords: [ + 'Triton', + 'uc10', + 'diversity', + 'halloween', + 'mermaid', + 'fantasy', + 'disguise', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'samhain', + 'merboy', + 'mergirl', + 'merman', + 'merperson', + 'selkie', + 'undine', + 'atargatis', + 'siren' + ]), + Emoji( + name: 'merman: light skin tone', + char: '\u{1F9DC}\u{1F3FB}\u{200D}\u{2642}\u{FE0F}', + shortName: 'merman_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personFantasy, + keywords: [ + 'Triton', + 'light skin tone', + 'uc10', + 'diversity', + 'halloween', + 'mermaid', + 'fantasy', + 'disguise', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'samhain', + 'merboy', + 'mergirl', + 'merman', + 'merperson', + 'selkie', + 'undine', + 'atargatis', + 'siren' + ], + modifiable: true), + Emoji( + name: 'merman: medium-light skin tone', + char: '\u{1F9DC}\u{1F3FC}\u{200D}\u{2642}\u{FE0F}', + shortName: 'merman_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personFantasy, + keywords: [ + 'Triton', + 'medium-light skin tone', + 'uc10', + 'diversity', + 'halloween', + 'mermaid', + 'fantasy', + 'disguise', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'samhain', + 'merboy', + 'mergirl', + 'merman', + 'merperson', + 'selkie', + 'undine', + 'atargatis', + 'siren' + ], + modifiable: true), + Emoji( + name: 'merman: medium skin tone', + char: '\u{1F9DC}\u{1F3FD}\u{200D}\u{2642}\u{FE0F}', + shortName: 'merman_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personFantasy, + keywords: [ + 'Triton', + 'medium skin tone', + 'uc10', + 'diversity', + 'halloween', + 'mermaid', + 'fantasy', + 'disguise', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'samhain', + 'merboy', + 'mergirl', + 'merman', + 'merperson', + 'selkie', + 'undine', + 'atargatis', + 'siren' + ], + modifiable: true), + Emoji( + name: 'merman: medium-dark skin tone', + char: '\u{1F9DC}\u{1F3FE}\u{200D}\u{2642}\u{FE0F}', + shortName: 'merman_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personFantasy, + keywords: [ + 'Triton', + 'medium-dark skin tone', + 'uc10', + 'diversity', + 'halloween', + 'mermaid', + 'fantasy', + 'disguise', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'samhain', + 'merboy', + 'mergirl', + 'merman', + 'merperson', + 'selkie', + 'undine', + 'atargatis', + 'siren' + ], + modifiable: true), + Emoji( + name: 'merman: dark skin tone', + char: '\u{1F9DC}\u{1F3FF}\u{200D}\u{2642}\u{FE0F}', + shortName: 'merman_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personFantasy, + keywords: [ + 'Triton', + 'dark skin tone', + 'uc10', + 'diversity', + 'halloween', + 'mermaid', + 'fantasy', + 'disguise', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'samhain', + 'merboy', + 'mergirl', + 'merman', + 'merperson', + 'selkie', + 'undine', + 'atargatis', + 'siren' + ], + modifiable: true), + Emoji( + name: 'fairy', + char: '\u{1F9DA}', + shortName: 'fairy', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personFantasy, + keywords: [ + 'Oberon', + 'Puck', + 'Titania', + 'uc10', + 'diversity', + 'halloween', + 'magic', + 'fantasy', + 'disguise', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'samhain', + 'spell', + 'genie', + 'magical' + ]), + Emoji( + name: 'fairy: light skin tone', + char: '\u{1F9DA}\u{1F3FB}', + shortName: 'fairy_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personFantasy, + keywords: [ + 'Oberon', + 'Puck', + 'Titania', + 'light skin tone', + 'uc10', + 'diversity', + 'halloween', + 'magic', + 'fantasy', + 'disguise', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'samhain', + 'spell', + 'genie', + 'magical' + ], + modifiable: true), + Emoji( + name: 'fairy: medium-light skin tone', + char: '\u{1F9DA}\u{1F3FC}', + shortName: 'fairy_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personFantasy, + keywords: [ + 'Oberon', + 'Puck', + 'Titania', + 'medium-light skin tone', + 'uc10', + 'diversity', + 'halloween', + 'magic', + 'fantasy', + 'disguise', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'samhain', + 'spell', + 'genie', + 'magical' + ], + modifiable: true), + Emoji( + name: 'fairy: medium skin tone', + char: '\u{1F9DA}\u{1F3FD}', + shortName: 'fairy_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personFantasy, + keywords: [ + 'Oberon', + 'Puck', + 'Titania', + 'medium skin tone', + 'uc10', + 'diversity', + 'halloween', + 'magic', + 'fantasy', + 'disguise', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'samhain', + 'spell', + 'genie', + 'magical' + ], + modifiable: true), + Emoji( + name: 'fairy: medium-dark skin tone', + char: '\u{1F9DA}\u{1F3FE}', + shortName: 'fairy_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personFantasy, + keywords: [ + 'Oberon', + 'Puck', + 'Titania', + 'medium-dark skin tone', + 'uc10', + 'diversity', + 'halloween', + 'magic', + 'fantasy', + 'disguise', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'samhain', + 'spell', + 'genie', + 'magical' + ], + modifiable: true), + Emoji( + name: 'fairy: dark skin tone', + char: '\u{1F9DA}\u{1F3FF}', + shortName: 'fairy_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personFantasy, + keywords: [ + 'Oberon', + 'Puck', + 'Titania', + 'dark skin tone', + 'uc10', + 'diversity', + 'halloween', + 'magic', + 'fantasy', + 'disguise', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'samhain', + 'spell', + 'genie', + 'magical' + ], + modifiable: true), + Emoji( + name: 'woman fairy', + char: '\u{1F9DA}\u{200D}\u{2640}\u{FE0F}', + shortName: 'woman_fairy', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personFantasy, + keywords: [ + 'Titania', + 'uc10', + 'diversity', + 'halloween', + 'beautiful', + 'magic', + 'disney', + 'fantasy', + 'disguise', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'samhain', + 'cute', + 'pretty', + 'adorable', + 'adore', + 'beauty', + 'cutie', + 'babe', + 'lovely', + 'spell', + 'genie', + 'magical', + 'cartoon' + ]), + Emoji( + name: 'woman fairy: light skin tone', + char: '\u{1F9DA}\u{1F3FB}\u{200D}\u{2640}\u{FE0F}', + shortName: 'woman_fairy_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personFantasy, + keywords: [ + 'Titania', + 'light skin tone', + 'uc10', + 'diversity', + 'halloween', + 'beautiful', + 'magic', + 'disney', + 'fantasy', + 'disguise', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'samhain', + 'cute', + 'pretty', + 'adorable', + 'adore', + 'beauty', + 'cutie', + 'babe', + 'lovely', + 'spell', + 'genie', + 'magical', + 'cartoon' + ], + modifiable: true), + Emoji( + name: 'woman fairy: medium-light skin tone', + char: '\u{1F9DA}\u{1F3FC}\u{200D}\u{2640}\u{FE0F}', + shortName: 'woman_fairy_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personFantasy, + keywords: [ + 'Titania', + 'medium-light skin tone', + 'uc10', + 'diversity', + 'halloween', + 'beautiful', + 'magic', + 'disney', + 'fantasy', + 'disguise', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'samhain', + 'cute', + 'pretty', + 'adorable', + 'adore', + 'beauty', + 'cutie', + 'babe', + 'lovely', + 'spell', + 'genie', + 'magical', + 'cartoon' + ], + modifiable: true), + Emoji( + name: 'woman fairy: medium skin tone', + char: '\u{1F9DA}\u{1F3FD}\u{200D}\u{2640}\u{FE0F}', + shortName: 'woman_fairy_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personFantasy, + keywords: [ + 'Titania', + 'medium skin tone', + 'uc10', + 'diversity', + 'halloween', + 'beautiful', + 'magic', + 'disney', + 'fantasy', + 'disguise', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'samhain', + 'cute', + 'pretty', + 'adorable', + 'adore', + 'beauty', + 'cutie', + 'babe', + 'lovely', + 'spell', + 'genie', + 'magical', + 'cartoon' + ], + modifiable: true), + Emoji( + name: 'woman fairy: medium-dark skin tone', + char: '\u{1F9DA}\u{1F3FE}\u{200D}\u{2640}\u{FE0F}', + shortName: 'woman_fairy_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personFantasy, + keywords: [ + 'Titania', + 'medium-dark skin tone', + 'uc10', + 'diversity', + 'halloween', + 'beautiful', + 'magic', + 'disney', + 'fantasy', + 'disguise', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'samhain', + 'cute', + 'pretty', + 'adorable', + 'adore', + 'beauty', + 'cutie', + 'babe', + 'lovely', + 'spell', + 'genie', + 'magical', + 'cartoon' + ], + modifiable: true), + Emoji( + name: 'woman fairy: dark skin tone', + char: '\u{1F9DA}\u{1F3FF}\u{200D}\u{2640}\u{FE0F}', + shortName: 'woman_fairy_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personFantasy, + keywords: [ + 'Titania', + 'dark skin tone', + 'uc10', + 'diversity', + 'halloween', + 'beautiful', + 'magic', + 'disney', + 'fantasy', + 'disguise', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'samhain', + 'cute', + 'pretty', + 'adorable', + 'adore', + 'beauty', + 'cutie', + 'babe', + 'lovely', + 'spell', + 'genie', + 'magical', + 'cartoon' + ], + modifiable: true), + Emoji( + name: 'man fairy', + char: '\u{1F9DA}\u{200D}\u{2642}\u{FE0F}', + shortName: 'man_fairy', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personFantasy, + keywords: [ + 'Oberon', + 'Puck', + 'uc10', + 'diversity', + 'halloween', + 'beautiful', + 'magic', + 'fantasy', + 'disguise', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'samhain', + 'cute', + 'pretty', + 'adorable', + 'adore', + 'beauty', + 'cutie', + 'babe', + 'lovely', + 'spell', + 'genie', + 'magical' + ]), + Emoji( + name: 'man fairy: light skin tone', + char: '\u{1F9DA}\u{1F3FB}\u{200D}\u{2642}\u{FE0F}', + shortName: 'man_fairy_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personFantasy, + keywords: [ + 'Oberon', + 'Puck', + 'light skin tone', + 'uc10', + 'diversity', + 'halloween', + 'beautiful', + 'magic', + 'fantasy', + 'disguise', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'samhain', + 'cute', + 'pretty', + 'adorable', + 'adore', + 'beauty', + 'cutie', + 'babe', + 'lovely', + 'spell', + 'genie', + 'magical' + ], + modifiable: true), + Emoji( + name: 'man fairy: medium-light skin tone', + char: '\u{1F9DA}\u{1F3FC}\u{200D}\u{2642}\u{FE0F}', + shortName: 'man_fairy_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personFantasy, + keywords: [ + 'Oberon', + 'Puck', + 'medium-light skin tone', + 'uc10', + 'diversity', + 'halloween', + 'beautiful', + 'magic', + 'fantasy', + 'disguise', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'samhain', + 'cute', + 'pretty', + 'adorable', + 'adore', + 'beauty', + 'cutie', + 'babe', + 'lovely', + 'spell', + 'genie', + 'magical' + ], + modifiable: true), + Emoji( + name: 'man fairy: medium skin tone', + char: '\u{1F9DA}\u{1F3FD}\u{200D}\u{2642}\u{FE0F}', + shortName: 'man_fairy_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personFantasy, + keywords: [ + 'Oberon', + 'Puck', + 'medium skin tone', + 'uc10', + 'diversity', + 'halloween', + 'beautiful', + 'magic', + 'fantasy', + 'disguise', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'samhain', + 'cute', + 'pretty', + 'adorable', + 'adore', + 'beauty', + 'cutie', + 'babe', + 'lovely', + 'spell', + 'genie', + 'magical' + ], + modifiable: true), + Emoji( + name: 'man fairy: medium-dark skin tone', + char: '\u{1F9DA}\u{1F3FE}\u{200D}\u{2642}\u{FE0F}', + shortName: 'man_fairy_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personFantasy, + keywords: [ + 'Oberon', + 'Puck', + 'medium-dark skin tone', + 'uc10', + 'diversity', + 'halloween', + 'beautiful', + 'magic', + 'fantasy', + 'disguise', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'samhain', + 'cute', + 'pretty', + 'adorable', + 'adore', + 'beauty', + 'cutie', + 'babe', + 'lovely', + 'spell', + 'genie', + 'magical' + ], + modifiable: true), + Emoji( + name: 'man fairy: dark skin tone', + char: '\u{1F9DA}\u{1F3FF}\u{200D}\u{2642}\u{FE0F}', + shortName: 'man_fairy_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personFantasy, + keywords: [ + 'Oberon', + 'Puck', + 'dark skin tone', + 'uc10', + 'diversity', + 'halloween', + 'beautiful', + 'magic', + 'fantasy', + 'disguise', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'samhain', + 'cute', + 'pretty', + 'adorable', + 'adore', + 'beauty', + 'cutie', + 'babe', + 'lovely', + 'spell', + 'genie', + 'magical' + ], + modifiable: true), + Emoji( + name: 'baby angel', + char: '\u{1F47C}', + shortName: 'angel', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personFantasy, + keywords: [ + 'angel', + 'baby', + 'face', + 'fairy tale', + 'fantasy', + 'uc6', + 'diversity', + 'halloween', + 'baby', + 'christmas', + 'pray', + 'omg', + 'jesus', + 'fantasy', + 'child', + 'soul', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'samhain', + 'kid', + 'babies', + 'infant', + 'infants', + 'crying kid', + 'bebe', + 'little', + 'petite', + 'bambino', + 'navidad', + 'xmas', + 'noel', + 'merry christmas', + 'prayer', + 'praying', + 'prayers', + 'grateful', + 'sorry', + 'heaven', + 'bless', + 'faith', + 'holy', + 'spirit', + 'hopeful', + 'blessed', + 'preach', + 'offering', + 'omfg', + 'oh my god', + 'children', + 'girl', + 'boy', + 'kids', + 'niño', + 'enfant' + ]), + Emoji( + name: 'baby angel: light skin tone', + char: '\u{1F47C}\u{1F3FB}', + shortName: 'angel_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personFantasy, + keywords: [ + 'angel', + 'baby', + 'face', + 'fairy tale', + 'fantasy', + 'light skin tone', + 'uc8', + 'diversity', + 'halloween', + 'baby', + 'christmas', + 'pray', + 'omg', + 'jesus', + 'fantasy', + 'child', + 'soul', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'samhain', + 'kid', + 'babies', + 'infant', + 'infants', + 'crying kid', + 'bebe', + 'little', + 'petite', + 'bambino', + 'navidad', + 'xmas', + 'noel', + 'merry christmas', + 'prayer', + 'praying', + 'prayers', + 'grateful', + 'sorry', + 'heaven', + 'bless', + 'faith', + 'holy', + 'spirit', + 'hopeful', + 'blessed', + 'preach', + 'offering', + 'omfg', + 'oh my god', + 'children', + 'girl', + 'boy', + 'kids', + 'niño', + 'enfant' + ], + modifiable: true), + Emoji( + name: 'baby angel: medium-light skin tone', + char: '\u{1F47C}\u{1F3FC}', + shortName: 'angel_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personFantasy, + keywords: [ + 'angel', + 'baby', + 'face', + 'fairy tale', + 'fantasy', + 'medium-light skin tone', + 'uc8', + 'diversity', + 'halloween', + 'baby', + 'christmas', + 'pray', + 'omg', + 'jesus', + 'fantasy', + 'child', + 'soul', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'samhain', + 'kid', + 'babies', + 'infant', + 'infants', + 'crying kid', + 'bebe', + 'little', + 'petite', + 'bambino', + 'navidad', + 'xmas', + 'noel', + 'merry christmas', + 'prayer', + 'praying', + 'prayers', + 'grateful', + 'sorry', + 'heaven', + 'bless', + 'faith', + 'holy', + 'spirit', + 'hopeful', + 'blessed', + 'preach', + 'offering', + 'omfg', + 'oh my god', + 'children', + 'girl', + 'boy', + 'kids', + 'niño', + 'enfant' + ], + modifiable: true), + Emoji( + name: 'baby angel: medium skin tone', + char: '\u{1F47C}\u{1F3FD}', + shortName: 'angel_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personFantasy, + keywords: [ + 'angel', + 'baby', + 'face', + 'fairy tale', + 'fantasy', + 'medium skin tone', + 'uc8', + 'diversity', + 'halloween', + 'baby', + 'christmas', + 'pray', + 'omg', + 'jesus', + 'fantasy', + 'child', + 'soul', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'samhain', + 'kid', + 'babies', + 'infant', + 'infants', + 'crying kid', + 'bebe', + 'little', + 'petite', + 'bambino', + 'navidad', + 'xmas', + 'noel', + 'merry christmas', + 'prayer', + 'praying', + 'prayers', + 'grateful', + 'sorry', + 'heaven', + 'bless', + 'faith', + 'holy', + 'spirit', + 'hopeful', + 'blessed', + 'preach', + 'offering', + 'omfg', + 'oh my god', + 'children', + 'girl', + 'boy', + 'kids', + 'niño', + 'enfant' + ], + modifiable: true), + Emoji( + name: 'baby angel: medium-dark skin tone', + char: '\u{1F47C}\u{1F3FE}', + shortName: 'angel_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personFantasy, + keywords: [ + 'angel', + 'baby', + 'face', + 'fairy tale', + 'fantasy', + 'medium-dark skin tone', + 'uc8', + 'diversity', + 'halloween', + 'baby', + 'christmas', + 'pray', + 'omg', + 'jesus', + 'fantasy', + 'child', + 'soul', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'samhain', + 'kid', + 'babies', + 'infant', + 'infants', + 'crying kid', + 'bebe', + 'little', + 'petite', + 'bambino', + 'navidad', + 'xmas', + 'noel', + 'merry christmas', + 'prayer', + 'praying', + 'prayers', + 'grateful', + 'sorry', + 'heaven', + 'bless', + 'faith', + 'holy', + 'spirit', + 'hopeful', + 'blessed', + 'preach', + 'offering', + 'omfg', + 'oh my god', + 'children', + 'girl', + 'boy', + 'kids', + 'niño', + 'enfant' + ], + modifiable: true), + Emoji( + name: 'baby angel: dark skin tone', + char: '\u{1F47C}\u{1F3FF}', + shortName: 'angel_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personFantasy, + keywords: [ + 'angel', + 'baby', + 'dark skin tone', + 'face', + 'fairy tale', + 'fantasy', + 'uc8', + 'diversity', + 'halloween', + 'baby', + 'christmas', + 'pray', + 'omg', + 'jesus', + 'fantasy', + 'child', + 'soul', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'samhain', + 'kid', + 'babies', + 'infant', + 'infants', + 'crying kid', + 'bebe', + 'little', + 'petite', + 'bambino', + 'navidad', + 'xmas', + 'noel', + 'merry christmas', + 'prayer', + 'praying', + 'prayers', + 'grateful', + 'sorry', + 'heaven', + 'bless', + 'faith', + 'holy', + 'spirit', + 'hopeful', + 'blessed', + 'preach', + 'offering', + 'omfg', + 'oh my god', + 'children', + 'girl', + 'boy', + 'kids', + 'niño', + 'enfant' + ], + modifiable: true), + Emoji( + name: 'pregnant woman', + char: '\u{1F930}', + shortName: 'pregnant_woman', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'pregnant', + 'woman', + 'uc9', + 'diversity', + 'women', + 'baby', + 'parent', + 'wife', + 'child', + 'mom', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'woman', + 'female', + 'kid', + 'babies', + 'infant', + 'infants', + 'crying kid', + 'bebe', + 'little', + 'petite', + 'bambino', + 'parents', + 'adult', + 'children', + 'girl', + 'boy', + 'kids', + 'niño', + 'enfant', + 'maman', + 'mommy', + 'mama', + 'mother' + ]), + Emoji( + name: 'pregnant woman: light skin tone', + char: '\u{1F930}\u{1F3FB}', + shortName: 'pregnant_woman_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'light skin tone', + 'pregnant', + 'woman', + 'uc9', + 'diversity', + 'women', + 'baby', + 'parent', + 'wife', + 'child', + 'mom', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'woman', + 'female', + 'kid', + 'babies', + 'infant', + 'infants', + 'crying kid', + 'bebe', + 'little', + 'petite', + 'bambino', + 'parents', + 'adult', + 'children', + 'girl', + 'boy', + 'kids', + 'niño', + 'enfant', + 'maman', + 'mommy', + 'mama', + 'mother' + ], + modifiable: true), + Emoji( + name: 'pregnant woman: medium-light skin tone', + char: '\u{1F930}\u{1F3FC}', + shortName: 'pregnant_woman_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'medium-light skin tone', + 'pregnant', + 'woman', + 'uc9', + 'diversity', + 'women', + 'baby', + 'parent', + 'wife', + 'child', + 'mom', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'woman', + 'female', + 'kid', + 'babies', + 'infant', + 'infants', + 'crying kid', + 'bebe', + 'little', + 'petite', + 'bambino', + 'parents', + 'adult', + 'children', + 'girl', + 'boy', + 'kids', + 'niño', + 'enfant', + 'maman', + 'mommy', + 'mama', + 'mother' + ], + modifiable: true), + Emoji( + name: 'pregnant woman: medium skin tone', + char: '\u{1F930}\u{1F3FD}', + shortName: 'pregnant_woman_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'medium skin tone', + 'pregnant', + 'woman', + 'uc9', + 'diversity', + 'women', + 'baby', + 'parent', + 'wife', + 'child', + 'mom', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'woman', + 'female', + 'kid', + 'babies', + 'infant', + 'infants', + 'crying kid', + 'bebe', + 'little', + 'petite', + 'bambino', + 'parents', + 'adult', + 'children', + 'girl', + 'boy', + 'kids', + 'niño', + 'enfant', + 'maman', + 'mommy', + 'mama', + 'mother' + ], + modifiable: true), + Emoji( + name: 'pregnant woman: medium-dark skin tone', + char: '\u{1F930}\u{1F3FE}', + shortName: 'pregnant_woman_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'medium-dark skin tone', + 'pregnant', + 'woman', + 'uc9', + 'diversity', + 'women', + 'baby', + 'parent', + 'wife', + 'child', + 'mom', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'woman', + 'female', + 'kid', + 'babies', + 'infant', + 'infants', + 'crying kid', + 'bebe', + 'little', + 'petite', + 'bambino', + 'parents', + 'adult', + 'children', + 'girl', + 'boy', + 'kids', + 'niño', + 'enfant', + 'maman', + 'mommy', + 'mama', + 'mother' + ], + modifiable: true), + Emoji( + name: 'pregnant woman: dark skin tone', + char: '\u{1F930}\u{1F3FF}', + shortName: 'pregnant_woman_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'dark skin tone', + 'pregnant', + 'woman', + 'uc9', + 'diversity', + 'women', + 'baby', + 'parent', + 'wife', + 'child', + 'mom', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'woman', + 'female', + 'kid', + 'babies', + 'infant', + 'infants', + 'crying kid', + 'bebe', + 'little', + 'petite', + 'bambino', + 'parents', + 'adult', + 'children', + 'girl', + 'boy', + 'kids', + 'niño', + 'enfant', + 'maman', + 'mommy', + 'mama', + 'mother' + ], + modifiable: true), + Emoji( + name: 'breast-feeding', + char: '\u{1F931}', + shortName: 'breast_feeding', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'baby', + 'breast', + 'nursing', + 'uc10', + 'food', + 'diversity', + 'boobs', + 'women', + 'baby', + 'parent', + 'wife', + 'child', + 'mom', + 'foods', + 'eat', + 'meal', + 'comida', + 'nourriture', + 'eats', + 'groceries', + 'grocery', + 'hungry', + 'tasty', + 'mmm', + 'yummy', + 'feed', + 'hunger', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'boob', + 'tits', + 'tit', + 'breast', + 'woman', + 'female', + 'kid', + 'babies', + 'infant', + 'infants', + 'crying kid', + 'bebe', + 'little', + 'petite', + 'bambino', + 'parents', + 'adult', + 'children', + 'girl', + 'boy', + 'kids', + 'niño', + 'enfant', + 'maman', + 'mommy', + 'mama', + 'mother' + ]), + Emoji( + name: 'breast-feeding: light skin tone', + char: '\u{1F931}\u{1F3FB}', + shortName: 'breast_feeding_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'baby', + 'breast', + 'light skin tone', + 'nursing', + 'uc10', + 'food', + 'diversity', + 'boobs', + 'women', + 'baby', + 'parent', + 'wife', + 'child', + 'mom', + 'foods', + 'eat', + 'meal', + 'comida', + 'nourriture', + 'eats', + 'groceries', + 'grocery', + 'hungry', + 'tasty', + 'mmm', + 'yummy', + 'feed', + 'hunger', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'boob', + 'tits', + 'tit', + 'breast', + 'woman', + 'female', + 'kid', + 'babies', + 'infant', + 'infants', + 'crying kid', + 'bebe', + 'little', + 'petite', + 'bambino', + 'parents', + 'adult', + 'children', + 'girl', + 'boy', + 'kids', + 'niño', + 'enfant', + 'maman', + 'mommy', + 'mama', + 'mother' + ], + modifiable: true), + Emoji( + name: 'breast-feeding: medium-light skin tone', + char: '\u{1F931}\u{1F3FC}', + shortName: 'breast_feeding_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'baby', + 'breast', + 'medium-light skin tone', + 'nursing', + 'uc10', + 'food', + 'diversity', + 'boobs', + 'women', + 'baby', + 'parent', + 'wife', + 'child', + 'mom', + 'foods', + 'eat', + 'meal', + 'comida', + 'nourriture', + 'eats', + 'groceries', + 'grocery', + 'hungry', + 'tasty', + 'mmm', + 'yummy', + 'feed', + 'hunger', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'boob', + 'tits', + 'tit', + 'breast', + 'woman', + 'female', + 'kid', + 'babies', + 'infant', + 'infants', + 'crying kid', + 'bebe', + 'little', + 'petite', + 'bambino', + 'parents', + 'adult', + 'children', + 'girl', + 'boy', + 'kids', + 'niño', + 'enfant', + 'maman', + 'mommy', + 'mama', + 'mother' + ], + modifiable: true), + Emoji( + name: 'breast-feeding: medium skin tone', + char: '\u{1F931}\u{1F3FD}', + shortName: 'breast_feeding_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'baby', + 'breast', + 'medium skin tone', + 'nursing', + 'uc10', + 'food', + 'diversity', + 'boobs', + 'women', + 'baby', + 'parent', + 'wife', + 'child', + 'mom', + 'foods', + 'eat', + 'meal', + 'comida', + 'nourriture', + 'eats', + 'groceries', + 'grocery', + 'hungry', + 'tasty', + 'mmm', + 'yummy', + 'feed', + 'hunger', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'boob', + 'tits', + 'tit', + 'breast', + 'woman', + 'female', + 'kid', + 'babies', + 'infant', + 'infants', + 'crying kid', + 'bebe', + 'little', + 'petite', + 'bambino', + 'parents', + 'adult', + 'children', + 'girl', + 'boy', + 'kids', + 'niño', + 'enfant', + 'maman', + 'mommy', + 'mama', + 'mother' + ], + modifiable: true), + Emoji( + name: 'breast-feeding: medium-dark skin tone', + char: '\u{1F931}\u{1F3FE}', + shortName: 'breast_feeding_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'baby', + 'breast', + 'medium-dark skin tone', + 'nursing', + 'uc10', + 'food', + 'diversity', + 'boobs', + 'women', + 'baby', + 'parent', + 'wife', + 'child', + 'mom', + 'foods', + 'eat', + 'meal', + 'comida', + 'nourriture', + 'eats', + 'groceries', + 'grocery', + 'hungry', + 'tasty', + 'mmm', + 'yummy', + 'feed', + 'hunger', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'boob', + 'tits', + 'tit', + 'breast', + 'woman', + 'female', + 'kid', + 'babies', + 'infant', + 'infants', + 'crying kid', + 'bebe', + 'little', + 'petite', + 'bambino', + 'parents', + 'adult', + 'children', + 'girl', + 'boy', + 'kids', + 'niño', + 'enfant', + 'maman', + 'mommy', + 'mama', + 'mother' + ], + modifiable: true), + Emoji( + name: 'breast-feeding: dark skin tone', + char: '\u{1F931}\u{1F3FF}', + shortName: 'breast_feeding_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'baby', + 'breast', + 'dark skin tone', + 'nursing', + 'uc10', + 'food', + 'diversity', + 'boobs', + 'women', + 'baby', + 'parent', + 'wife', + 'child', + 'mom', + 'foods', + 'eat', + 'meal', + 'comida', + 'nourriture', + 'eats', + 'groceries', + 'grocery', + 'hungry', + 'tasty', + 'mmm', + 'yummy', + 'feed', + 'hunger', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'boob', + 'tits', + 'tit', + 'breast', + 'woman', + 'female', + 'kid', + 'babies', + 'infant', + 'infants', + 'crying kid', + 'bebe', + 'little', + 'petite', + 'bambino', + 'parents', + 'adult', + 'children', + 'girl', + 'boy', + 'kids', + 'niño', + 'enfant', + 'maman', + 'mommy', + 'mama', + 'mother' + ], + modifiable: true), + Emoji( + name: 'person feeding baby', + char: '\u{1F9D1}\u{200D}\u{1F37C}', + shortName: 'person_feeding_baby', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'uc13', + 'food', + 'baby', + 'daddy', + 'parent', + 'wife', + 'child', + 'formula', + 'foods', + 'eat', + 'meal', + 'comida', + 'nourriture', + 'eats', + 'groceries', + 'grocery', + 'hungry', + 'tasty', + 'mmm', + 'yummy', + 'feed', + 'hunger', + 'kid', + 'babies', + 'infant', + 'infants', + 'crying kid', + 'bebe', + 'little', + 'petite', + 'bambino', + 'dad', + 'papa', + 'pere', + 'father', + 'parents', + 'adult', + 'children', + 'girl', + 'boy', + 'kids', + 'niño', + 'enfant' + ]), + Emoji( + name: 'person feeding baby: light skin tone', + char: '\u{1F9D1}\u{1F3FB}\u{200D}\u{1F37C}', + shortName: 'person_feeding_baby_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'uc13', + 'food', + 'baby', + 'daddy', + 'parent', + 'wife', + 'child', + 'formula', + 'foods', + 'eat', + 'meal', + 'comida', + 'nourriture', + 'eats', + 'groceries', + 'grocery', + 'hungry', + 'tasty', + 'mmm', + 'yummy', + 'feed', + 'hunger', + 'kid', + 'babies', + 'infant', + 'infants', + 'crying kid', + 'bebe', + 'little', + 'petite', + 'bambino', + 'dad', + 'papa', + 'pere', + 'father', + 'parents', + 'adult', + 'children', + 'girl', + 'boy', + 'kids', + 'niño', + 'enfant' + ], + modifiable: true), + Emoji( + name: 'person feeding baby: medium-light skin tone', + char: '\u{1F9D1}\u{1F3FC}\u{200D}\u{1F37C}', + shortName: 'person_feeding_baby_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'uc13', + 'food', + 'baby', + 'daddy', + 'parent', + 'wife', + 'child', + 'formula', + 'foods', + 'eat', + 'meal', + 'comida', + 'nourriture', + 'eats', + 'groceries', + 'grocery', + 'hungry', + 'tasty', + 'mmm', + 'yummy', + 'feed', + 'hunger', + 'kid', + 'babies', + 'infant', + 'infants', + 'crying kid', + 'bebe', + 'little', + 'petite', + 'bambino', + 'dad', + 'papa', + 'pere', + 'father', + 'parents', + 'adult', + 'children', + 'girl', + 'boy', + 'kids', + 'niño', + 'enfant' + ], + modifiable: true), + Emoji( + name: 'person feeding baby: medium skin tone', + char: '\u{1F9D1}\u{1F3FD}\u{200D}\u{1F37C}', + shortName: 'person_feeding_baby_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'uc13', + 'food', + 'baby', + 'daddy', + 'parent', + 'wife', + 'child', + 'formula', + 'foods', + 'eat', + 'meal', + 'comida', + 'nourriture', + 'eats', + 'groceries', + 'grocery', + 'hungry', + 'tasty', + 'mmm', + 'yummy', + 'feed', + 'hunger', + 'kid', + 'babies', + 'infant', + 'infants', + 'crying kid', + 'bebe', + 'little', + 'petite', + 'bambino', + 'dad', + 'papa', + 'pere', + 'father', + 'parents', + 'adult', + 'children', + 'girl', + 'boy', + 'kids', + 'niño', + 'enfant' + ], + modifiable: true), + Emoji( + name: 'person feeding baby: medium-dark skin tone', + char: '\u{1F9D1}\u{1F3FE}\u{200D}\u{1F37C}', + shortName: 'person_feeding_baby_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'uc13', + 'food', + 'baby', + 'daddy', + 'parent', + 'wife', + 'child', + 'formula', + 'foods', + 'eat', + 'meal', + 'comida', + 'nourriture', + 'eats', + 'groceries', + 'grocery', + 'hungry', + 'tasty', + 'mmm', + 'yummy', + 'feed', + 'hunger', + 'kid', + 'babies', + 'infant', + 'infants', + 'crying kid', + 'bebe', + 'little', + 'petite', + 'bambino', + 'dad', + 'papa', + 'pere', + 'father', + 'parents', + 'adult', + 'children', + 'girl', + 'boy', + 'kids', + 'niño', + 'enfant' + ], + modifiable: true), + Emoji( + name: 'person feeding baby: dark skin tone', + char: '\u{1F9D1}\u{1F3FF}\u{200D}\u{1F37C}', + shortName: 'person_feeding_baby_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'uc13', + 'food', + 'baby', + 'daddy', + 'parent', + 'wife', + 'child', + 'formula', + 'foods', + 'eat', + 'meal', + 'comida', + 'nourriture', + 'eats', + 'groceries', + 'grocery', + 'hungry', + 'tasty', + 'mmm', + 'yummy', + 'feed', + 'hunger', + 'kid', + 'babies', + 'infant', + 'infants', + 'crying kid', + 'bebe', + 'little', + 'petite', + 'bambino', + 'dad', + 'papa', + 'pere', + 'father', + 'parents', + 'adult', + 'children', + 'girl', + 'boy', + 'kids', + 'niño', + 'enfant' + ], + modifiable: true), + Emoji( + name: 'woman feeding baby', + char: '\u{1F469}\u{200D}\u{1F37C}', + shortName: 'woman_feeding_baby', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'uc13', + 'food', + 'baby', + 'parent', + 'wife', + 'child', + 'mom', + 'formula', + 'foods', + 'eat', + 'meal', + 'comida', + 'nourriture', + 'eats', + 'groceries', + 'grocery', + 'hungry', + 'tasty', + 'mmm', + 'yummy', + 'feed', + 'hunger', + 'kid', + 'babies', + 'infant', + 'infants', + 'crying kid', + 'bebe', + 'little', + 'petite', + 'bambino', + 'parents', + 'adult', + 'children', + 'girl', + 'boy', + 'kids', + 'niño', + 'enfant', + 'maman', + 'mommy', + 'mama', + 'mother' + ]), + Emoji( + name: 'woman feeding baby: light skin tone', + char: '\u{1F469}\u{1F3FB}\u{200D}\u{1F37C}', + shortName: 'woman_feeding_baby_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'uc13', + 'food', + 'baby', + 'parent', + 'wife', + 'child', + 'mom', + 'formula', + 'foods', + 'eat', + 'meal', + 'comida', + 'nourriture', + 'eats', + 'groceries', + 'grocery', + 'hungry', + 'tasty', + 'mmm', + 'yummy', + 'feed', + 'hunger', + 'kid', + 'babies', + 'infant', + 'infants', + 'crying kid', + 'bebe', + 'little', + 'petite', + 'bambino', + 'parents', + 'adult', + 'children', + 'girl', + 'boy', + 'kids', + 'niño', + 'enfant', + 'maman', + 'mommy', + 'mama', + 'mother' + ], + modifiable: true), + Emoji( + name: 'woman feeding baby: medium-light skin tone', + char: '\u{1F469}\u{1F3FC}\u{200D}\u{1F37C}', + shortName: 'woman_feeding_baby_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'uc13', + 'food', + 'baby', + 'parent', + 'wife', + 'child', + 'mom', + 'formula', + 'foods', + 'eat', + 'meal', + 'comida', + 'nourriture', + 'eats', + 'groceries', + 'grocery', + 'hungry', + 'tasty', + 'mmm', + 'yummy', + 'feed', + 'hunger', + 'kid', + 'babies', + 'infant', + 'infants', + 'crying kid', + 'bebe', + 'little', + 'petite', + 'bambino', + 'parents', + 'adult', + 'children', + 'girl', + 'boy', + 'kids', + 'niño', + 'enfant', + 'maman', + 'mommy', + 'mama', + 'mother' + ], + modifiable: true), + Emoji( + name: 'woman feeding baby: medium skin tone', + char: '\u{1F469}\u{1F3FD}\u{200D}\u{1F37C}', + shortName: 'woman_feeding_baby_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'uc13', + 'food', + 'baby', + 'parent', + 'wife', + 'child', + 'mom', + 'formula', + 'foods', + 'eat', + 'meal', + 'comida', + 'nourriture', + 'eats', + 'groceries', + 'grocery', + 'hungry', + 'tasty', + 'mmm', + 'yummy', + 'feed', + 'hunger', + 'kid', + 'babies', + 'infant', + 'infants', + 'crying kid', + 'bebe', + 'little', + 'petite', + 'bambino', + 'parents', + 'adult', + 'children', + 'girl', + 'boy', + 'kids', + 'niño', + 'enfant', + 'maman', + 'mommy', + 'mama', + 'mother' + ], + modifiable: true), + Emoji( + name: 'woman feeding baby: medium-dark skin tone', + char: '\u{1F469}\u{1F3FE}\u{200D}\u{1F37C}', + shortName: 'woman_feeding_baby_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'uc13', + 'food', + 'baby', + 'parent', + 'wife', + 'child', + 'mom', + 'formula', + 'foods', + 'eat', + 'meal', + 'comida', + 'nourriture', + 'eats', + 'groceries', + 'grocery', + 'hungry', + 'tasty', + 'mmm', + 'yummy', + 'feed', + 'hunger', + 'kid', + 'babies', + 'infant', + 'infants', + 'crying kid', + 'bebe', + 'little', + 'petite', + 'bambino', + 'parents', + 'adult', + 'children', + 'girl', + 'boy', + 'kids', + 'niño', + 'enfant', + 'maman', + 'mommy', + 'mama', + 'mother' + ], + modifiable: true), + Emoji( + name: 'woman feeding baby: dark skin tone', + char: '\u{1F469}\u{1F3FF}\u{200D}\u{1F37C}', + shortName: 'woman_feeding_baby_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'uc13', + 'food', + 'baby', + 'parent', + 'wife', + 'child', + 'mom', + 'formula', + 'foods', + 'eat', + 'meal', + 'comida', + 'nourriture', + 'eats', + 'groceries', + 'grocery', + 'hungry', + 'tasty', + 'mmm', + 'yummy', + 'feed', + 'hunger', + 'kid', + 'babies', + 'infant', + 'infants', + 'crying kid', + 'bebe', + 'little', + 'petite', + 'bambino', + 'parents', + 'adult', + 'children', + 'girl', + 'boy', + 'kids', + 'niño', + 'enfant', + 'maman', + 'mommy', + 'mama', + 'mother' + ], + modifiable: true), + Emoji( + name: 'man feeding baby', + char: '\u{1F468}\u{200D}\u{1F37C}', + shortName: 'man_feeding_baby', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'uc13', + 'food', + 'baby', + 'daddy', + 'parent', + 'child', + 'formula', + 'foods', + 'eat', + 'meal', + 'comida', + 'nourriture', + 'eats', + 'groceries', + 'grocery', + 'hungry', + 'tasty', + 'mmm', + 'yummy', + 'feed', + 'hunger', + 'kid', + 'babies', + 'infant', + 'infants', + 'crying kid', + 'bebe', + 'little', + 'petite', + 'bambino', + 'dad', + 'papa', + 'pere', + 'father', + 'parents', + 'adult', + 'children', + 'girl', + 'boy', + 'kids', + 'niño', + 'enfant' + ]), + Emoji( + name: 'man feeding baby: light skin tone', + char: '\u{1F468}\u{1F3FB}\u{200D}\u{1F37C}', + shortName: 'man_feeding_baby_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'uc13', + 'food', + 'baby', + 'daddy', + 'parent', + 'child', + 'formula', + 'foods', + 'eat', + 'meal', + 'comida', + 'nourriture', + 'eats', + 'groceries', + 'grocery', + 'hungry', + 'tasty', + 'mmm', + 'yummy', + 'feed', + 'hunger', + 'kid', + 'babies', + 'infant', + 'infants', + 'crying kid', + 'bebe', + 'little', + 'petite', + 'bambino', + 'dad', + 'papa', + 'pere', + 'father', + 'parents', + 'adult', + 'children', + 'girl', + 'boy', + 'kids', + 'niño', + 'enfant' + ], + modifiable: true), + Emoji( + name: 'man feeding baby: medium-light skin tone', + char: '\u{1F468}\u{1F3FC}\u{200D}\u{1F37C}', + shortName: 'man_feeding_baby_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'uc13', + 'food', + 'baby', + 'daddy', + 'parent', + 'child', + 'formula', + 'foods', + 'eat', + 'meal', + 'comida', + 'nourriture', + 'eats', + 'groceries', + 'grocery', + 'hungry', + 'tasty', + 'mmm', + 'yummy', + 'feed', + 'hunger', + 'kid', + 'babies', + 'infant', + 'infants', + 'crying kid', + 'bebe', + 'little', + 'petite', + 'bambino', + 'dad', + 'papa', + 'pere', + 'father', + 'parents', + 'adult', + 'children', + 'girl', + 'boy', + 'kids', + 'niño', + 'enfant' + ], + modifiable: true), + Emoji( + name: 'man feeding baby: medium skin tone', + char: '\u{1F468}\u{1F3FD}\u{200D}\u{1F37C}', + shortName: 'man_feeding_baby_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'uc13', + 'food', + 'baby', + 'daddy', + 'parent', + 'child', + 'formula', + 'foods', + 'eat', + 'meal', + 'comida', + 'nourriture', + 'eats', + 'groceries', + 'grocery', + 'hungry', + 'tasty', + 'mmm', + 'yummy', + 'feed', + 'hunger', + 'kid', + 'babies', + 'infant', + 'infants', + 'crying kid', + 'bebe', + 'little', + 'petite', + 'bambino', + 'dad', + 'papa', + 'pere', + 'father', + 'parents', + 'adult', + 'children', + 'girl', + 'boy', + 'kids', + 'niño', + 'enfant' + ], + modifiable: true), + Emoji( + name: 'man feeding baby: medium-dark skin tone', + char: '\u{1F468}\u{1F3FE}\u{200D}\u{1F37C}', + shortName: 'man_feeding_baby_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'uc13', + 'food', + 'baby', + 'daddy', + 'parent', + 'child', + 'formula', + 'foods', + 'eat', + 'meal', + 'comida', + 'nourriture', + 'eats', + 'groceries', + 'grocery', + 'hungry', + 'tasty', + 'mmm', + 'yummy', + 'feed', + 'hunger', + 'kid', + 'babies', + 'infant', + 'infants', + 'crying kid', + 'bebe', + 'little', + 'petite', + 'bambino', + 'dad', + 'papa', + 'pere', + 'father', + 'parents', + 'adult', + 'children', + 'girl', + 'boy', + 'kids', + 'niño', + 'enfant' + ], + modifiable: true), + Emoji( + name: 'man feeding baby: dark skin tone', + char: '\u{1F468}\u{1F3FF}\u{200D}\u{1F37C}', + shortName: 'man_feeding_baby_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personRole, + keywords: [ + 'uc13', + 'food', + 'baby', + 'daddy', + 'parent', + 'child', + 'formula', + 'foods', + 'eat', + 'meal', + 'comida', + 'nourriture', + 'eats', + 'groceries', + 'grocery', + 'hungry', + 'tasty', + 'mmm', + 'yummy', + 'feed', + 'hunger', + 'kid', + 'babies', + 'infant', + 'infants', + 'crying kid', + 'bebe', + 'little', + 'petite', + 'bambino', + 'dad', + 'papa', + 'pere', + 'father', + 'parents', + 'adult', + 'children', + 'girl', + 'boy', + 'kids', + 'niño', + 'enfant' + ], + modifiable: true), + Emoji( + name: 'person bowing', + char: '\u{1F647}', + shortName: 'person_bowing', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personGesture, + keywords: [ + 'apology', + 'bow', + 'gesture', + 'sorry', + 'uc6', + 'diversity', + 'thank you', + 'pray', + 'jesus', + 'yoga', + 'begging', + 'fame', + 'idea', + 'hope', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'thanks', + 'thankful', + 'praise', + 'gracias', + 'merci', + 'thankyou', + 'acceptable', + 'prayer', + 'praying', + 'prayers', + 'grateful', + 'sorry', + 'heaven', + 'bless', + 'faith', + 'holy', + 'spirit', + 'hopeful', + 'blessed', + 'preach', + 'offering', + 'meditation', + 'meditate', + 'zen', + 'om', + 'aum', + 'famous', + 'celebrity', + 'swear', + 'promise' + ]), + Emoji( + name: 'person bowing: light skin tone', + char: '\u{1F647}\u{1F3FB}', + shortName: 'person_bowing_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personGesture, + keywords: [ + 'apology', + 'bow', + 'gesture', + 'light skin tone', + 'sorry', + 'uc8', + 'diversity', + 'thank you', + 'pray', + 'jesus', + 'yoga', + 'begging', + 'fame', + 'idea', + 'hope', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'thanks', + 'thankful', + 'praise', + 'gracias', + 'merci', + 'thankyou', + 'acceptable', + 'prayer', + 'praying', + 'prayers', + 'grateful', + 'sorry', + 'heaven', + 'bless', + 'faith', + 'holy', + 'spirit', + 'hopeful', + 'blessed', + 'preach', + 'offering', + 'meditation', + 'meditate', + 'zen', + 'om', + 'aum', + 'famous', + 'celebrity', + 'swear', + 'promise' + ], + modifiable: true), + Emoji( + name: 'person bowing: medium-light skin tone', + char: '\u{1F647}\u{1F3FC}', + shortName: 'person_bowing_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personGesture, + keywords: [ + 'apology', + 'bow', + 'gesture', + 'medium-light skin tone', + 'sorry', + 'uc8', + 'diversity', + 'thank you', + 'pray', + 'jesus', + 'yoga', + 'begging', + 'fame', + 'idea', + 'hope', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'thanks', + 'thankful', + 'praise', + 'gracias', + 'merci', + 'thankyou', + 'acceptable', + 'prayer', + 'praying', + 'prayers', + 'grateful', + 'sorry', + 'heaven', + 'bless', + 'faith', + 'holy', + 'spirit', + 'hopeful', + 'blessed', + 'preach', + 'offering', + 'meditation', + 'meditate', + 'zen', + 'om', + 'aum', + 'famous', + 'celebrity', + 'swear', + 'promise' + ], + modifiable: true), + Emoji( + name: 'person bowing: medium skin tone', + char: '\u{1F647}\u{1F3FD}', + shortName: 'person_bowing_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personGesture, + keywords: [ + 'apology', + 'bow', + 'gesture', + 'medium skin tone', + 'sorry', + 'uc8', + 'diversity', + 'thank you', + 'pray', + 'jesus', + 'yoga', + 'begging', + 'fame', + 'idea', + 'hope', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'thanks', + 'thankful', + 'praise', + 'gracias', + 'merci', + 'thankyou', + 'acceptable', + 'prayer', + 'praying', + 'prayers', + 'grateful', + 'sorry', + 'heaven', + 'bless', + 'faith', + 'holy', + 'spirit', + 'hopeful', + 'blessed', + 'preach', + 'offering', + 'meditation', + 'meditate', + 'zen', + 'om', + 'aum', + 'famous', + 'celebrity', + 'swear', + 'promise' + ], + modifiable: true), + Emoji( + name: 'person bowing: medium-dark skin tone', + char: '\u{1F647}\u{1F3FE}', + shortName: 'person_bowing_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personGesture, + keywords: [ + 'apology', + 'bow', + 'gesture', + 'medium-dark skin tone', + 'sorry', + 'uc8', + 'diversity', + 'thank you', + 'pray', + 'jesus', + 'yoga', + 'begging', + 'fame', + 'idea', + 'hope', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'thanks', + 'thankful', + 'praise', + 'gracias', + 'merci', + 'thankyou', + 'acceptable', + 'prayer', + 'praying', + 'prayers', + 'grateful', + 'sorry', + 'heaven', + 'bless', + 'faith', + 'holy', + 'spirit', + 'hopeful', + 'blessed', + 'preach', + 'offering', + 'meditation', + 'meditate', + 'zen', + 'om', + 'aum', + 'famous', + 'celebrity', + 'swear', + 'promise' + ], + modifiable: true), + Emoji( + name: 'person bowing: dark skin tone', + char: '\u{1F647}\u{1F3FF}', + shortName: 'person_bowing_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personGesture, + keywords: [ + 'apology', + 'bow', + 'dark skin tone', + 'gesture', + 'sorry', + 'uc8', + 'diversity', + 'thank you', + 'pray', + 'jesus', + 'yoga', + 'begging', + 'fame', + 'idea', + 'hope', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'thanks', + 'thankful', + 'praise', + 'gracias', + 'merci', + 'thankyou', + 'acceptable', + 'prayer', + 'praying', + 'prayers', + 'grateful', + 'sorry', + 'heaven', + 'bless', + 'faith', + 'holy', + 'spirit', + 'hopeful', + 'blessed', + 'preach', + 'offering', + 'meditation', + 'meditate', + 'zen', + 'om', + 'aum', + 'famous', + 'celebrity', + 'swear', + 'promise' + ], + modifiable: true), + Emoji( + name: 'woman bowing', + char: '\u{1F647}\u{200D}\u{2640}\u{FE0F}', + shortName: 'woman_bowing', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personGesture, + keywords: [ + 'apology', + 'bowing', + 'favor', + 'gesture', + 'sorry', + 'woman', + 'uc6', + 'diversity', + 'women', + 'thank you', + 'pray', + 'jesus', + 'yoga', + 'fame', + 'idea', + 'hope', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'woman', + 'female', + 'thanks', + 'thankful', + 'praise', + 'gracias', + 'merci', + 'thankyou', + 'acceptable', + 'prayer', + 'praying', + 'prayers', + 'grateful', + 'sorry', + 'heaven', + 'bless', + 'faith', + 'holy', + 'spirit', + 'hopeful', + 'blessed', + 'preach', + 'offering', + 'meditation', + 'meditate', + 'zen', + 'om', + 'aum', + 'famous', + 'celebrity', + 'swear', + 'promise' + ]), + Emoji( + name: 'woman bowing: light skin tone', + char: '\u{1F647}\u{1F3FB}\u{200D}\u{2640}\u{FE0F}', + shortName: 'woman_bowing_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personGesture, + keywords: [ + 'apology', + 'bowing', + 'favor', + 'gesture', + 'light skin tone', + 'sorry', + 'woman', + 'uc8', + 'diversity', + 'women', + 'thank you', + 'pray', + 'jesus', + 'yoga', + 'fame', + 'idea', + 'hope', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'woman', + 'female', + 'thanks', + 'thankful', + 'praise', + 'gracias', + 'merci', + 'thankyou', + 'acceptable', + 'prayer', + 'praying', + 'prayers', + 'grateful', + 'sorry', + 'heaven', + 'bless', + 'faith', + 'holy', + 'spirit', + 'hopeful', + 'blessed', + 'preach', + 'offering', + 'meditation', + 'meditate', + 'zen', + 'om', + 'aum', + 'famous', + 'celebrity', + 'swear', + 'promise' + ], + modifiable: true), + Emoji( + name: 'woman bowing: medium-light skin tone', + char: '\u{1F647}\u{1F3FC}\u{200D}\u{2640}\u{FE0F}', + shortName: 'woman_bowing_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personGesture, + keywords: [ + 'apology', + 'bowing', + 'favor', + 'gesture', + 'medium-light skin tone', + 'sorry', + 'woman', + 'uc8', + 'diversity', + 'women', + 'thank you', + 'pray', + 'jesus', + 'yoga', + 'fame', + 'idea', + 'hope', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'woman', + 'female', + 'thanks', + 'thankful', + 'praise', + 'gracias', + 'merci', + 'thankyou', + 'acceptable', + 'prayer', + 'praying', + 'prayers', + 'grateful', + 'sorry', + 'heaven', + 'bless', + 'faith', + 'holy', + 'spirit', + 'hopeful', + 'blessed', + 'preach', + 'offering', + 'meditation', + 'meditate', + 'zen', + 'om', + 'aum', + 'famous', + 'celebrity', + 'swear', + 'promise' + ], + modifiable: true), + Emoji( + name: 'woman bowing: medium skin tone', + char: '\u{1F647}\u{1F3FD}\u{200D}\u{2640}\u{FE0F}', + shortName: 'woman_bowing_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personGesture, + keywords: [ + 'apology', + 'bowing', + 'favor', + 'gesture', + 'medium skin tone', + 'sorry', + 'woman', + 'uc8', + 'diversity', + 'women', + 'thank you', + 'pray', + 'jesus', + 'yoga', + 'fame', + 'idea', + 'hope', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'woman', + 'female', + 'thanks', + 'thankful', + 'praise', + 'gracias', + 'merci', + 'thankyou', + 'acceptable', + 'prayer', + 'praying', + 'prayers', + 'grateful', + 'sorry', + 'heaven', + 'bless', + 'faith', + 'holy', + 'spirit', + 'hopeful', + 'blessed', + 'preach', + 'offering', + 'meditation', + 'meditate', + 'zen', + 'om', + 'aum', + 'famous', + 'celebrity', + 'swear', + 'promise' + ], + modifiable: true), + Emoji( + name: 'woman bowing: medium-dark skin tone', + char: '\u{1F647}\u{1F3FE}\u{200D}\u{2640}\u{FE0F}', + shortName: 'woman_bowing_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personGesture, + keywords: [ + 'apology', + 'bowing', + 'favor', + 'gesture', + 'medium-dark skin tone', + 'sorry', + 'woman', + 'uc8', + 'diversity', + 'women', + 'thank you', + 'pray', + 'jesus', + 'yoga', + 'fame', + 'idea', + 'hope', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'woman', + 'female', + 'thanks', + 'thankful', + 'praise', + 'gracias', + 'merci', + 'thankyou', + 'acceptable', + 'prayer', + 'praying', + 'prayers', + 'grateful', + 'sorry', + 'heaven', + 'bless', + 'faith', + 'holy', + 'spirit', + 'hopeful', + 'blessed', + 'preach', + 'offering', + 'meditation', + 'meditate', + 'zen', + 'om', + 'aum', + 'famous', + 'celebrity', + 'swear', + 'promise' + ], + modifiable: true), + Emoji( + name: 'woman bowing: dark skin tone', + char: '\u{1F647}\u{1F3FF}\u{200D}\u{2640}\u{FE0F}', + shortName: 'woman_bowing_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personGesture, + keywords: [ + 'apology', + 'bowing', + 'dark skin tone', + 'favor', + 'gesture', + 'sorry', + 'woman', + 'uc8', + 'diversity', + 'women', + 'thank you', + 'pray', + 'jesus', + 'yoga', + 'fame', + 'idea', + 'hope', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'woman', + 'female', + 'thanks', + 'thankful', + 'praise', + 'gracias', + 'merci', + 'thankyou', + 'acceptable', + 'prayer', + 'praying', + 'prayers', + 'grateful', + 'sorry', + 'heaven', + 'bless', + 'faith', + 'holy', + 'spirit', + 'hopeful', + 'blessed', + 'preach', + 'offering', + 'meditation', + 'meditate', + 'zen', + 'om', + 'aum', + 'famous', + 'celebrity', + 'swear', + 'promise' + ], + modifiable: true), + Emoji( + name: 'man bowing', + char: '\u{1F647}\u{200D}\u{2642}\u{FE0F}', + shortName: 'man_bowing', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personGesture, + keywords: [ + 'apology', + 'bowing', + 'favor', + 'gesture', + 'man', + 'sorry', + 'uc6', + 'diversity', + 'thank you', + 'pray', + 'jesus', + 'yoga', + 'fame', + 'idea', + 'hope', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'thanks', + 'thankful', + 'praise', + 'gracias', + 'merci', + 'thankyou', + 'acceptable', + 'prayer', + 'praying', + 'prayers', + 'grateful', + 'sorry', + 'heaven', + 'bless', + 'faith', + 'holy', + 'spirit', + 'hopeful', + 'blessed', + 'preach', + 'offering', + 'meditation', + 'meditate', + 'zen', + 'om', + 'aum', + 'famous', + 'celebrity', + 'swear', + 'promise' + ]), + Emoji( + name: 'man bowing: light skin tone', + char: '\u{1F647}\u{1F3FB}\u{200D}\u{2642}\u{FE0F}', + shortName: 'man_bowing_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personGesture, + keywords: [ + 'apology', + 'bowing', + 'favor', + 'gesture', + 'light skin tone', + 'man', + 'sorry', + 'uc8', + 'diversity', + 'thank you', + 'pray', + 'jesus', + 'yoga', + 'fame', + 'idea', + 'hope', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'thanks', + 'thankful', + 'praise', + 'gracias', + 'merci', + 'thankyou', + 'acceptable', + 'prayer', + 'praying', + 'prayers', + 'grateful', + 'sorry', + 'heaven', + 'bless', + 'faith', + 'holy', + 'spirit', + 'hopeful', + 'blessed', + 'preach', + 'offering', + 'meditation', + 'meditate', + 'zen', + 'om', + 'aum', + 'famous', + 'celebrity', + 'swear', + 'promise' + ], + modifiable: true), + Emoji( + name: 'man bowing: medium-light skin tone', + char: '\u{1F647}\u{1F3FC}\u{200D}\u{2642}\u{FE0F}', + shortName: 'man_bowing_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personGesture, + keywords: [ + 'apology', + 'bowing', + 'favor', + 'gesture', + 'man', + 'medium-light skin tone', + 'sorry', + 'uc8', + 'diversity', + 'thank you', + 'pray', + 'jesus', + 'yoga', + 'fame', + 'idea', + 'hope', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'thanks', + 'thankful', + 'praise', + 'gracias', + 'merci', + 'thankyou', + 'acceptable', + 'prayer', + 'praying', + 'prayers', + 'grateful', + 'sorry', + 'heaven', + 'bless', + 'faith', + 'holy', + 'spirit', + 'hopeful', + 'blessed', + 'preach', + 'offering', + 'meditation', + 'meditate', + 'zen', + 'om', + 'aum', + 'famous', + 'celebrity', + 'swear', + 'promise' + ], + modifiable: true), + Emoji( + name: 'man bowing: medium skin tone', + char: '\u{1F647}\u{1F3FD}\u{200D}\u{2642}\u{FE0F}', + shortName: 'man_bowing_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personGesture, + keywords: [ + 'apology', + 'bowing', + 'favor', + 'gesture', + 'man', + 'medium skin tone', + 'sorry', + 'uc8', + 'diversity', + 'thank you', + 'pray', + 'jesus', + 'yoga', + 'fame', + 'idea', + 'hope', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'thanks', + 'thankful', + 'praise', + 'gracias', + 'merci', + 'thankyou', + 'acceptable', + 'prayer', + 'praying', + 'prayers', + 'grateful', + 'sorry', + 'heaven', + 'bless', + 'faith', + 'holy', + 'spirit', + 'hopeful', + 'blessed', + 'preach', + 'offering', + 'meditation', + 'meditate', + 'zen', + 'om', + 'aum', + 'famous', + 'celebrity', + 'swear', + 'promise' + ], + modifiable: true), + Emoji( + name: 'man bowing: medium-dark skin tone', + char: '\u{1F647}\u{1F3FE}\u{200D}\u{2642}\u{FE0F}', + shortName: 'man_bowing_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personGesture, + keywords: [ + 'apology', + 'bowing', + 'favor', + 'gesture', + 'man', + 'medium-dark skin tone', + 'sorry', + 'uc8', + 'diversity', + 'thank you', + 'pray', + 'jesus', + 'yoga', + 'fame', + 'idea', + 'hope', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'thanks', + 'thankful', + 'praise', + 'gracias', + 'merci', + 'thankyou', + 'acceptable', + 'prayer', + 'praying', + 'prayers', + 'grateful', + 'sorry', + 'heaven', + 'bless', + 'faith', + 'holy', + 'spirit', + 'hopeful', + 'blessed', + 'preach', + 'offering', + 'meditation', + 'meditate', + 'zen', + 'om', + 'aum', + 'famous', + 'celebrity', + 'swear', + 'promise' + ], + modifiable: true), + Emoji( + name: 'man bowing: dark skin tone', + char: '\u{1F647}\u{1F3FF}\u{200D}\u{2642}\u{FE0F}', + shortName: 'man_bowing_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personGesture, + keywords: [ + 'apology', + 'bowing', + 'dark skin tone', + 'favor', + 'gesture', + 'man', + 'sorry', + 'uc8', + 'diversity', + 'thank you', + 'pray', + 'jesus', + 'yoga', + 'fame', + 'idea', + 'hope', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'thanks', + 'thankful', + 'praise', + 'gracias', + 'merci', + 'thankyou', + 'acceptable', + 'prayer', + 'praying', + 'prayers', + 'grateful', + 'sorry', + 'heaven', + 'bless', + 'faith', + 'holy', + 'spirit', + 'hopeful', + 'blessed', + 'preach', + 'offering', + 'meditation', + 'meditate', + 'zen', + 'om', + 'aum', + 'famous', + 'celebrity', + 'swear', + 'promise' + ], + modifiable: true), + Emoji( + name: 'person tipping hand', + char: '\u{1F481}', + shortName: 'person_tipping_hand', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personGesture, + keywords: [ + 'hand', + 'help', + 'information', + 'sassy', + 'tipping', + 'uc6', + 'diversity', + 'men', + 'lipstick', + 'help', + 'mom', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'mouth', + 'mouths', + 'makeup', + 'lip', + 'lips', + 'maman', + 'mommy', + 'mama', + 'mother' + ]), + Emoji( + name: 'person tipping hand: light skin tone', + char: '\u{1F481}\u{1F3FB}', + shortName: 'person_tipping_hand_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personGesture, + keywords: [ + 'hand', + 'help', + 'information', + 'light skin tone', + 'sassy', + 'tipping', + 'uc8', + 'diversity', + 'men', + 'lipstick', + 'help', + 'mom', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'mouth', + 'mouths', + 'makeup', + 'lip', + 'lips', + 'maman', + 'mommy', + 'mama', + 'mother' + ], + modifiable: true), + Emoji( + name: 'person tipping hand: medium-light skin tone', + char: '\u{1F481}\u{1F3FC}', + shortName: 'person_tipping_hand_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personGesture, + keywords: [ + 'hand', + 'help', + 'information', + 'medium-light skin tone', + 'sassy', + 'tipping', + 'uc8', + 'diversity', + 'men', + 'lipstick', + 'help', + 'mom', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'mouth', + 'mouths', + 'makeup', + 'lip', + 'lips', + 'maman', + 'mommy', + 'mama', + 'mother' + ], + modifiable: true), + Emoji( + name: 'person tipping hand: medium skin tone', + char: '\u{1F481}\u{1F3FD}', + shortName: 'person_tipping_hand_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personGesture, + keywords: [ + 'hand', + 'help', + 'information', + 'medium skin tone', + 'sassy', + 'tipping', + 'uc8', + 'diversity', + 'men', + 'lipstick', + 'help', + 'mom', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'mouth', + 'mouths', + 'makeup', + 'lip', + 'lips', + 'maman', + 'mommy', + 'mama', + 'mother' + ], + modifiable: true), + Emoji( + name: 'person tipping hand: medium-dark skin tone', + char: '\u{1F481}\u{1F3FE}', + shortName: 'person_tipping_hand_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personGesture, + keywords: [ + 'hand', + 'help', + 'information', + 'medium-dark skin tone', + 'sassy', + 'tipping', + 'uc8', + 'diversity', + 'men', + 'lipstick', + 'help', + 'mom', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'mouth', + 'mouths', + 'makeup', + 'lip', + 'lips', + 'maman', + 'mommy', + 'mama', + 'mother' + ], + modifiable: true), + Emoji( + name: 'person tipping hand: dark skin tone', + char: '\u{1F481}\u{1F3FF}', + shortName: 'person_tipping_hand_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personGesture, + keywords: [ + 'dark skin tone', + 'hand', + 'help', + 'information', + 'sassy', + 'tipping', + 'uc8', + 'diversity', + 'men', + 'lipstick', + 'help', + 'mom', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'mouth', + 'mouths', + 'makeup', + 'lip', + 'lips', + 'maman', + 'mommy', + 'mama', + 'mother' + ], + modifiable: true), + Emoji( + name: 'woman tipping hand', + char: '\u{1F481}\u{200D}\u{2640}\u{FE0F}', + shortName: 'woman_tipping_hand', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personGesture, + keywords: [ + 'sassy', + 'tipping hand', + 'woman', + 'uc6', + 'diversity', + 'women', + 'help', + 'mom', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'woman', + 'female', + 'maman', + 'mommy', + 'mama', + 'mother' + ]), + Emoji( + name: 'woman tipping hand: light skin tone', + char: '\u{1F481}\u{1F3FB}\u{200D}\u{2640}\u{FE0F}', + shortName: 'woman_tipping_hand_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personGesture, + keywords: [ + 'light skin tone', + 'sassy', + 'tipping hand', + 'woman', + 'uc8', + 'diversity', + 'women', + 'help', + 'mom', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'woman', + 'female', + 'maman', + 'mommy', + 'mama', + 'mother' + ], + modifiable: true), + Emoji( + name: 'woman tipping hand: medium-light skin tone', + char: '\u{1F481}\u{1F3FC}\u{200D}\u{2640}\u{FE0F}', + shortName: 'woman_tipping_hand_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personGesture, + keywords: [ + 'medium-light skin tone', + 'sassy', + 'tipping hand', + 'woman', + 'uc8', + 'diversity', + 'women', + 'help', + 'mom', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'woman', + 'female', + 'maman', + 'mommy', + 'mama', + 'mother' + ], + modifiable: true), + Emoji( + name: 'woman tipping hand: medium skin tone', + char: '\u{1F481}\u{1F3FD}\u{200D}\u{2640}\u{FE0F}', + shortName: 'woman_tipping_hand_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personGesture, + keywords: [ + 'medium skin tone', + 'sassy', + 'tipping hand', + 'woman', + 'uc8', + 'diversity', + 'women', + 'help', + 'mom', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'woman', + 'female', + 'maman', + 'mommy', + 'mama', + 'mother' + ], + modifiable: true), + Emoji( + name: 'woman tipping hand: medium-dark skin tone', + char: '\u{1F481}\u{1F3FE}\u{200D}\u{2640}\u{FE0F}', + shortName: 'woman_tipping_hand_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personGesture, + keywords: [ + 'medium-dark skin tone', + 'sassy', + 'tipping hand', + 'woman', + 'uc8', + 'diversity', + 'women', + 'help', + 'mom', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'woman', + 'female', + 'maman', + 'mommy', + 'mama', + 'mother' + ], + modifiable: true), + Emoji( + name: 'woman tipping hand: dark skin tone', + char: '\u{1F481}\u{1F3FF}\u{200D}\u{2640}\u{FE0F}', + shortName: 'woman_tipping_hand_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personGesture, + keywords: [ + 'dark skin tone', + 'sassy', + 'tipping hand', + 'woman', + 'uc8', + 'diversity', + 'women', + 'help', + 'mom', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'woman', + 'female', + 'maman', + 'mommy', + 'mama', + 'mother' + ], + modifiable: true), + Emoji( + name: 'man tipping hand', + char: '\u{1F481}\u{200D}\u{2642}\u{FE0F}', + shortName: 'man_tipping_hand', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personGesture, + keywords: [ + 'man', + 'sassy', + 'tipping hand', + 'uc6', + 'diversity', + 'men', + 'help', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male' + ]), + Emoji( + name: 'man tipping hand: light skin tone', + char: '\u{1F481}\u{1F3FB}\u{200D}\u{2642}\u{FE0F}', + shortName: 'man_tipping_hand_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personGesture, + keywords: [ + 'light skin tone', + 'man', + 'sassy', + 'tipping hand', + 'uc8', + 'diversity', + 'men', + 'help', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male' + ], + modifiable: true), + Emoji( + name: 'man tipping hand: medium-light skin tone', + char: '\u{1F481}\u{1F3FC}\u{200D}\u{2642}\u{FE0F}', + shortName: 'man_tipping_hand_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personGesture, + keywords: [ + 'man', + 'medium-light skin tone', + 'sassy', + 'tipping hand', + 'uc8', + 'diversity', + 'men', + 'help', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male' + ], + modifiable: true), + Emoji( + name: 'man tipping hand: medium skin tone', + char: '\u{1F481}\u{1F3FD}\u{200D}\u{2642}\u{FE0F}', + shortName: 'man_tipping_hand_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personGesture, + keywords: [ + 'man', + 'medium skin tone', + 'sassy', + 'tipping hand', + 'uc8', + 'diversity', + 'men', + 'help', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male' + ], + modifiable: true), + Emoji( + name: 'man tipping hand: medium-dark skin tone', + char: '\u{1F481}\u{1F3FE}\u{200D}\u{2642}\u{FE0F}', + shortName: 'man_tipping_hand_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personGesture, + keywords: [ + 'man', + 'medium-dark skin tone', + 'sassy', + 'tipping hand', + 'uc8', + 'diversity', + 'men', + 'help', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male' + ], + modifiable: true), + Emoji( + name: 'man tipping hand: dark skin tone', + char: '\u{1F481}\u{1F3FF}\u{200D}\u{2642}\u{FE0F}', + shortName: 'man_tipping_hand_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personGesture, + keywords: [ + 'dark skin tone', + 'man', + 'sassy', + 'tipping hand', + 'uc8', + 'diversity', + 'men', + 'help', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male' + ], + modifiable: true), + Emoji( + name: 'person gesturing NO', + char: '\u{1F645}', + shortName: 'person_gesturing_no', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personGesture, + keywords: [ + 'forbidden', + 'gesture', + 'hand', + 'no', + 'not', + 'prohibited', + 'uc6', + 'diversity', + 'men', + 'angry', + 'girls night', + 'hate', + 'danger', + 'bitch', + 'daddy', + 'crazy', + 'private', + 'mom', + 'never', + 'wrong', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'upset', + 'pissed', + 'pissed off', + 'unhappy', + 'frustrated', + 'anger', + 'rage', + 'frustration', + 'furious', + 'mad', + 'ladies night', + 'girls only', + 'girlfriend', + 'i hate', + 'disgust', + 'stump', + 'shout', + 'dislike', + 'rude', + 'annoy', + 'grinch', + 'gross', + 'grumpy', + 'mean', + 'problem', + 'suck', + 'jerk', + 'asshole', + 'no', + 'warn', + 'attention', + 'caution', + 'alert', + 'error', + 'panic', + 'restricted', + "don't", + 'dont', + 'dangerous', + 'puta', + 'pute', + 'dad', + 'papa', + 'pere', + 'father', + 'weird', + 'awkward', + 'insane', + 'wild', + 'прив', + 'privé', + 'privado', + 'reserved', + 'maman', + 'mommy', + 'mama', + 'mother' + ]), + Emoji( + name: 'person gesturing NO: light skin tone', + char: '\u{1F645}\u{1F3FB}', + shortName: 'person_gesturing_no_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personGesture, + keywords: [ + 'forbidden', + 'gesture', + 'hand', + 'light skin tone', + 'no', + 'not', + 'prohibited', + 'uc8', + 'diversity', + 'men', + 'angry', + 'girls night', + 'hate', + 'danger', + 'bitch', + 'daddy', + 'crazy', + 'private', + 'mom', + 'never', + 'wrong', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'upset', + 'pissed', + 'pissed off', + 'unhappy', + 'frustrated', + 'anger', + 'rage', + 'frustration', + 'furious', + 'mad', + 'ladies night', + 'girls only', + 'girlfriend', + 'i hate', + 'disgust', + 'stump', + 'shout', + 'dislike', + 'rude', + 'annoy', + 'grinch', + 'gross', + 'grumpy', + 'mean', + 'problem', + 'suck', + 'jerk', + 'asshole', + 'no', + 'warn', + 'attention', + 'caution', + 'alert', + 'error', + 'panic', + 'restricted', + "don't", + 'dont', + 'dangerous', + 'puta', + 'pute', + 'dad', + 'papa', + 'pere', + 'father', + 'weird', + 'awkward', + 'insane', + 'wild', + 'прив', + 'privé', + 'privado', + 'reserved', + 'maman', + 'mommy', + 'mama', + 'mother' + ], + modifiable: true), + Emoji( + name: 'person gesturing NO: medium-light skin tone', + char: '\u{1F645}\u{1F3FC}', + shortName: 'person_gesturing_no_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personGesture, + keywords: [ + 'forbidden', + 'gesture', + 'hand', + 'medium-light skin tone', + 'no', + 'not', + 'prohibited', + 'uc8', + 'diversity', + 'men', + 'angry', + 'girls night', + 'hate', + 'danger', + 'bitch', + 'daddy', + 'crazy', + 'private', + 'mom', + 'never', + 'wrong', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'upset', + 'pissed', + 'pissed off', + 'unhappy', + 'frustrated', + 'anger', + 'rage', + 'frustration', + 'furious', + 'mad', + 'ladies night', + 'girls only', + 'girlfriend', + 'i hate', + 'disgust', + 'stump', + 'shout', + 'dislike', + 'rude', + 'annoy', + 'grinch', + 'gross', + 'grumpy', + 'mean', + 'problem', + 'suck', + 'jerk', + 'asshole', + 'no', + 'warn', + 'attention', + 'caution', + 'alert', + 'error', + 'panic', + 'restricted', + "don't", + 'dont', + 'dangerous', + 'puta', + 'pute', + 'dad', + 'papa', + 'pere', + 'father', + 'weird', + 'awkward', + 'insane', + 'wild', + 'прив', + 'privé', + 'privado', + 'reserved', + 'maman', + 'mommy', + 'mama', + 'mother' + ], + modifiable: true), + Emoji( + name: 'person gesturing NO: medium skin tone', + char: '\u{1F645}\u{1F3FD}', + shortName: 'person_gesturing_no_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personGesture, + keywords: [ + 'forbidden', + 'gesture', + 'hand', + 'medium skin tone', + 'no', + 'not', + 'prohibited', + 'uc8', + 'diversity', + 'men', + 'angry', + 'girls night', + 'hate', + 'danger', + 'bitch', + 'daddy', + 'crazy', + 'private', + 'mom', + 'never', + 'wrong', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'upset', + 'pissed', + 'pissed off', + 'unhappy', + 'frustrated', + 'anger', + 'rage', + 'frustration', + 'furious', + 'mad', + 'ladies night', + 'girls only', + 'girlfriend', + 'i hate', + 'disgust', + 'stump', + 'shout', + 'dislike', + 'rude', + 'annoy', + 'grinch', + 'gross', + 'grumpy', + 'mean', + 'problem', + 'suck', + 'jerk', + 'asshole', + 'no', + 'warn', + 'attention', + 'caution', + 'alert', + 'error', + 'panic', + 'restricted', + "don't", + 'dont', + 'dangerous', + 'puta', + 'pute', + 'dad', + 'papa', + 'pere', + 'father', + 'weird', + 'awkward', + 'insane', + 'wild', + 'прив', + 'privé', + 'privado', + 'reserved', + 'maman', + 'mommy', + 'mama', + 'mother' + ], + modifiable: true), + Emoji( + name: 'person gesturing NO: medium-dark skin tone', + char: '\u{1F645}\u{1F3FE}', + shortName: 'person_gesturing_no_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personGesture, + keywords: [ + 'forbidden', + 'gesture', + 'hand', + 'medium-dark skin tone', + 'no', + 'not', + 'prohibited', + 'uc8', + 'diversity', + 'men', + 'angry', + 'girls night', + 'hate', + 'danger', + 'bitch', + 'daddy', + 'crazy', + 'private', + 'mom', + 'never', + 'wrong', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'upset', + 'pissed', + 'pissed off', + 'unhappy', + 'frustrated', + 'anger', + 'rage', + 'frustration', + 'furious', + 'mad', + 'ladies night', + 'girls only', + 'girlfriend', + 'i hate', + 'disgust', + 'stump', + 'shout', + 'dislike', + 'rude', + 'annoy', + 'grinch', + 'gross', + 'grumpy', + 'mean', + 'problem', + 'suck', + 'jerk', + 'asshole', + 'no', + 'warn', + 'attention', + 'caution', + 'alert', + 'error', + 'panic', + 'restricted', + "don't", + 'dont', + 'dangerous', + 'puta', + 'pute', + 'dad', + 'papa', + 'pere', + 'father', + 'weird', + 'awkward', + 'insane', + 'wild', + 'прив', + 'privé', + 'privado', + 'reserved', + 'maman', + 'mommy', + 'mama', + 'mother' + ], + modifiable: true), + Emoji( + name: 'person gesturing NO: dark skin tone', + char: '\u{1F645}\u{1F3FF}', + shortName: 'person_gesturing_no_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personGesture, + keywords: [ + 'dark skin tone', + 'forbidden', + 'gesture', + 'hand', + 'no', + 'not', + 'prohibited', + 'uc8', + 'diversity', + 'men', + 'angry', + 'girls night', + 'hate', + 'danger', + 'bitch', + 'daddy', + 'crazy', + 'private', + 'mom', + 'never', + 'wrong', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'upset', + 'pissed', + 'pissed off', + 'unhappy', + 'frustrated', + 'anger', + 'rage', + 'frustration', + 'furious', + 'mad', + 'ladies night', + 'girls only', + 'girlfriend', + 'i hate', + 'disgust', + 'stump', + 'shout', + 'dislike', + 'rude', + 'annoy', + 'grinch', + 'gross', + 'grumpy', + 'mean', + 'problem', + 'suck', + 'jerk', + 'asshole', + 'no', + 'warn', + 'attention', + 'caution', + 'alert', + 'error', + 'panic', + 'restricted', + "don't", + 'dont', + 'dangerous', + 'puta', + 'pute', + 'dad', + 'papa', + 'pere', + 'father', + 'weird', + 'awkward', + 'insane', + 'wild', + 'прив', + 'privé', + 'privado', + 'reserved', + 'maman', + 'mommy', + 'mama', + 'mother' + ], + modifiable: true), + Emoji( + name: 'woman gesturing NO', + char: '\u{1F645}\u{200D}\u{2640}\u{FE0F}', + shortName: 'woman_gesturing_no', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personGesture, + keywords: [ + 'forbidden', + 'gesture', + 'hand', + 'no', + 'prohibited', + 'woman', + 'uc6', + 'diversity', + 'women', + 'girls night', + 'danger', + 'bitch', + 'crazy', + 'private', + 'mom', + 'never', + 'wrong', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'woman', + 'female', + 'ladies night', + 'girls only', + 'girlfriend', + 'warn', + 'attention', + 'caution', + 'alert', + 'error', + 'panic', + 'restricted', + "don't", + 'dont', + 'dangerous', + 'puta', + 'pute', + 'weird', + 'awkward', + 'insane', + 'wild', + 'прив', + 'privé', + 'privado', + 'reserved', + 'maman', + 'mommy', + 'mama', + 'mother' + ]), + Emoji( + name: 'woman gesturing NO: light skin tone', + char: '\u{1F645}\u{1F3FB}\u{200D}\u{2640}\u{FE0F}', + shortName: 'woman_gesturing_no_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personGesture, + keywords: [ + 'forbidden', + 'gesture', + 'hand', + 'light skin tone', + 'no', + 'prohibited', + 'woman', + 'uc8', + 'diversity', + 'women', + 'girls night', + 'danger', + 'bitch', + 'crazy', + 'private', + 'mom', + 'never', + 'wrong', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'woman', + 'female', + 'ladies night', + 'girls only', + 'girlfriend', + 'warn', + 'attention', + 'caution', + 'alert', + 'error', + 'panic', + 'restricted', + "don't", + 'dont', + 'dangerous', + 'puta', + 'pute', + 'weird', + 'awkward', + 'insane', + 'wild', + 'прив', + 'privé', + 'privado', + 'reserved', + 'maman', + 'mommy', + 'mama', + 'mother' + ], + modifiable: true), + Emoji( + name: 'woman gesturing NO: medium-light skin tone', + char: '\u{1F645}\u{1F3FC}\u{200D}\u{2640}\u{FE0F}', + shortName: 'woman_gesturing_no_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personGesture, + keywords: [ + 'forbidden', + 'gesture', + 'hand', + 'medium-light skin tone', + 'no', + 'prohibited', + 'woman', + 'uc8', + 'diversity', + 'women', + 'girls night', + 'danger', + 'bitch', + 'crazy', + 'private', + 'mom', + 'never', + 'wrong', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'woman', + 'female', + 'ladies night', + 'girls only', + 'girlfriend', + 'warn', + 'attention', + 'caution', + 'alert', + 'error', + 'panic', + 'restricted', + "don't", + 'dont', + 'dangerous', + 'puta', + 'pute', + 'weird', + 'awkward', + 'insane', + 'wild', + 'прив', + 'privé', + 'privado', + 'reserved', + 'maman', + 'mommy', + 'mama', + 'mother' + ], + modifiable: true), + Emoji( + name: 'woman gesturing NO: medium skin tone', + char: '\u{1F645}\u{1F3FD}\u{200D}\u{2640}\u{FE0F}', + shortName: 'woman_gesturing_no_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personGesture, + keywords: [ + 'forbidden', + 'gesture', + 'hand', + 'medium skin tone', + 'no', + 'prohibited', + 'woman', + 'uc8', + 'diversity', + 'women', + 'girls night', + 'danger', + 'bitch', + 'crazy', + 'private', + 'mom', + 'never', + 'wrong', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'woman', + 'female', + 'ladies night', + 'girls only', + 'girlfriend', + 'warn', + 'attention', + 'caution', + 'alert', + 'error', + 'panic', + 'restricted', + "don't", + 'dont', + 'dangerous', + 'puta', + 'pute', + 'weird', + 'awkward', + 'insane', + 'wild', + 'прив', + 'privé', + 'privado', + 'reserved', + 'maman', + 'mommy', + 'mama', + 'mother' + ], + modifiable: true), + Emoji( + name: 'woman gesturing NO: medium-dark skin tone', + char: '\u{1F645}\u{1F3FE}\u{200D}\u{2640}\u{FE0F}', + shortName: 'woman_gesturing_no_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personGesture, + keywords: [ + 'forbidden', + 'gesture', + 'hand', + 'medium-dark skin tone', + 'no', + 'prohibited', + 'woman', + 'uc8', + 'diversity', + 'women', + 'girls night', + 'danger', + 'bitch', + 'crazy', + 'private', + 'mom', + 'never', + 'wrong', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'woman', + 'female', + 'ladies night', + 'girls only', + 'girlfriend', + 'warn', + 'attention', + 'caution', + 'alert', + 'error', + 'panic', + 'restricted', + "don't", + 'dont', + 'dangerous', + 'puta', + 'pute', + 'weird', + 'awkward', + 'insane', + 'wild', + 'прив', + 'privé', + 'privado', + 'reserved', + 'maman', + 'mommy', + 'mama', + 'mother' + ], + modifiable: true), + Emoji( + name: 'woman gesturing NO: dark skin tone', + char: '\u{1F645}\u{1F3FF}\u{200D}\u{2640}\u{FE0F}', + shortName: 'woman_gesturing_no_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personGesture, + keywords: [ + 'dark skin tone', + 'forbidden', + 'gesture', + 'hand', + 'no', + 'prohibited', + 'woman', + 'uc8', + 'diversity', + 'women', + 'girls night', + 'danger', + 'bitch', + 'crazy', + 'private', + 'mom', + 'never', + 'wrong', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'woman', + 'female', + 'ladies night', + 'girls only', + 'girlfriend', + 'warn', + 'attention', + 'caution', + 'alert', + 'error', + 'panic', + 'restricted', + "don't", + 'dont', + 'dangerous', + 'puta', + 'pute', + 'weird', + 'awkward', + 'insane', + 'wild', + 'прив', + 'privé', + 'privado', + 'reserved', + 'maman', + 'mommy', + 'mama', + 'mother' + ], + modifiable: true), + Emoji( + name: 'man gesturing NO', + char: '\u{1F645}\u{200D}\u{2642}\u{FE0F}', + shortName: 'man_gesturing_no', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personGesture, + keywords: [ + 'forbidden', + 'gesture', + 'hand', + 'man', + 'no', + 'prohibited', + 'uc6', + 'diversity', + 'men', + 'angry', + 'hate', + 'danger', + 'daddy', + 'crazy', + 'private', + 'never', + 'wrong', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'upset', + 'pissed', + 'pissed off', + 'unhappy', + 'frustrated', + 'anger', + 'rage', + 'frustration', + 'furious', + 'mad', + 'i hate', + 'disgust', + 'stump', + 'shout', + 'dislike', + 'rude', + 'annoy', + 'grinch', + 'gross', + 'grumpy', + 'mean', + 'problem', + 'suck', + 'jerk', + 'asshole', + 'no', + 'warn', + 'attention', + 'caution', + 'alert', + 'error', + 'panic', + 'restricted', + "don't", + 'dont', + 'dangerous', + 'dad', + 'papa', + 'pere', + 'father', + 'weird', + 'awkward', + 'insane', + 'wild', + 'прив', + 'privé', + 'privado', + 'reserved' + ]), + Emoji( + name: 'man gesturing NO: light skin tone', + char: '\u{1F645}\u{1F3FB}\u{200D}\u{2642}\u{FE0F}', + shortName: 'man_gesturing_no_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personGesture, + keywords: [ + 'forbidden', + 'gesture', + 'hand', + 'light skin tone', + 'man', + 'no', + 'prohibited', + 'uc8', + 'diversity', + 'men', + 'angry', + 'hate', + 'danger', + 'daddy', + 'crazy', + 'private', + 'never', + 'wrong', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'upset', + 'pissed', + 'pissed off', + 'unhappy', + 'frustrated', + 'anger', + 'rage', + 'frustration', + 'furious', + 'mad', + 'i hate', + 'disgust', + 'stump', + 'shout', + 'dislike', + 'rude', + 'annoy', + 'grinch', + 'gross', + 'grumpy', + 'mean', + 'problem', + 'suck', + 'jerk', + 'asshole', + 'no', + 'warn', + 'attention', + 'caution', + 'alert', + 'error', + 'panic', + 'restricted', + "don't", + 'dont', + 'dangerous', + 'dad', + 'papa', + 'pere', + 'father', + 'weird', + 'awkward', + 'insane', + 'wild', + 'прив', + 'privé', + 'privado', + 'reserved' + ], + modifiable: true), + Emoji( + name: 'man gesturing NO: medium-light skin tone', + char: '\u{1F645}\u{1F3FC}\u{200D}\u{2642}\u{FE0F}', + shortName: 'man_gesturing_no_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personGesture, + keywords: [ + 'forbidden', + 'gesture', + 'hand', + 'man', + 'medium-light skin tone', + 'no', + 'prohibited', + 'uc8', + 'diversity', + 'men', + 'angry', + 'hate', + 'danger', + 'daddy', + 'crazy', + 'private', + 'never', + 'wrong', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'upset', + 'pissed', + 'pissed off', + 'unhappy', + 'frustrated', + 'anger', + 'rage', + 'frustration', + 'furious', + 'mad', + 'i hate', + 'disgust', + 'stump', + 'shout', + 'dislike', + 'rude', + 'annoy', + 'grinch', + 'gross', + 'grumpy', + 'mean', + 'problem', + 'suck', + 'jerk', + 'asshole', + 'no', + 'warn', + 'attention', + 'caution', + 'alert', + 'error', + 'panic', + 'restricted', + "don't", + 'dont', + 'dangerous', + 'dad', + 'papa', + 'pere', + 'father', + 'weird', + 'awkward', + 'insane', + 'wild', + 'прив', + 'privé', + 'privado', + 'reserved' + ], + modifiable: true), + Emoji( + name: 'man gesturing NO: medium skin tone', + char: '\u{1F645}\u{1F3FD}\u{200D}\u{2642}\u{FE0F}', + shortName: 'man_gesturing_no_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personGesture, + keywords: [ + 'forbidden', + 'gesture', + 'hand', + 'man', + 'medium skin tone', + 'no', + 'prohibited', + 'uc8', + 'diversity', + 'men', + 'angry', + 'hate', + 'danger', + 'daddy', + 'crazy', + 'private', + 'never', + 'wrong', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'upset', + 'pissed', + 'pissed off', + 'unhappy', + 'frustrated', + 'anger', + 'rage', + 'frustration', + 'furious', + 'mad', + 'i hate', + 'disgust', + 'stump', + 'shout', + 'dislike', + 'rude', + 'annoy', + 'grinch', + 'gross', + 'grumpy', + 'mean', + 'problem', + 'suck', + 'jerk', + 'asshole', + 'no', + 'warn', + 'attention', + 'caution', + 'alert', + 'error', + 'panic', + 'restricted', + "don't", + 'dont', + 'dangerous', + 'dad', + 'papa', + 'pere', + 'father', + 'weird', + 'awkward', + 'insane', + 'wild', + 'прив', + 'privé', + 'privado', + 'reserved' + ], + modifiable: true), + Emoji( + name: 'man gesturing NO: medium-dark skin tone', + char: '\u{1F645}\u{1F3FE}\u{200D}\u{2642}\u{FE0F}', + shortName: 'man_gesturing_no_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personGesture, + keywords: [ + 'forbidden', + 'gesture', + 'hand', + 'man', + 'medium-dark skin tone', + 'no', + 'prohibited', + 'uc8', + 'diversity', + 'men', + 'angry', + 'hate', + 'danger', + 'daddy', + 'crazy', + 'private', + 'never', + 'wrong', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'upset', + 'pissed', + 'pissed off', + 'unhappy', + 'frustrated', + 'anger', + 'rage', + 'frustration', + 'furious', + 'mad', + 'i hate', + 'disgust', + 'stump', + 'shout', + 'dislike', + 'rude', + 'annoy', + 'grinch', + 'gross', + 'grumpy', + 'mean', + 'problem', + 'suck', + 'jerk', + 'asshole', + 'no', + 'warn', + 'attention', + 'caution', + 'alert', + 'error', + 'panic', + 'restricted', + "don't", + 'dont', + 'dangerous', + 'dad', + 'papa', + 'pere', + 'father', + 'weird', + 'awkward', + 'insane', + 'wild', + 'прив', + 'privé', + 'privado', + 'reserved' + ], + modifiable: true), + Emoji( + name: 'man gesturing NO: dark skin tone', + char: '\u{1F645}\u{1F3FF}\u{200D}\u{2642}\u{FE0F}', + shortName: 'man_gesturing_no_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personGesture, + keywords: [ + 'dark skin tone', + 'forbidden', + 'gesture', + 'hand', + 'man', + 'no', + 'prohibited', + 'uc8', + 'diversity', + 'men', + 'angry', + 'hate', + 'danger', + 'daddy', + 'crazy', + 'private', + 'never', + 'wrong', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'upset', + 'pissed', + 'pissed off', + 'unhappy', + 'frustrated', + 'anger', + 'rage', + 'frustration', + 'furious', + 'mad', + 'i hate', + 'disgust', + 'stump', + 'shout', + 'dislike', + 'rude', + 'annoy', + 'grinch', + 'gross', + 'grumpy', + 'mean', + 'problem', + 'suck', + 'jerk', + 'asshole', + 'no', + 'warn', + 'attention', + 'caution', + 'alert', + 'error', + 'panic', + 'restricted', + "don't", + 'dont', + 'dangerous', + 'dad', + 'papa', + 'pere', + 'father', + 'weird', + 'awkward', + 'insane', + 'wild', + 'прив', + 'privé', + 'privado', + 'reserved' + ], + modifiable: true), + Emoji( + name: 'person gesturing OK', + char: '\u{1F646}', + shortName: 'person_gesturing_ok', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personGesture, + keywords: [ + 'OK', + 'gesture', + 'hand', + 'uc6', + 'diversity', + 'men', + 'thank you', + 'awesome', + 'yoga', + 'crazy', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'thanks', + 'thankful', + 'praise', + 'gracias', + 'merci', + 'thankyou', + 'acceptable', + 'okay', + 'got it', + 'cool', + 'ok', + 'will do', + 'like', + 'bien', + 'yep', + 'yup', + 'meditation', + 'meditate', + 'zen', + 'om', + 'aum', + 'weird', + 'awkward', + 'insane', + 'wild', + '*\\0/*', + '\\0/', + '*\\O/*', + '\\O/' + ]), + Emoji( + name: 'person gesturing OK: light skin tone', + char: '\u{1F646}\u{1F3FB}', + shortName: 'person_gesturing_ok_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personGesture, + keywords: [ + 'OK', + 'gesture', + 'hand', + 'light skin tone', + 'uc8', + 'diversity', + 'men', + 'thank you', + 'awesome', + 'yoga', + 'crazy', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'thanks', + 'thankful', + 'praise', + 'gracias', + 'merci', + 'thankyou', + 'acceptable', + 'okay', + 'got it', + 'cool', + 'ok', + 'will do', + 'like', + 'bien', + 'yep', + 'yup', + 'meditation', + 'meditate', + 'zen', + 'om', + 'aum', + 'weird', + 'awkward', + 'insane', + 'wild' + ], + modifiable: true), + Emoji( + name: 'person gesturing OK: medium-light skin tone', + char: '\u{1F646}\u{1F3FC}', + shortName: 'person_gesturing_ok_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personGesture, + keywords: [ + 'OK', + 'gesture', + 'hand', + 'medium-light skin tone', + 'uc8', + 'diversity', + 'men', + 'thank you', + 'awesome', + 'yoga', + 'crazy', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'thanks', + 'thankful', + 'praise', + 'gracias', + 'merci', + 'thankyou', + 'acceptable', + 'okay', + 'got it', + 'cool', + 'ok', + 'will do', + 'like', + 'bien', + 'yep', + 'yup', + 'meditation', + 'meditate', + 'zen', + 'om', + 'aum', + 'weird', + 'awkward', + 'insane', + 'wild' + ], + modifiable: true), + Emoji( + name: 'person gesturing OK: medium skin tone', + char: '\u{1F646}\u{1F3FD}', + shortName: 'person_gesturing_ok_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personGesture, + keywords: [ + 'OK', + 'gesture', + 'hand', + 'medium skin tone', + 'uc8', + 'diversity', + 'men', + 'thank you', + 'awesome', + 'yoga', + 'crazy', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'thanks', + 'thankful', + 'praise', + 'gracias', + 'merci', + 'thankyou', + 'acceptable', + 'okay', + 'got it', + 'cool', + 'ok', + 'will do', + 'like', + 'bien', + 'yep', + 'yup', + 'meditation', + 'meditate', + 'zen', + 'om', + 'aum', + 'weird', + 'awkward', + 'insane', + 'wild' + ], + modifiable: true), + Emoji( + name: 'person gesturing OK: medium-dark skin tone', + char: '\u{1F646}\u{1F3FE}', + shortName: 'person_gesturing_ok_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personGesture, + keywords: [ + 'OK', + 'gesture', + 'hand', + 'medium-dark skin tone', + 'uc8', + 'diversity', + 'men', + 'thank you', + 'awesome', + 'yoga', + 'crazy', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'thanks', + 'thankful', + 'praise', + 'gracias', + 'merci', + 'thankyou', + 'acceptable', + 'okay', + 'got it', + 'cool', + 'ok', + 'will do', + 'like', + 'bien', + 'yep', + 'yup', + 'meditation', + 'meditate', + 'zen', + 'om', + 'aum', + 'weird', + 'awkward', + 'insane', + 'wild' + ], + modifiable: true), + Emoji( + name: 'person gesturing OK: dark skin tone', + char: '\u{1F646}\u{1F3FF}', + shortName: 'person_gesturing_ok_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personGesture, + keywords: [ + 'OK', + 'dark skin tone', + 'gesture', + 'hand', + 'uc8', + 'diversity', + 'men', + 'thank you', + 'awesome', + 'yoga', + 'crazy', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'thanks', + 'thankful', + 'praise', + 'gracias', + 'merci', + 'thankyou', + 'acceptable', + 'okay', + 'got it', + 'cool', + 'ok', + 'will do', + 'like', + 'bien', + 'yep', + 'yup', + 'meditation', + 'meditate', + 'zen', + 'om', + 'aum', + 'weird', + 'awkward', + 'insane', + 'wild' + ], + modifiable: true), + Emoji( + name: 'woman gesturing OK', + char: '\u{1F646}\u{200D}\u{2640}\u{FE0F}', + shortName: 'woman_gesturing_ok', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personGesture, + keywords: [ + 'OK', + 'gesture', + 'hand', + 'woman', + 'uc6', + 'diversity', + 'women', + 'thank you', + 'awesome', + 'yoga', + 'crazy', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'woman', + 'female', + 'thanks', + 'thankful', + 'praise', + 'gracias', + 'merci', + 'thankyou', + 'acceptable', + 'okay', + 'got it', + 'cool', + 'ok', + 'will do', + 'like', + 'bien', + 'yep', + 'yup', + 'meditation', + 'meditate', + 'zen', + 'om', + 'aum', + 'weird', + 'awkward', + 'insane', + 'wild' + ]), + Emoji( + name: 'woman gesturing OK: light skin tone', + char: '\u{1F646}\u{1F3FB}\u{200D}\u{2640}\u{FE0F}', + shortName: 'woman_gesturing_ok_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personGesture, + keywords: [ + 'OK', + 'gesture', + 'hand', + 'light skin tone', + 'woman', + 'uc8', + 'diversity', + 'women', + 'thank you', + 'awesome', + 'yoga', + 'crazy', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'woman', + 'female', + 'thanks', + 'thankful', + 'praise', + 'gracias', + 'merci', + 'thankyou', + 'acceptable', + 'okay', + 'got it', + 'cool', + 'ok', + 'will do', + 'like', + 'bien', + 'yep', + 'yup', + 'meditation', + 'meditate', + 'zen', + 'om', + 'aum', + 'weird', + 'awkward', + 'insane', + 'wild' + ], + modifiable: true), + Emoji( + name: 'woman gesturing OK: medium-light skin tone', + char: '\u{1F646}\u{1F3FC}\u{200D}\u{2640}\u{FE0F}', + shortName: 'woman_gesturing_ok_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personGesture, + keywords: [ + 'OK', + 'gesture', + 'hand', + 'medium-light skin tone', + 'woman', + 'uc8', + 'diversity', + 'women', + 'thank you', + 'awesome', + 'yoga', + 'crazy', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'woman', + 'female', + 'thanks', + 'thankful', + 'praise', + 'gracias', + 'merci', + 'thankyou', + 'acceptable', + 'okay', + 'got it', + 'cool', + 'ok', + 'will do', + 'like', + 'bien', + 'yep', + 'yup', + 'meditation', + 'meditate', + 'zen', + 'om', + 'aum', + 'weird', + 'awkward', + 'insane', + 'wild' + ], + modifiable: true), + Emoji( + name: 'woman gesturing OK: medium skin tone', + char: '\u{1F646}\u{1F3FD}\u{200D}\u{2640}\u{FE0F}', + shortName: 'woman_gesturing_ok_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personGesture, + keywords: [ + 'OK', + 'gesture', + 'hand', + 'medium skin tone', + 'woman', + 'uc8', + 'diversity', + 'women', + 'thank you', + 'awesome', + 'yoga', + 'crazy', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'woman', + 'female', + 'thanks', + 'thankful', + 'praise', + 'gracias', + 'merci', + 'thankyou', + 'acceptable', + 'okay', + 'got it', + 'cool', + 'ok', + 'will do', + 'like', + 'bien', + 'yep', + 'yup', + 'meditation', + 'meditate', + 'zen', + 'om', + 'aum', + 'weird', + 'awkward', + 'insane', + 'wild' + ], + modifiable: true), + Emoji( + name: 'woman gesturing OK: medium-dark skin tone', + char: '\u{1F646}\u{1F3FE}\u{200D}\u{2640}\u{FE0F}', + shortName: 'woman_gesturing_ok_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personGesture, + keywords: [ + 'OK', + 'gesture', + 'hand', + 'medium-dark skin tone', + 'woman', + 'uc8', + 'diversity', + 'women', + 'thank you', + 'awesome', + 'yoga', + 'crazy', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'woman', + 'female', + 'thanks', + 'thankful', + 'praise', + 'gracias', + 'merci', + 'thankyou', + 'acceptable', + 'okay', + 'got it', + 'cool', + 'ok', + 'will do', + 'like', + 'bien', + 'yep', + 'yup', + 'meditation', + 'meditate', + 'zen', + 'om', + 'aum', + 'weird', + 'awkward', + 'insane', + 'wild' + ], + modifiable: true), + Emoji( + name: 'woman gesturing OK: dark skin tone', + char: '\u{1F646}\u{1F3FF}\u{200D}\u{2640}\u{FE0F}', + shortName: 'woman_gesturing_ok_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personGesture, + keywords: [ + 'OK', + 'dark skin tone', + 'gesture', + 'hand', + 'woman', + 'uc8', + 'diversity', + 'women', + 'thank you', + 'awesome', + 'yoga', + 'crazy', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'woman', + 'female', + 'thanks', + 'thankful', + 'praise', + 'gracias', + 'merci', + 'thankyou', + 'acceptable', + 'okay', + 'got it', + 'cool', + 'ok', + 'will do', + 'like', + 'bien', + 'yep', + 'yup', + 'meditation', + 'meditate', + 'zen', + 'om', + 'aum', + 'weird', + 'awkward', + 'insane', + 'wild' + ], + modifiable: true), + Emoji( + name: 'man gesturing OK', + char: '\u{1F646}\u{200D}\u{2642}\u{FE0F}', + shortName: 'man_gesturing_ok', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personGesture, + keywords: [ + 'OK', + 'gesture', + 'hand', + 'man', + 'uc6', + 'diversity', + 'men', + 'thank you', + 'awesome', + 'yoga', + 'crazy', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'thanks', + 'thankful', + 'praise', + 'gracias', + 'merci', + 'thankyou', + 'acceptable', + 'okay', + 'got it', + 'cool', + 'ok', + 'will do', + 'like', + 'bien', + 'yep', + 'yup', + 'meditation', + 'meditate', + 'zen', + 'om', + 'aum', + 'weird', + 'awkward', + 'insane', + 'wild' + ]), + Emoji( + name: 'man gesturing OK: light skin tone', + char: '\u{1F646}\u{1F3FB}\u{200D}\u{2642}\u{FE0F}', + shortName: 'man_gesturing_ok_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personGesture, + keywords: [ + 'OK', + 'gesture', + 'hand', + 'light skin tone', + 'man', + 'uc8', + 'diversity', + 'men', + 'thank you', + 'awesome', + 'yoga', + 'crazy', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'thanks', + 'thankful', + 'praise', + 'gracias', + 'merci', + 'thankyou', + 'acceptable', + 'okay', + 'got it', + 'cool', + 'ok', + 'will do', + 'like', + 'bien', + 'yep', + 'yup', + 'meditation', + 'meditate', + 'zen', + 'om', + 'aum', + 'weird', + 'awkward', + 'insane', + 'wild' + ], + modifiable: true), + Emoji( + name: 'man gesturing OK: medium-light skin tone', + char: '\u{1F646}\u{1F3FC}\u{200D}\u{2642}\u{FE0F}', + shortName: 'man_gesturing_ok_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personGesture, + keywords: [ + 'OK', + 'gesture', + 'hand', + 'man', + 'medium-light skin tone', + 'uc8', + 'diversity', + 'men', + 'thank you', + 'awesome', + 'yoga', + 'crazy', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'thanks', + 'thankful', + 'praise', + 'gracias', + 'merci', + 'thankyou', + 'acceptable', + 'okay', + 'got it', + 'cool', + 'ok', + 'will do', + 'like', + 'bien', + 'yep', + 'yup', + 'meditation', + 'meditate', + 'zen', + 'om', + 'aum', + 'weird', + 'awkward', + 'insane', + 'wild' + ], + modifiable: true), + Emoji( + name: 'man gesturing OK: medium skin tone', + char: '\u{1F646}\u{1F3FD}\u{200D}\u{2642}\u{FE0F}', + shortName: 'man_gesturing_ok_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personGesture, + keywords: [ + 'OK', + 'gesture', + 'hand', + 'man', + 'medium skin tone', + 'uc8', + 'diversity', + 'men', + 'thank you', + 'awesome', + 'yoga', + 'crazy', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'thanks', + 'thankful', + 'praise', + 'gracias', + 'merci', + 'thankyou', + 'acceptable', + 'okay', + 'got it', + 'cool', + 'ok', + 'will do', + 'like', + 'bien', + 'yep', + 'yup', + 'meditation', + 'meditate', + 'zen', + 'om', + 'aum', + 'weird', + 'awkward', + 'insane', + 'wild' + ], + modifiable: true), + Emoji( + name: 'man gesturing OK: medium-dark skin tone', + char: '\u{1F646}\u{1F3FE}\u{200D}\u{2642}\u{FE0F}', + shortName: 'man_gesturing_ok_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personGesture, + keywords: [ + 'OK', + 'gesture', + 'hand', + 'man', + 'medium-dark skin tone', + 'uc8', + 'diversity', + 'men', + 'thank you', + 'awesome', + 'yoga', + 'crazy', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'thanks', + 'thankful', + 'praise', + 'gracias', + 'merci', + 'thankyou', + 'acceptable', + 'okay', + 'got it', + 'cool', + 'ok', + 'will do', + 'like', + 'bien', + 'yep', + 'yup', + 'meditation', + 'meditate', + 'zen', + 'om', + 'aum', + 'weird', + 'awkward', + 'insane', + 'wild' + ], + modifiable: true), + Emoji( + name: 'man gesturing OK: dark skin tone', + char: '\u{1F646}\u{1F3FF}\u{200D}\u{2642}\u{FE0F}', + shortName: 'man_gesturing_ok_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personGesture, + keywords: [ + 'OK', + 'dark skin tone', + 'gesture', + 'hand', + 'man', + 'uc8', + 'diversity', + 'men', + 'thank you', + 'awesome', + 'yoga', + 'crazy', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'thanks', + 'thankful', + 'praise', + 'gracias', + 'merci', + 'thankyou', + 'acceptable', + 'okay', + 'got it', + 'cool', + 'ok', + 'will do', + 'like', + 'bien', + 'yep', + 'yup', + 'meditation', + 'meditate', + 'zen', + 'om', + 'aum', + 'weird', + 'awkward', + 'insane', + 'wild' + ], + modifiable: true), + Emoji( + name: 'person raising hand', + char: '\u{1F64B}', + shortName: 'person_raising_hand', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personGesture, + keywords: [ + 'gesture', + 'hand', + 'happy', + 'raised', + 'uc6', + 'diversity', + 'men', + 'hi', + 'girls night', + 'boys night', + 'celebrate', + 'daddy', + 'help', + 'crazy', + 'mom', + 'proud', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'hello', + 'greeting', + 'bonjour', + 'bye', + 'ciao', + 'adios', + 'goodbye', + 'hey', + 'holla', + 'my name is', + 'salut', + 'welcome', + 'ПРИВЕТ', + 'tu tapelle', + 'ladies night', + 'girls only', + 'girlfriend', + 'guys night', + 'celebration', + 'event', + 'celebrating', + 'festa', + 'parties', + 'events', + 'new years', + 'new year', + 'fiesta', + 'fete', + 'newyear', + 'party', + 'festive', + 'festival', + 'yolo', + 'festejar', + 'dad', + 'papa', + 'pere', + 'father', + 'weird', + 'awkward', + 'insane', + 'wild', + 'maman', + 'mommy', + 'mama', + 'mother' + ]), + Emoji( + name: 'person raising hand: light skin tone', + char: '\u{1F64B}\u{1F3FB}', + shortName: 'person_raising_hand_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personGesture, + keywords: [ + 'gesture', + 'hand', + 'happy', + 'light skin tone', + 'raised', + 'uc8', + 'diversity', + 'men', + 'hi', + 'girls night', + 'boys night', + 'celebrate', + 'daddy', + 'help', + 'crazy', + 'mom', + 'proud', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'hello', + 'greeting', + 'bonjour', + 'bye', + 'ciao', + 'adios', + 'goodbye', + 'hey', + 'holla', + 'my name is', + 'salut', + 'welcome', + 'ПРИВЕТ', + 'tu tapelle', + 'ladies night', + 'girls only', + 'girlfriend', + 'guys night', + 'celebration', + 'event', + 'celebrating', + 'festa', + 'parties', + 'events', + 'new years', + 'new year', + 'fiesta', + 'fete', + 'newyear', + 'party', + 'festive', + 'festival', + 'yolo', + 'festejar', + 'dad', + 'papa', + 'pere', + 'father', + 'weird', + 'awkward', + 'insane', + 'wild', + 'maman', + 'mommy', + 'mama', + 'mother' + ], + modifiable: true), + Emoji( + name: 'person raising hand: medium-light skin tone', + char: '\u{1F64B}\u{1F3FC}', + shortName: 'person_raising_hand_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personGesture, + keywords: [ + 'gesture', + 'hand', + 'happy', + 'medium-light skin tone', + 'raised', + 'uc8', + 'diversity', + 'men', + 'hi', + 'girls night', + 'boys night', + 'celebrate', + 'daddy', + 'help', + 'crazy', + 'mom', + 'proud', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'hello', + 'greeting', + 'bonjour', + 'bye', + 'ciao', + 'adios', + 'goodbye', + 'hey', + 'holla', + 'my name is', + 'salut', + 'welcome', + 'ПРИВЕТ', + 'tu tapelle', + 'ladies night', + 'girls only', + 'girlfriend', + 'guys night', + 'celebration', + 'event', + 'celebrating', + 'festa', + 'parties', + 'events', + 'new years', + 'new year', + 'fiesta', + 'fete', + 'newyear', + 'party', + 'festive', + 'festival', + 'yolo', + 'festejar', + 'dad', + 'papa', + 'pere', + 'father', + 'weird', + 'awkward', + 'insane', + 'wild', + 'maman', + 'mommy', + 'mama', + 'mother' + ], + modifiable: true), + Emoji( + name: 'person raising hand: medium skin tone', + char: '\u{1F64B}\u{1F3FD}', + shortName: 'person_raising_hand_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personGesture, + keywords: [ + 'gesture', + 'hand', + 'happy', + 'medium skin tone', + 'raised', + 'uc8', + 'diversity', + 'men', + 'hi', + 'girls night', + 'boys night', + 'celebrate', + 'daddy', + 'help', + 'crazy', + 'mom', + 'proud', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'hello', + 'greeting', + 'bonjour', + 'bye', + 'ciao', + 'adios', + 'goodbye', + 'hey', + 'holla', + 'my name is', + 'salut', + 'welcome', + 'ПРИВЕТ', + 'tu tapelle', + 'ladies night', + 'girls only', + 'girlfriend', + 'guys night', + 'celebration', + 'event', + 'celebrating', + 'festa', + 'parties', + 'events', + 'new years', + 'new year', + 'fiesta', + 'fete', + 'newyear', + 'party', + 'festive', + 'festival', + 'yolo', + 'festejar', + 'dad', + 'papa', + 'pere', + 'father', + 'weird', + 'awkward', + 'insane', + 'wild', + 'maman', + 'mommy', + 'mama', + 'mother' + ], + modifiable: true), + Emoji( + name: 'person raising hand: medium-dark skin tone', + char: '\u{1F64B}\u{1F3FE}', + shortName: 'person_raising_hand_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personGesture, + keywords: [ + 'gesture', + 'hand', + 'happy', + 'medium-dark skin tone', + 'raised', + 'uc8', + 'diversity', + 'men', + 'hi', + 'girls night', + 'boys night', + 'celebrate', + 'daddy', + 'help', + 'crazy', + 'mom', + 'proud', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'hello', + 'greeting', + 'bonjour', + 'bye', + 'ciao', + 'adios', + 'goodbye', + 'hey', + 'holla', + 'my name is', + 'salut', + 'welcome', + 'ПРИВЕТ', + 'tu tapelle', + 'ladies night', + 'girls only', + 'girlfriend', + 'guys night', + 'celebration', + 'event', + 'celebrating', + 'festa', + 'parties', + 'events', + 'new years', + 'new year', + 'fiesta', + 'fete', + 'newyear', + 'party', + 'festive', + 'festival', + 'yolo', + 'festejar', + 'dad', + 'papa', + 'pere', + 'father', + 'weird', + 'awkward', + 'insane', + 'wild', + 'maman', + 'mommy', + 'mama', + 'mother' + ], + modifiable: true), + Emoji( + name: 'person raising hand: dark skin tone', + char: '\u{1F64B}\u{1F3FF}', + shortName: 'person_raising_hand_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personGesture, + keywords: [ + 'dark skin tone', + 'gesture', + 'hand', + 'happy', + 'raised', + 'uc8', + 'diversity', + 'men', + 'hi', + 'girls night', + 'boys night', + 'celebrate', + 'daddy', + 'help', + 'crazy', + 'mom', + 'proud', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'hello', + 'greeting', + 'bonjour', + 'bye', + 'ciao', + 'adios', + 'goodbye', + 'hey', + 'holla', + 'my name is', + 'salut', + 'welcome', + 'ПРИВЕТ', + 'tu tapelle', + 'ladies night', + 'girls only', + 'girlfriend', + 'guys night', + 'celebration', + 'event', + 'celebrating', + 'festa', + 'parties', + 'events', + 'new years', + 'new year', + 'fiesta', + 'fete', + 'newyear', + 'party', + 'festive', + 'festival', + 'yolo', + 'festejar', + 'dad', + 'papa', + 'pere', + 'father', + 'weird', + 'awkward', + 'insane', + 'wild', + 'maman', + 'mommy', + 'mama', + 'mother' + ], + modifiable: true), + Emoji( + name: 'woman raising hand', + char: '\u{1F64B}\u{200D}\u{2640}\u{FE0F}', + shortName: 'woman_raising_hand', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personGesture, + keywords: [ + 'gesture', + 'raising hand', + 'woman', + 'uc6', + 'diversity', + 'women', + 'hi', + 'girls night', + 'celebrate', + 'help', + 'crazy', + 'mom', + 'proud', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'woman', + 'female', + 'hello', + 'greeting', + 'bonjour', + 'bye', + 'ciao', + 'adios', + 'goodbye', + 'hey', + 'holla', + 'my name is', + 'salut', + 'welcome', + 'ПРИВЕТ', + 'tu tapelle', + 'ladies night', + 'girls only', + 'girlfriend', + 'celebration', + 'event', + 'celebrating', + 'festa', + 'parties', + 'events', + 'new years', + 'new year', + 'fiesta', + 'fete', + 'newyear', + 'party', + 'festive', + 'festival', + 'yolo', + 'festejar', + 'weird', + 'awkward', + 'insane', + 'wild', + 'maman', + 'mommy', + 'mama', + 'mother' + ]), + Emoji( + name: 'woman raising hand: light skin tone', + char: '\u{1F64B}\u{1F3FB}\u{200D}\u{2640}\u{FE0F}', + shortName: 'woman_raising_hand_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personGesture, + keywords: [ + 'gesture', + 'light skin tone', + 'raising hand', + 'woman', + 'uc8', + 'diversity', + 'women', + 'hi', + 'girls night', + 'celebrate', + 'help', + 'crazy', + 'mom', + 'proud', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'woman', + 'female', + 'hello', + 'greeting', + 'bonjour', + 'bye', + 'ciao', + 'adios', + 'goodbye', + 'hey', + 'holla', + 'my name is', + 'salut', + 'welcome', + 'ПРИВЕТ', + 'tu tapelle', + 'ladies night', + 'girls only', + 'girlfriend', + 'celebration', + 'event', + 'celebrating', + 'festa', + 'parties', + 'events', + 'new years', + 'new year', + 'fiesta', + 'fete', + 'newyear', + 'party', + 'festive', + 'festival', + 'yolo', + 'festejar', + 'weird', + 'awkward', + 'insane', + 'wild', + 'maman', + 'mommy', + 'mama', + 'mother' + ], + modifiable: true), + Emoji( + name: 'woman raising hand: medium-light skin tone', + char: '\u{1F64B}\u{1F3FC}\u{200D}\u{2640}\u{FE0F}', + shortName: 'woman_raising_hand_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personGesture, + keywords: [ + 'gesture', + 'medium-light skin tone', + 'raising hand', + 'woman', + 'uc8', + 'diversity', + 'women', + 'hi', + 'girls night', + 'celebrate', + 'help', + 'crazy', + 'mom', + 'proud', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'woman', + 'female', + 'hello', + 'greeting', + 'bonjour', + 'bye', + 'ciao', + 'adios', + 'goodbye', + 'hey', + 'holla', + 'my name is', + 'salut', + 'welcome', + 'ПРИВЕТ', + 'tu tapelle', + 'ladies night', + 'girls only', + 'girlfriend', + 'celebration', + 'event', + 'celebrating', + 'festa', + 'parties', + 'events', + 'new years', + 'new year', + 'fiesta', + 'fete', + 'newyear', + 'party', + 'festive', + 'festival', + 'yolo', + 'festejar', + 'weird', + 'awkward', + 'insane', + 'wild', + 'maman', + 'mommy', + 'mama', + 'mother' + ], + modifiable: true), + Emoji( + name: 'woman raising hand: medium skin tone', + char: '\u{1F64B}\u{1F3FD}\u{200D}\u{2640}\u{FE0F}', + shortName: 'woman_raising_hand_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personGesture, + keywords: [ + 'gesture', + 'medium skin tone', + 'raising hand', + 'woman', + 'uc8', + 'diversity', + 'women', + 'hi', + 'girls night', + 'celebrate', + 'help', + 'crazy', + 'mom', + 'proud', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'woman', + 'female', + 'hello', + 'greeting', + 'bonjour', + 'bye', + 'ciao', + 'adios', + 'goodbye', + 'hey', + 'holla', + 'my name is', + 'salut', + 'welcome', + 'ПРИВЕТ', + 'tu tapelle', + 'ladies night', + 'girls only', + 'girlfriend', + 'celebration', + 'event', + 'celebrating', + 'festa', + 'parties', + 'events', + 'new years', + 'new year', + 'fiesta', + 'fete', + 'newyear', + 'party', + 'festive', + 'festival', + 'yolo', + 'festejar', + 'weird', + 'awkward', + 'insane', + 'wild', + 'maman', + 'mommy', + 'mama', + 'mother' + ], + modifiable: true), + Emoji( + name: 'woman raising hand: medium-dark skin tone', + char: '\u{1F64B}\u{1F3FE}\u{200D}\u{2640}\u{FE0F}', + shortName: 'woman_raising_hand_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personGesture, + keywords: [ + 'gesture', + 'medium-dark skin tone', + 'raising hand', + 'woman', + 'uc8', + 'diversity', + 'women', + 'hi', + 'girls night', + 'celebrate', + 'help', + 'crazy', + 'mom', + 'proud', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'woman', + 'female', + 'hello', + 'greeting', + 'bonjour', + 'bye', + 'ciao', + 'adios', + 'goodbye', + 'hey', + 'holla', + 'my name is', + 'salut', + 'welcome', + 'ПРИВЕТ', + 'tu tapelle', + 'ladies night', + 'girls only', + 'girlfriend', + 'celebration', + 'event', + 'celebrating', + 'festa', + 'parties', + 'events', + 'new years', + 'new year', + 'fiesta', + 'fete', + 'newyear', + 'party', + 'festive', + 'festival', + 'yolo', + 'festejar', + 'weird', + 'awkward', + 'insane', + 'wild', + 'maman', + 'mommy', + 'mama', + 'mother' + ], + modifiable: true), + Emoji( + name: 'woman raising hand: dark skin tone', + char: '\u{1F64B}\u{1F3FF}\u{200D}\u{2640}\u{FE0F}', + shortName: 'woman_raising_hand_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personGesture, + keywords: [ + 'dark skin tone', + 'gesture', + 'raising hand', + 'woman', + 'uc8', + 'diversity', + 'women', + 'hi', + 'girls night', + 'celebrate', + 'help', + 'crazy', + 'mom', + 'proud', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'woman', + 'female', + 'hello', + 'greeting', + 'bonjour', + 'bye', + 'ciao', + 'adios', + 'goodbye', + 'hey', + 'holla', + 'my name is', + 'salut', + 'welcome', + 'ПРИВЕТ', + 'tu tapelle', + 'ladies night', + 'girls only', + 'girlfriend', + 'celebration', + 'event', + 'celebrating', + 'festa', + 'parties', + 'events', + 'new years', + 'new year', + 'fiesta', + 'fete', + 'newyear', + 'party', + 'festive', + 'festival', + 'yolo', + 'festejar', + 'weird', + 'awkward', + 'insane', + 'wild', + 'maman', + 'mommy', + 'mama', + 'mother' + ], + modifiable: true), + Emoji( + name: 'man raising hand', + char: '\u{1F64B}\u{200D}\u{2642}\u{FE0F}', + shortName: 'man_raising_hand', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personGesture, + keywords: [ + 'gesture', + 'man', + 'raising hand', + 'uc6', + 'diversity', + 'men', + 'hi', + 'boys night', + 'celebrate', + 'daddy', + 'help', + 'crazy', + 'proud', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'hello', + 'greeting', + 'bonjour', + 'bye', + 'ciao', + 'adios', + 'goodbye', + 'hey', + 'holla', + 'my name is', + 'salut', + 'welcome', + 'ПРИВЕТ', + 'tu tapelle', + 'guys night', + 'celebration', + 'event', + 'celebrating', + 'festa', + 'parties', + 'events', + 'new years', + 'new year', + 'fiesta', + 'fete', + 'newyear', + 'party', + 'festive', + 'festival', + 'yolo', + 'festejar', + 'dad', + 'papa', + 'pere', + 'father', + 'weird', + 'awkward', + 'insane', + 'wild' + ]), + Emoji( + name: 'man raising hand: light skin tone', + char: '\u{1F64B}\u{1F3FB}\u{200D}\u{2642}\u{FE0F}', + shortName: 'man_raising_hand_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personGesture, + keywords: [ + 'gesture', + 'light skin tone', + 'man', + 'raising hand', + 'uc8', + 'diversity', + 'men', + 'hi', + 'boys night', + 'celebrate', + 'daddy', + 'help', + 'crazy', + 'proud', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'hello', + 'greeting', + 'bonjour', + 'bye', + 'ciao', + 'adios', + 'goodbye', + 'hey', + 'holla', + 'my name is', + 'salut', + 'welcome', + 'ПРИВЕТ', + 'tu tapelle', + 'guys night', + 'celebration', + 'event', + 'celebrating', + 'festa', + 'parties', + 'events', + 'new years', + 'new year', + 'fiesta', + 'fete', + 'newyear', + 'party', + 'festive', + 'festival', + 'yolo', + 'festejar', + 'dad', + 'papa', + 'pere', + 'father', + 'weird', + 'awkward', + 'insane', + 'wild' + ], + modifiable: true), + Emoji( + name: 'man raising hand: medium-light skin tone', + char: '\u{1F64B}\u{1F3FC}\u{200D}\u{2642}\u{FE0F}', + shortName: 'man_raising_hand_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personGesture, + keywords: [ + 'gesture', + 'man', + 'medium-light skin tone', + 'raising hand', + 'uc8', + 'diversity', + 'men', + 'hi', + 'boys night', + 'celebrate', + 'daddy', + 'help', + 'crazy', + 'proud', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'hello', + 'greeting', + 'bonjour', + 'bye', + 'ciao', + 'adios', + 'goodbye', + 'hey', + 'holla', + 'my name is', + 'salut', + 'welcome', + 'ПРИВЕТ', + 'tu tapelle', + 'guys night', + 'celebration', + 'event', + 'celebrating', + 'festa', + 'parties', + 'events', + 'new years', + 'new year', + 'fiesta', + 'fete', + 'newyear', + 'party', + 'festive', + 'festival', + 'yolo', + 'festejar', + 'dad', + 'papa', + 'pere', + 'father', + 'weird', + 'awkward', + 'insane', + 'wild' + ], + modifiable: true), + Emoji( + name: 'man raising hand: medium skin tone', + char: '\u{1F64B}\u{1F3FD}\u{200D}\u{2642}\u{FE0F}', + shortName: 'man_raising_hand_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personGesture, + keywords: [ + 'gesture', + 'man', + 'medium skin tone', + 'raising hand', + 'uc8', + 'diversity', + 'men', + 'hi', + 'boys night', + 'celebrate', + 'daddy', + 'help', + 'crazy', + 'proud', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'hello', + 'greeting', + 'bonjour', + 'bye', + 'ciao', + 'adios', + 'goodbye', + 'hey', + 'holla', + 'my name is', + 'salut', + 'welcome', + 'ПРИВЕТ', + 'tu tapelle', + 'guys night', + 'celebration', + 'event', + 'celebrating', + 'festa', + 'parties', + 'events', + 'new years', + 'new year', + 'fiesta', + 'fete', + 'newyear', + 'party', + 'festive', + 'festival', + 'yolo', + 'festejar', + 'dad', + 'papa', + 'pere', + 'father', + 'weird', + 'awkward', + 'insane', + 'wild' + ], + modifiable: true), + Emoji( + name: 'man raising hand: medium-dark skin tone', + char: '\u{1F64B}\u{1F3FE}\u{200D}\u{2642}\u{FE0F}', + shortName: 'man_raising_hand_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personGesture, + keywords: [ + 'gesture', + 'man', + 'medium-dark skin tone', + 'raising hand', + 'uc8', + 'diversity', + 'men', + 'hi', + 'boys night', + 'celebrate', + 'daddy', + 'help', + 'crazy', + 'proud', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'hello', + 'greeting', + 'bonjour', + 'bye', + 'ciao', + 'adios', + 'goodbye', + 'hey', + 'holla', + 'my name is', + 'salut', + 'welcome', + 'ПРИВЕТ', + 'tu tapelle', + 'guys night', + 'celebration', + 'event', + 'celebrating', + 'festa', + 'parties', + 'events', + 'new years', + 'new year', + 'fiesta', + 'fete', + 'newyear', + 'party', + 'festive', + 'festival', + 'yolo', + 'festejar', + 'dad', + 'papa', + 'pere', + 'father', + 'weird', + 'awkward', + 'insane', + 'wild' + ], + modifiable: true), + Emoji( + name: 'man raising hand: dark skin tone', + char: '\u{1F64B}\u{1F3FF}\u{200D}\u{2642}\u{FE0F}', + shortName: 'man_raising_hand_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personGesture, + keywords: [ + 'dark skin tone', + 'gesture', + 'man', + 'raising hand', + 'uc8', + 'diversity', + 'men', + 'hi', + 'boys night', + 'celebrate', + 'daddy', + 'help', + 'crazy', + 'proud', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'hello', + 'greeting', + 'bonjour', + 'bye', + 'ciao', + 'adios', + 'goodbye', + 'hey', + 'holla', + 'my name is', + 'salut', + 'welcome', + 'ПРИВЕТ', + 'tu tapelle', + 'guys night', + 'celebration', + 'event', + 'celebrating', + 'festa', + 'parties', + 'events', + 'new years', + 'new year', + 'fiesta', + 'fete', + 'newyear', + 'party', + 'festive', + 'festival', + 'yolo', + 'festejar', + 'dad', + 'papa', + 'pere', + 'father', + 'weird', + 'awkward', + 'insane', + 'wild' + ], + modifiable: true), + Emoji( + name: 'deaf person', + char: '\u{1F9CF}', + shortName: 'deaf_person', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personGesture, + keywords: [ + 'uc12', + 'old people', + 'diversity', + 'handicap', + 'quiet', + 'sound', + 'deaf', + 'accessibility', + 'grandparents', + 'elderly', + 'grandma', + 'grandpa', + 'grandmother', + 'grandfather', + 'mamie', + 'papy', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'disabled', + 'disability', + 'shut up', + 'hushed', + 'silence', + 'silent', + 'shush', + 'shh', + 'volume', + 'speaker', + 'loud', + 'mic', + 'audio', + 'hear', + 'hard of hearing' + ]), + Emoji( + name: 'deaf person: light skin tone', + char: '\u{1F9CF}\u{1F3FB}', + shortName: 'deaf_person_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personGesture, + keywords: [ + 'uc12', + 'old people', + 'diversity', + 'handicap', + 'quiet', + 'sound', + 'deaf', + 'accessibility', + 'grandparents', + 'elderly', + 'grandma', + 'grandpa', + 'grandmother', + 'grandfather', + 'mamie', + 'papy', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'disabled', + 'disability', + 'shut up', + 'hushed', + 'silence', + 'silent', + 'shush', + 'shh', + 'volume', + 'speaker', + 'loud', + 'mic', + 'audio', + 'hear', + 'hard of hearing' + ], + modifiable: true), + Emoji( + name: 'deaf person: medium-light skin tone', + char: '\u{1F9CF}\u{1F3FC}', + shortName: 'deaf_person_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personGesture, + keywords: [ + 'uc12', + 'old people', + 'diversity', + 'handicap', + 'quiet', + 'sound', + 'deaf', + 'accessibility', + 'grandparents', + 'elderly', + 'grandma', + 'grandpa', + 'grandmother', + 'grandfather', + 'mamie', + 'papy', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'disabled', + 'disability', + 'shut up', + 'hushed', + 'silence', + 'silent', + 'shush', + 'shh', + 'volume', + 'speaker', + 'loud', + 'mic', + 'audio', + 'hear', + 'hard of hearing' + ], + modifiable: true), + Emoji( + name: 'deaf person: medium skin tone', + char: '\u{1F9CF}\u{1F3FD}', + shortName: 'deaf_person_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personGesture, + keywords: [ + 'uc12', + 'old people', + 'diversity', + 'handicap', + 'quiet', + 'sound', + 'deaf', + 'accessibility', + 'grandparents', + 'elderly', + 'grandma', + 'grandpa', + 'grandmother', + 'grandfather', + 'mamie', + 'papy', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'disabled', + 'disability', + 'shut up', + 'hushed', + 'silence', + 'silent', + 'shush', + 'shh', + 'volume', + 'speaker', + 'loud', + 'mic', + 'audio', + 'hear', + 'hard of hearing' + ], + modifiable: true), + Emoji( + name: 'deaf person: medium-dark skin tone', + char: '\u{1F9CF}\u{1F3FE}', + shortName: 'deaf_person_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personGesture, + keywords: [ + 'uc12', + 'old people', + 'diversity', + 'handicap', + 'quiet', + 'sound', + 'deaf', + 'accessibility', + 'grandparents', + 'elderly', + 'grandma', + 'grandpa', + 'grandmother', + 'grandfather', + 'mamie', + 'papy', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'disabled', + 'disability', + 'shut up', + 'hushed', + 'silence', + 'silent', + 'shush', + 'shh', + 'volume', + 'speaker', + 'loud', + 'mic', + 'audio', + 'hear', + 'hard of hearing' + ], + modifiable: true), + Emoji( + name: 'deaf person: dark skin tone', + char: '\u{1F9CF}\u{1F3FF}', + shortName: 'deaf_person_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personGesture, + keywords: [ + 'uc12', + 'old people', + 'diversity', + 'handicap', + 'quiet', + 'sound', + 'deaf', + 'accessibility', + 'grandparents', + 'elderly', + 'grandma', + 'grandpa', + 'grandmother', + 'grandfather', + 'mamie', + 'papy', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'disabled', + 'disability', + 'shut up', + 'hushed', + 'silence', + 'silent', + 'shush', + 'shh', + 'volume', + 'speaker', + 'loud', + 'mic', + 'audio', + 'hear', + 'hard of hearing' + ], + modifiable: true), + Emoji( + name: 'deaf woman', + char: '\u{1F9CF}\u{200D}\u{2640}\u{FE0F}', + shortName: 'deaf_woman', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personGesture, + keywords: [ + 'uc12', + 'old people', + 'diversity', + 'women', + 'handicap', + 'quiet', + 'sound', + 'deaf', + 'accessibility', + 'grandparents', + 'elderly', + 'grandma', + 'grandpa', + 'grandmother', + 'grandfather', + 'mamie', + 'papy', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'woman', + 'female', + 'disabled', + 'disability', + 'shut up', + 'hushed', + 'silence', + 'silent', + 'shush', + 'shh', + 'volume', + 'speaker', + 'loud', + 'mic', + 'audio', + 'hear', + 'hard of hearing' + ]), + Emoji( + name: 'deaf woman: light skin tone', + char: '\u{1F9CF}\u{1F3FB}\u{200D}\u{2640}\u{FE0F}', + shortName: 'deaf_woman_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personGesture, + keywords: [ + 'uc12', + 'old people', + 'diversity', + 'women', + 'handicap', + 'quiet', + 'sound', + 'deaf', + 'accessibility', + 'grandparents', + 'elderly', + 'grandma', + 'grandpa', + 'grandmother', + 'grandfather', + 'mamie', + 'papy', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'woman', + 'female', + 'disabled', + 'disability', + 'shut up', + 'hushed', + 'silence', + 'silent', + 'shush', + 'shh', + 'volume', + 'speaker', + 'loud', + 'mic', + 'audio', + 'hear', + 'hard of hearing' + ], + modifiable: true), + Emoji( + name: 'deaf woman: medium-light skin tone', + char: '\u{1F9CF}\u{1F3FC}\u{200D}\u{2640}\u{FE0F}', + shortName: 'deaf_woman_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personGesture, + keywords: [ + 'uc12', + 'old people', + 'diversity', + 'women', + 'handicap', + 'quiet', + 'sound', + 'deaf', + 'accessibility', + 'grandparents', + 'elderly', + 'grandma', + 'grandpa', + 'grandmother', + 'grandfather', + 'mamie', + 'papy', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'woman', + 'female', + 'disabled', + 'disability', + 'shut up', + 'hushed', + 'silence', + 'silent', + 'shush', + 'shh', + 'volume', + 'speaker', + 'loud', + 'mic', + 'audio', + 'hear', + 'hard of hearing' + ], + modifiable: true), + Emoji( + name: 'deaf woman: medium skin tone', + char: '\u{1F9CF}\u{1F3FD}\u{200D}\u{2640}\u{FE0F}', + shortName: 'deaf_woman_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personGesture, + keywords: [ + 'uc12', + 'old people', + 'diversity', + 'women', + 'handicap', + 'quiet', + 'sound', + 'deaf', + 'accessibility', + 'grandparents', + 'elderly', + 'grandma', + 'grandpa', + 'grandmother', + 'grandfather', + 'mamie', + 'papy', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'woman', + 'female', + 'disabled', + 'disability', + 'shut up', + 'hushed', + 'silence', + 'silent', + 'shush', + 'shh', + 'volume', + 'speaker', + 'loud', + 'mic', + 'audio', + 'hear', + 'hard of hearing' + ], + modifiable: true), + Emoji( + name: 'deaf woman: medium-dark skin tone', + char: '\u{1F9CF}\u{1F3FE}\u{200D}\u{2640}\u{FE0F}', + shortName: 'deaf_woman_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personGesture, + keywords: [ + 'uc12', + 'old people', + 'diversity', + 'women', + 'handicap', + 'quiet', + 'sound', + 'deaf', + 'accessibility', + 'grandparents', + 'elderly', + 'grandma', + 'grandpa', + 'grandmother', + 'grandfather', + 'mamie', + 'papy', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'woman', + 'female', + 'disabled', + 'disability', + 'shut up', + 'hushed', + 'silence', + 'silent', + 'shush', + 'shh', + 'volume', + 'speaker', + 'loud', + 'mic', + 'audio', + 'hear', + 'hard of hearing' + ], + modifiable: true), + Emoji( + name: 'deaf woman: dark skin tone', + char: '\u{1F9CF}\u{1F3FF}\u{200D}\u{2640}\u{FE0F}', + shortName: 'deaf_woman_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personGesture, + keywords: [ + 'uc12', + 'old people', + 'diversity', + 'women', + 'handicap', + 'quiet', + 'sound', + 'deaf', + 'accessibility', + 'grandparents', + 'elderly', + 'grandma', + 'grandpa', + 'grandmother', + 'grandfather', + 'mamie', + 'papy', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'woman', + 'female', + 'disabled', + 'disability', + 'shut up', + 'hushed', + 'silence', + 'silent', + 'shush', + 'shh', + 'volume', + 'speaker', + 'loud', + 'mic', + 'audio', + 'hear', + 'hard of hearing' + ], + modifiable: true), + Emoji( + name: 'deaf man', + char: '\u{1F9CF}\u{200D}\u{2642}\u{FE0F}', + shortName: 'deaf_man', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personGesture, + keywords: [ + 'uc12', + 'old people', + 'diversity', + 'handicap', + 'quiet', + 'sound', + 'deaf', + 'accessibility', + 'grandparents', + 'elderly', + 'grandma', + 'grandpa', + 'grandmother', + 'grandfather', + 'mamie', + 'papy', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'disabled', + 'disability', + 'shut up', + 'hushed', + 'silence', + 'silent', + 'shush', + 'shh', + 'volume', + 'speaker', + 'loud', + 'mic', + 'audio', + 'hear', + 'hard of hearing' + ]), + Emoji( + name: 'deaf man: light skin tone', + char: '\u{1F9CF}\u{1F3FB}\u{200D}\u{2642}\u{FE0F}', + shortName: 'deaf_man_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personGesture, + keywords: [ + 'uc12', + 'old people', + 'diversity', + 'handicap', + 'quiet', + 'sound', + 'deaf', + 'accessibility', + 'grandparents', + 'elderly', + 'grandma', + 'grandpa', + 'grandmother', + 'grandfather', + 'mamie', + 'papy', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'disabled', + 'disability', + 'shut up', + 'hushed', + 'silence', + 'silent', + 'shush', + 'shh', + 'volume', + 'speaker', + 'loud', + 'mic', + 'audio', + 'hear', + 'hard of hearing' + ], + modifiable: true), + Emoji( + name: 'deaf man: medium-light skin tone', + char: '\u{1F9CF}\u{1F3FC}\u{200D}\u{2642}\u{FE0F}', + shortName: 'deaf_man_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personGesture, + keywords: [ + 'uc12', + 'old people', + 'diversity', + 'handicap', + 'quiet', + 'sound', + 'deaf', + 'accessibility', + 'grandparents', + 'elderly', + 'grandma', + 'grandpa', + 'grandmother', + 'grandfather', + 'mamie', + 'papy', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'disabled', + 'disability', + 'shut up', + 'hushed', + 'silence', + 'silent', + 'shush', + 'shh', + 'volume', + 'speaker', + 'loud', + 'mic', + 'audio', + 'hear', + 'hard of hearing' + ], + modifiable: true), + Emoji( + name: 'deaf man: medium skin tone', + char: '\u{1F9CF}\u{1F3FD}\u{200D}\u{2642}\u{FE0F}', + shortName: 'deaf_man_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personGesture, + keywords: [ + 'uc12', + 'old people', + 'diversity', + 'handicap', + 'quiet', + 'sound', + 'deaf', + 'accessibility', + 'grandparents', + 'elderly', + 'grandma', + 'grandpa', + 'grandmother', + 'grandfather', + 'mamie', + 'papy', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'disabled', + 'disability', + 'shut up', + 'hushed', + 'silence', + 'silent', + 'shush', + 'shh', + 'volume', + 'speaker', + 'loud', + 'mic', + 'audio', + 'hear', + 'hard of hearing' + ], + modifiable: true), + Emoji( + name: 'deaf man: medium-dark skin tone', + char: '\u{1F9CF}\u{1F3FE}\u{200D}\u{2642}\u{FE0F}', + shortName: 'deaf_man_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personGesture, + keywords: [ + 'uc12', + 'old people', + 'diversity', + 'handicap', + 'quiet', + 'sound', + 'deaf', + 'accessibility', + 'grandparents', + 'elderly', + 'grandma', + 'grandpa', + 'grandmother', + 'grandfather', + 'mamie', + 'papy', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'disabled', + 'disability', + 'shut up', + 'hushed', + 'silence', + 'silent', + 'shush', + 'shh', + 'volume', + 'speaker', + 'loud', + 'mic', + 'audio', + 'hear', + 'hard of hearing' + ], + modifiable: true), + Emoji( + name: 'deaf man: dark skin tone', + char: '\u{1F9CF}\u{1F3FF}\u{200D}\u{2642}\u{FE0F}', + shortName: 'deaf_man_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personGesture, + keywords: [ + 'uc12', + 'old people', + 'diversity', + 'handicap', + 'quiet', + 'sound', + 'deaf', + 'accessibility', + 'grandparents', + 'elderly', + 'grandma', + 'grandpa', + 'grandmother', + 'grandfather', + 'mamie', + 'papy', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'disabled', + 'disability', + 'shut up', + 'hushed', + 'silence', + 'silent', + 'shush', + 'shh', + 'volume', + 'speaker', + 'loud', + 'mic', + 'audio', + 'hear', + 'hard of hearing' + ], + modifiable: true), + Emoji( + name: 'person facepalming', + char: '\u{1F926}', + shortName: 'person_facepalming', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personGesture, + keywords: [ + 'disbelief', + 'exasperation', + 'face', + 'palm', + 'uc9', + 'diversity', + 'men', + 'stressed', + 'boys night', + 'facepalm', + 'dumb', + 'las vegas', + 'crazy', + 'wrong', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'guys night', + 'whoops', + 'oops', + 'mistake', + 'idiot', + 'ignorant', + 'stupid', + 'vegas', + 'weird', + 'awkward', + 'insane', + 'wild' + ]), + Emoji( + name: 'person facepalming: light skin tone', + char: '\u{1F926}\u{1F3FB}', + shortName: 'person_facepalming_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personGesture, + keywords: [ + 'disbelief', + 'exasperation', + 'face', + 'light skin tone', + 'palm', + 'uc9', + 'diversity', + 'men', + 'stressed', + 'boys night', + 'facepalm', + 'dumb', + 'las vegas', + 'crazy', + 'wrong', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'guys night', + 'whoops', + 'oops', + 'mistake', + 'idiot', + 'ignorant', + 'stupid', + 'vegas', + 'weird', + 'awkward', + 'insane', + 'wild' + ], + modifiable: true), + Emoji( + name: 'person facepalming: medium-light skin tone', + char: '\u{1F926}\u{1F3FC}', + shortName: 'person_facepalming_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personGesture, + keywords: [ + 'disbelief', + 'exasperation', + 'face', + 'medium-light skin tone', + 'palm', + 'uc9', + 'diversity', + 'men', + 'stressed', + 'boys night', + 'facepalm', + 'dumb', + 'las vegas', + 'crazy', + 'wrong', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'guys night', + 'whoops', + 'oops', + 'mistake', + 'idiot', + 'ignorant', + 'stupid', + 'vegas', + 'weird', + 'awkward', + 'insane', + 'wild' + ], + modifiable: true), + Emoji( + name: 'person facepalming: medium skin tone', + char: '\u{1F926}\u{1F3FD}', + shortName: 'person_facepalming_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personGesture, + keywords: [ + 'disbelief', + 'exasperation', + 'face', + 'medium skin tone', + 'palm', + 'uc9', + 'diversity', + 'men', + 'stressed', + 'boys night', + 'facepalm', + 'dumb', + 'las vegas', + 'crazy', + 'wrong', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'guys night', + 'whoops', + 'oops', + 'mistake', + 'idiot', + 'ignorant', + 'stupid', + 'vegas', + 'weird', + 'awkward', + 'insane', + 'wild' + ], + modifiable: true), + Emoji( + name: 'person facepalming: medium-dark skin tone', + char: '\u{1F926}\u{1F3FE}', + shortName: 'person_facepalming_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personGesture, + keywords: [ + 'disbelief', + 'exasperation', + 'face', + 'medium-dark skin tone', + 'palm', + 'uc9', + 'diversity', + 'men', + 'stressed', + 'boys night', + 'facepalm', + 'dumb', + 'las vegas', + 'crazy', + 'wrong', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'guys night', + 'whoops', + 'oops', + 'mistake', + 'idiot', + 'ignorant', + 'stupid', + 'vegas', + 'weird', + 'awkward', + 'insane', + 'wild' + ], + modifiable: true), + Emoji( + name: 'person facepalming: dark skin tone', + char: '\u{1F926}\u{1F3FF}', + shortName: 'person_facepalming_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personGesture, + keywords: [ + 'dark skin tone', + 'disbelief', + 'exasperation', + 'face', + 'palm', + 'uc9', + 'diversity', + 'men', + 'stressed', + 'boys night', + 'facepalm', + 'dumb', + 'las vegas', + 'crazy', + 'wrong', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'guys night', + 'whoops', + 'oops', + 'mistake', + 'idiot', + 'ignorant', + 'stupid', + 'vegas', + 'weird', + 'awkward', + 'insane', + 'wild' + ], + modifiable: true), + Emoji( + name: 'woman facepalming', + char: '\u{1F926}\u{200D}\u{2640}\u{FE0F}', + shortName: 'woman_facepalming', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personGesture, + keywords: [ + 'disbelief', + 'exasperation', + 'facepalm', + 'woman', + 'uc9', + 'diversity', + 'women', + 'stressed', + 'facepalm', + 'dumb', + 'las vegas', + 'crazy', + 'wrong', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'woman', + 'female', + 'whoops', + 'oops', + 'mistake', + 'idiot', + 'ignorant', + 'stupid', + 'vegas', + 'weird', + 'awkward', + 'insane', + 'wild' + ]), + Emoji( + name: 'woman facepalming: light skin tone', + char: '\u{1F926}\u{1F3FB}\u{200D}\u{2640}\u{FE0F}', + shortName: 'woman_facepalming_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personGesture, + keywords: [ + 'disbelief', + 'exasperation', + 'facepalm', + 'light skin tone', + 'woman', + 'uc9', + 'diversity', + 'women', + 'stressed', + 'facepalm', + 'dumb', + 'las vegas', + 'crazy', + 'wrong', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'woman', + 'female', + 'whoops', + 'oops', + 'mistake', + 'idiot', + 'ignorant', + 'stupid', + 'vegas', + 'weird', + 'awkward', + 'insane', + 'wild' + ], + modifiable: true), + Emoji( + name: 'woman facepalming: medium-light skin tone', + char: '\u{1F926}\u{1F3FC}\u{200D}\u{2640}\u{FE0F}', + shortName: 'woman_facepalming_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personGesture, + keywords: [ + 'disbelief', + 'exasperation', + 'facepalm', + 'medium-light skin tone', + 'woman', + 'uc9', + 'diversity', + 'women', + 'stressed', + 'facepalm', + 'dumb', + 'las vegas', + 'crazy', + 'wrong', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'woman', + 'female', + 'whoops', + 'oops', + 'mistake', + 'idiot', + 'ignorant', + 'stupid', + 'vegas', + 'weird', + 'awkward', + 'insane', + 'wild' + ], + modifiable: true), + Emoji( + name: 'woman facepalming: medium skin tone', + char: '\u{1F926}\u{1F3FD}\u{200D}\u{2640}\u{FE0F}', + shortName: 'woman_facepalming_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personGesture, + keywords: [ + 'disbelief', + 'exasperation', + 'facepalm', + 'medium skin tone', + 'woman', + 'uc9', + 'diversity', + 'women', + 'stressed', + 'facepalm', + 'dumb', + 'las vegas', + 'crazy', + 'wrong', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'woman', + 'female', + 'whoops', + 'oops', + 'mistake', + 'idiot', + 'ignorant', + 'stupid', + 'vegas', + 'weird', + 'awkward', + 'insane', + 'wild' + ], + modifiable: true), + Emoji( + name: 'woman facepalming: medium-dark skin tone', + char: '\u{1F926}\u{1F3FE}\u{200D}\u{2640}\u{FE0F}', + shortName: 'woman_facepalming_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personGesture, + keywords: [ + 'disbelief', + 'exasperation', + 'facepalm', + 'medium-dark skin tone', + 'woman', + 'uc9', + 'diversity', + 'women', + 'stressed', + 'facepalm', + 'dumb', + 'las vegas', + 'crazy', + 'wrong', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'woman', + 'female', + 'whoops', + 'oops', + 'mistake', + 'idiot', + 'ignorant', + 'stupid', + 'vegas', + 'weird', + 'awkward', + 'insane', + 'wild' + ], + modifiable: true), + Emoji( + name: 'woman facepalming: dark skin tone', + char: '\u{1F926}\u{1F3FF}\u{200D}\u{2640}\u{FE0F}', + shortName: 'woman_facepalming_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personGesture, + keywords: [ + 'dark skin tone', + 'disbelief', + 'exasperation', + 'facepalm', + 'woman', + 'uc9', + 'diversity', + 'women', + 'stressed', + 'facepalm', + 'dumb', + 'las vegas', + 'crazy', + 'wrong', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'woman', + 'female', + 'whoops', + 'oops', + 'mistake', + 'idiot', + 'ignorant', + 'stupid', + 'vegas', + 'weird', + 'awkward', + 'insane', + 'wild' + ], + modifiable: true), + Emoji( + name: 'man facepalming', + char: '\u{1F926}\u{200D}\u{2642}\u{FE0F}', + shortName: 'man_facepalming', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personGesture, + keywords: [ + 'disbelief', + 'exasperation', + 'facepalm', + 'man', + 'uc9', + 'diversity', + 'men', + 'stressed', + 'boys night', + 'facepalm', + 'dumb', + 'las vegas', + 'crazy', + 'wrong', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'guys night', + 'whoops', + 'oops', + 'mistake', + 'idiot', + 'ignorant', + 'stupid', + 'vegas', + 'weird', + 'awkward', + 'insane', + 'wild' + ]), + Emoji( + name: 'man facepalming: light skin tone', + char: '\u{1F926}\u{1F3FB}\u{200D}\u{2642}\u{FE0F}', + shortName: 'man_facepalming_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personGesture, + keywords: [ + 'disbelief', + 'exasperation', + 'facepalm', + 'light skin tone', + 'man', + 'uc9', + 'diversity', + 'men', + 'stressed', + 'boys night', + 'facepalm', + 'dumb', + 'las vegas', + 'crazy', + 'wrong', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'guys night', + 'whoops', + 'oops', + 'mistake', + 'idiot', + 'ignorant', + 'stupid', + 'vegas', + 'weird', + 'awkward', + 'insane', + 'wild' + ], + modifiable: true), + Emoji( + name: 'man facepalming: medium-light skin tone', + char: '\u{1F926}\u{1F3FC}\u{200D}\u{2642}\u{FE0F}', + shortName: 'man_facepalming_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personGesture, + keywords: [ + 'disbelief', + 'exasperation', + 'facepalm', + 'man', + 'medium-light skin tone', + 'uc9', + 'diversity', + 'men', + 'stressed', + 'boys night', + 'facepalm', + 'dumb', + 'las vegas', + 'crazy', + 'wrong', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'guys night', + 'whoops', + 'oops', + 'mistake', + 'idiot', + 'ignorant', + 'stupid', + 'vegas', + 'weird', + 'awkward', + 'insane', + 'wild' + ], + modifiable: true), + Emoji( + name: 'man facepalming: medium skin tone', + char: '\u{1F926}\u{1F3FD}\u{200D}\u{2642}\u{FE0F}', + shortName: 'man_facepalming_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personGesture, + keywords: [ + 'disbelief', + 'exasperation', + 'facepalm', + 'man', + 'medium skin tone', + 'uc9', + 'diversity', + 'men', + 'stressed', + 'boys night', + 'facepalm', + 'dumb', + 'las vegas', + 'crazy', + 'wrong', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'guys night', + 'whoops', + 'oops', + 'mistake', + 'idiot', + 'ignorant', + 'stupid', + 'vegas', + 'weird', + 'awkward', + 'insane', + 'wild' + ], + modifiable: true), + Emoji( + name: 'man facepalming: medium-dark skin tone', + char: '\u{1F926}\u{1F3FE}\u{200D}\u{2642}\u{FE0F}', + shortName: 'man_facepalming_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personGesture, + keywords: [ + 'disbelief', + 'exasperation', + 'facepalm', + 'man', + 'medium-dark skin tone', + 'uc9', + 'diversity', + 'men', + 'stressed', + 'boys night', + 'facepalm', + 'dumb', + 'las vegas', + 'crazy', + 'wrong', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'guys night', + 'whoops', + 'oops', + 'mistake', + 'idiot', + 'ignorant', + 'stupid', + 'vegas', + 'weird', + 'awkward', + 'insane', + 'wild' + ], + modifiable: true), + Emoji( + name: 'man facepalming: dark skin tone', + char: '\u{1F926}\u{1F3FF}\u{200D}\u{2642}\u{FE0F}', + shortName: 'man_facepalming_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personGesture, + keywords: [ + 'dark skin tone', + 'disbelief', + 'exasperation', + 'facepalm', + 'man', + 'uc9', + 'diversity', + 'men', + 'stressed', + 'boys night', + 'facepalm', + 'dumb', + 'las vegas', + 'crazy', + 'wrong', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'guys night', + 'whoops', + 'oops', + 'mistake', + 'idiot', + 'ignorant', + 'stupid', + 'vegas', + 'weird', + 'awkward', + 'insane', + 'wild' + ], + modifiable: true), + Emoji( + name: 'person shrugging', + char: '\u{1F937}', + shortName: 'person_shrugging', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personGesture, + keywords: [ + 'doubt', + 'ignorance', + 'indifference', + 'shrug', + 'uc9', + 'diversity', + 'men', + 'shrug', + 'neutral', + 'bitch', + 'daddy', + 'doubt', + 'dumb', + 'confused', + 'what', + 'crazy', + 'mystery', + 'question', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'puta', + 'pute', + 'dad', + 'papa', + 'pere', + 'father', + 'unsure', + 'thinking', + 'wonder', + 'curious', + 'worry', + 'pensive', + 'remember', + 'skeptical', + 'idiot', + 'ignorant', + 'stupid', + 'perplexed', + 'weird', + 'awkward', + 'insane', + 'wild', + 'quiz', + 'puzzled' + ]), + Emoji( + name: 'person shrugging: light skin tone', + char: '\u{1F937}\u{1F3FB}', + shortName: 'person_shrugging_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personGesture, + keywords: [ + 'doubt', + 'ignorance', + 'indifference', + 'light skin tone', + 'shrug', + 'uc9', + 'diversity', + 'men', + 'shrug', + 'neutral', + 'bitch', + 'daddy', + 'doubt', + 'dumb', + 'confused', + 'what', + 'crazy', + 'mystery', + 'question', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'puta', + 'pute', + 'dad', + 'papa', + 'pere', + 'father', + 'unsure', + 'thinking', + 'wonder', + 'curious', + 'worry', + 'pensive', + 'remember', + 'skeptical', + 'idiot', + 'ignorant', + 'stupid', + 'perplexed', + 'weird', + 'awkward', + 'insane', + 'wild', + 'quiz', + 'puzzled' + ], + modifiable: true), + Emoji( + name: 'person shrugging: medium-light skin tone', + char: '\u{1F937}\u{1F3FC}', + shortName: 'person_shrugging_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personGesture, + keywords: [ + 'doubt', + 'ignorance', + 'indifference', + 'medium-light skin tone', + 'shrug', + 'uc9', + 'diversity', + 'men', + 'shrug', + 'neutral', + 'bitch', + 'daddy', + 'doubt', + 'dumb', + 'confused', + 'what', + 'crazy', + 'mystery', + 'question', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'puta', + 'pute', + 'dad', + 'papa', + 'pere', + 'father', + 'unsure', + 'thinking', + 'wonder', + 'curious', + 'worry', + 'pensive', + 'remember', + 'skeptical', + 'idiot', + 'ignorant', + 'stupid', + 'perplexed', + 'weird', + 'awkward', + 'insane', + 'wild', + 'quiz', + 'puzzled' + ], + modifiable: true), + Emoji( + name: 'person shrugging: medium skin tone', + char: '\u{1F937}\u{1F3FD}', + shortName: 'person_shrugging_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personGesture, + keywords: [ + 'doubt', + 'ignorance', + 'indifference', + 'medium skin tone', + 'shrug', + 'uc9', + 'diversity', + 'men', + 'shrug', + 'neutral', + 'bitch', + 'daddy', + 'doubt', + 'dumb', + 'confused', + 'what', + 'crazy', + 'mystery', + 'question', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'puta', + 'pute', + 'dad', + 'papa', + 'pere', + 'father', + 'unsure', + 'thinking', + 'wonder', + 'curious', + 'worry', + 'pensive', + 'remember', + 'skeptical', + 'idiot', + 'ignorant', + 'stupid', + 'perplexed', + 'weird', + 'awkward', + 'insane', + 'wild', + 'quiz', + 'puzzled' + ], + modifiable: true), + Emoji( + name: 'person shrugging: medium-dark skin tone', + char: '\u{1F937}\u{1F3FE}', + shortName: 'person_shrugging_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personGesture, + keywords: [ + 'doubt', + 'ignorance', + 'indifference', + 'medium-dark skin tone', + 'shrug', + 'uc9', + 'diversity', + 'men', + 'shrug', + 'neutral', + 'bitch', + 'daddy', + 'doubt', + 'dumb', + 'confused', + 'what', + 'crazy', + 'mystery', + 'question', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'puta', + 'pute', + 'dad', + 'papa', + 'pere', + 'father', + 'unsure', + 'thinking', + 'wonder', + 'curious', + 'worry', + 'pensive', + 'remember', + 'skeptical', + 'idiot', + 'ignorant', + 'stupid', + 'perplexed', + 'weird', + 'awkward', + 'insane', + 'wild', + 'quiz', + 'puzzled' + ], + modifiable: true), + Emoji( + name: 'person shrugging: dark skin tone', + char: '\u{1F937}\u{1F3FF}', + shortName: 'person_shrugging_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personGesture, + keywords: [ + 'dark skin tone', + 'doubt', + 'ignorance', + 'indifference', + 'shrug', + 'uc9', + 'diversity', + 'men', + 'shrug', + 'neutral', + 'bitch', + 'daddy', + 'doubt', + 'dumb', + 'confused', + 'what', + 'crazy', + 'mystery', + 'question', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'puta', + 'pute', + 'dad', + 'papa', + 'pere', + 'father', + 'unsure', + 'thinking', + 'wonder', + 'curious', + 'worry', + 'pensive', + 'remember', + 'skeptical', + 'idiot', + 'ignorant', + 'stupid', + 'perplexed', + 'weird', + 'awkward', + 'insane', + 'wild', + 'quiz', + 'puzzled' + ], + modifiable: true), + Emoji( + name: 'woman shrugging', + char: '\u{1F937}\u{200D}\u{2640}\u{FE0F}', + shortName: 'woman_shrugging', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personGesture, + keywords: [ + 'doubt', + 'ignorance', + 'indifference', + 'shrug', + 'woman', + 'uc9', + 'diversity', + 'women', + 'shrug', + 'neutral', + 'bitch', + 'doubt', + 'dumb', + 'guilty', + 'confused', + 'what', + 'crazy', + 'mystery', + 'question', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'woman', + 'female', + 'puta', + 'pute', + 'unsure', + 'thinking', + 'wonder', + 'curious', + 'worry', + 'pensive', + 'remember', + 'skeptical', + 'idiot', + 'ignorant', + 'stupid', + 'perplexed', + 'weird', + 'awkward', + 'insane', + 'wild', + 'quiz', + 'puzzled' + ]), + Emoji( + name: 'woman shrugging: light skin tone', + char: '\u{1F937}\u{1F3FB}\u{200D}\u{2640}\u{FE0F}', + shortName: 'woman_shrugging_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personGesture, + keywords: [ + 'doubt', + 'ignorance', + 'indifference', + 'light skin tone', + 'shrug', + 'woman', + 'uc9', + 'diversity', + 'women', + 'shrug', + 'neutral', + 'bitch', + 'doubt', + 'dumb', + 'guilty', + 'confused', + 'what', + 'crazy', + 'mystery', + 'question', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'woman', + 'female', + 'puta', + 'pute', + 'unsure', + 'thinking', + 'wonder', + 'curious', + 'worry', + 'pensive', + 'remember', + 'skeptical', + 'idiot', + 'ignorant', + 'stupid', + 'perplexed', + 'weird', + 'awkward', + 'insane', + 'wild', + 'quiz', + 'puzzled' + ], + modifiable: true), + Emoji( + name: 'woman shrugging: medium-light skin tone', + char: '\u{1F937}\u{1F3FC}\u{200D}\u{2640}\u{FE0F}', + shortName: 'woman_shrugging_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personGesture, + keywords: [ + 'doubt', + 'ignorance', + 'indifference', + 'medium-light skin tone', + 'shrug', + 'woman', + 'uc9', + 'diversity', + 'women', + 'shrug', + 'neutral', + 'bitch', + 'doubt', + 'dumb', + 'guilty', + 'confused', + 'what', + 'crazy', + 'mystery', + 'question', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'woman', + 'female', + 'puta', + 'pute', + 'unsure', + 'thinking', + 'wonder', + 'curious', + 'worry', + 'pensive', + 'remember', + 'skeptical', + 'idiot', + 'ignorant', + 'stupid', + 'perplexed', + 'weird', + 'awkward', + 'insane', + 'wild', + 'quiz', + 'puzzled' + ], + modifiable: true), + Emoji( + name: 'woman shrugging: medium skin tone', + char: '\u{1F937}\u{1F3FD}\u{200D}\u{2640}\u{FE0F}', + shortName: 'woman_shrugging_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personGesture, + keywords: [ + 'doubt', + 'ignorance', + 'indifference', + 'medium skin tone', + 'shrug', + 'woman', + 'uc9', + 'diversity', + 'women', + 'shrug', + 'neutral', + 'bitch', + 'doubt', + 'dumb', + 'guilty', + 'confused', + 'what', + 'crazy', + 'mystery', + 'question', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'woman', + 'female', + 'puta', + 'pute', + 'unsure', + 'thinking', + 'wonder', + 'curious', + 'worry', + 'pensive', + 'remember', + 'skeptical', + 'idiot', + 'ignorant', + 'stupid', + 'perplexed', + 'weird', + 'awkward', + 'insane', + 'wild', + 'quiz', + 'puzzled' + ], + modifiable: true), + Emoji( + name: 'woman shrugging: medium-dark skin tone', + char: '\u{1F937}\u{1F3FE}\u{200D}\u{2640}\u{FE0F}', + shortName: 'woman_shrugging_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personGesture, + keywords: [ + 'doubt', + 'ignorance', + 'indifference', + 'medium-dark skin tone', + 'shrug', + 'woman', + 'uc9', + 'diversity', + 'women', + 'shrug', + 'neutral', + 'bitch', + 'doubt', + 'dumb', + 'guilty', + 'confused', + 'what', + 'crazy', + 'mystery', + 'question', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'woman', + 'female', + 'puta', + 'pute', + 'unsure', + 'thinking', + 'wonder', + 'curious', + 'worry', + 'pensive', + 'remember', + 'skeptical', + 'idiot', + 'ignorant', + 'stupid', + 'perplexed', + 'weird', + 'awkward', + 'insane', + 'wild', + 'quiz', + 'puzzled' + ], + modifiable: true), + Emoji( + name: 'woman shrugging: dark skin tone', + char: '\u{1F937}\u{1F3FF}\u{200D}\u{2640}\u{FE0F}', + shortName: 'woman_shrugging_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personGesture, + keywords: [ + 'dark skin tone', + 'doubt', + 'ignorance', + 'indifference', + 'shrug', + 'woman', + 'uc9', + 'diversity', + 'women', + 'shrug', + 'neutral', + 'bitch', + 'doubt', + 'dumb', + 'guilty', + 'confused', + 'what', + 'crazy', + 'mystery', + 'question', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'woman', + 'female', + 'puta', + 'pute', + 'unsure', + 'thinking', + 'wonder', + 'curious', + 'worry', + 'pensive', + 'remember', + 'skeptical', + 'idiot', + 'ignorant', + 'stupid', + 'perplexed', + 'weird', + 'awkward', + 'insane', + 'wild', + 'quiz', + 'puzzled' + ], + modifiable: true), + Emoji( + name: 'man shrugging', + char: '\u{1F937}\u{200D}\u{2642}\u{FE0F}', + shortName: 'man_shrugging', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personGesture, + keywords: [ + 'doubt', + 'ignorance', + 'indifference', + 'man', + 'shrug', + 'uc9', + 'diversity', + 'men', + 'shrug', + 'neutral', + 'daddy', + 'doubt', + 'dumb', + 'guilty', + 'confused', + 'what', + 'crazy', + 'mystery', + 'question', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'dad', + 'papa', + 'pere', + 'father', + 'unsure', + 'thinking', + 'wonder', + 'curious', + 'worry', + 'pensive', + 'remember', + 'skeptical', + 'idiot', + 'ignorant', + 'stupid', + 'perplexed', + 'weird', + 'awkward', + 'insane', + 'wild', + 'quiz', + 'puzzled' + ]), + Emoji( + name: 'man shrugging: light skin tone', + char: '\u{1F937}\u{1F3FB}\u{200D}\u{2642}\u{FE0F}', + shortName: 'man_shrugging_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personGesture, + keywords: [ + 'doubt', + 'ignorance', + 'indifference', + 'light skin tone', + 'man', + 'shrug', + 'uc9', + 'diversity', + 'men', + 'shrug', + 'neutral', + 'daddy', + 'doubt', + 'dumb', + 'guilty', + 'confused', + 'what', + 'crazy', + 'mystery', + 'question', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'dad', + 'papa', + 'pere', + 'father', + 'unsure', + 'thinking', + 'wonder', + 'curious', + 'worry', + 'pensive', + 'remember', + 'skeptical', + 'idiot', + 'ignorant', + 'stupid', + 'perplexed', + 'weird', + 'awkward', + 'insane', + 'wild', + 'quiz', + 'puzzled' + ], + modifiable: true), + Emoji( + name: 'man shrugging: medium-light skin tone', + char: '\u{1F937}\u{1F3FC}\u{200D}\u{2642}\u{FE0F}', + shortName: 'man_shrugging_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personGesture, + keywords: [ + 'doubt', + 'ignorance', + 'indifference', + 'man', + 'medium-light skin tone', + 'shrug', + 'uc9', + 'diversity', + 'men', + 'shrug', + 'neutral', + 'daddy', + 'doubt', + 'dumb', + 'guilty', + 'confused', + 'what', + 'crazy', + 'mystery', + 'question', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'dad', + 'papa', + 'pere', + 'father', + 'unsure', + 'thinking', + 'wonder', + 'curious', + 'worry', + 'pensive', + 'remember', + 'skeptical', + 'idiot', + 'ignorant', + 'stupid', + 'perplexed', + 'weird', + 'awkward', + 'insane', + 'wild', + 'quiz', + 'puzzled' + ], + modifiable: true), + Emoji( + name: 'man shrugging: medium skin tone', + char: '\u{1F937}\u{1F3FD}\u{200D}\u{2642}\u{FE0F}', + shortName: 'man_shrugging_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personGesture, + keywords: [ + 'doubt', + 'ignorance', + 'indifference', + 'man', + 'medium skin tone', + 'shrug', + 'uc9', + 'diversity', + 'men', + 'shrug', + 'neutral', + 'daddy', + 'doubt', + 'dumb', + 'guilty', + 'confused', + 'what', + 'crazy', + 'mystery', + 'question', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'dad', + 'papa', + 'pere', + 'father', + 'unsure', + 'thinking', + 'wonder', + 'curious', + 'worry', + 'pensive', + 'remember', + 'skeptical', + 'idiot', + 'ignorant', + 'stupid', + 'perplexed', + 'weird', + 'awkward', + 'insane', + 'wild', + 'quiz', + 'puzzled' + ], + modifiable: true), + Emoji( + name: 'man shrugging: medium-dark skin tone', + char: '\u{1F937}\u{1F3FE}\u{200D}\u{2642}\u{FE0F}', + shortName: 'man_shrugging_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personGesture, + keywords: [ + 'doubt', + 'ignorance', + 'indifference', + 'man', + 'medium-dark skin tone', + 'shrug', + 'uc9', + 'diversity', + 'men', + 'shrug', + 'neutral', + 'daddy', + 'doubt', + 'dumb', + 'guilty', + 'confused', + 'what', + 'crazy', + 'mystery', + 'question', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'dad', + 'papa', + 'pere', + 'father', + 'unsure', + 'thinking', + 'wonder', + 'curious', + 'worry', + 'pensive', + 'remember', + 'skeptical', + 'idiot', + 'ignorant', + 'stupid', + 'perplexed', + 'weird', + 'awkward', + 'insane', + 'wild', + 'quiz', + 'puzzled' + ], + modifiable: true), + Emoji( + name: 'man shrugging: dark skin tone', + char: '\u{1F937}\u{1F3FF}\u{200D}\u{2642}\u{FE0F}', + shortName: 'man_shrugging_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personGesture, + keywords: [ + 'dark skin tone', + 'doubt', + 'ignorance', + 'indifference', + 'man', + 'shrug', + 'uc9', + 'diversity', + 'men', + 'shrug', + 'neutral', + 'daddy', + 'doubt', + 'dumb', + 'guilty', + 'confused', + 'what', + 'crazy', + 'mystery', + 'question', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'dad', + 'papa', + 'pere', + 'father', + 'unsure', + 'thinking', + 'wonder', + 'curious', + 'worry', + 'pensive', + 'remember', + 'skeptical', + 'idiot', + 'ignorant', + 'stupid', + 'perplexed', + 'weird', + 'awkward', + 'insane', + 'wild', + 'quiz', + 'puzzled' + ], + modifiable: true), + Emoji( + name: 'person pouting', + char: '\u{1F64E}', + shortName: 'person_pouting', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personGesture, + keywords: [ + 'gesture', + 'pouting', + 'uc6', + 'diversity', + 'sad', + 'men', + 'angry', + 'stressed', + 'hate', + 'bitch', + 'daddy', + 'mom', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'triste', + 'depression', + 'negative', + 'sadness', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'upset', + 'pissed', + 'pissed off', + 'unhappy', + 'frustrated', + 'anger', + 'rage', + 'frustration', + 'furious', + 'mad', + 'i hate', + 'disgust', + 'stump', + 'shout', + 'dislike', + 'rude', + 'annoy', + 'grinch', + 'gross', + 'grumpy', + 'mean', + 'problem', + 'suck', + 'jerk', + 'asshole', + 'no', + 'puta', + 'pute', + 'dad', + 'papa', + 'pere', + 'father', + 'maman', + 'mommy', + 'mama', + 'mother' + ]), + Emoji( + name: 'person pouting: light skin tone', + char: '\u{1F64E}\u{1F3FB}', + shortName: 'person_pouting_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personGesture, + keywords: [ + 'gesture', + 'light skin tone', + 'pouting', + 'uc8', + 'diversity', + 'sad', + 'men', + 'angry', + 'stressed', + 'hate', + 'bitch', + 'daddy', + 'mom', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'triste', + 'depression', + 'negative', + 'sadness', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'upset', + 'pissed', + 'pissed off', + 'unhappy', + 'frustrated', + 'anger', + 'rage', + 'frustration', + 'furious', + 'mad', + 'i hate', + 'disgust', + 'stump', + 'shout', + 'dislike', + 'rude', + 'annoy', + 'grinch', + 'gross', + 'grumpy', + 'mean', + 'problem', + 'suck', + 'jerk', + 'asshole', + 'no', + 'puta', + 'pute', + 'dad', + 'papa', + 'pere', + 'father', + 'maman', + 'mommy', + 'mama', + 'mother' + ], + modifiable: true), + Emoji( + name: 'person pouting: medium-light skin tone', + char: '\u{1F64E}\u{1F3FC}', + shortName: 'person_pouting_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personGesture, + keywords: [ + 'gesture', + 'medium-light skin tone', + 'pouting', + 'uc8', + 'diversity', + 'sad', + 'men', + 'angry', + 'stressed', + 'hate', + 'bitch', + 'daddy', + 'mom', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'triste', + 'depression', + 'negative', + 'sadness', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'upset', + 'pissed', + 'pissed off', + 'unhappy', + 'frustrated', + 'anger', + 'rage', + 'frustration', + 'furious', + 'mad', + 'i hate', + 'disgust', + 'stump', + 'shout', + 'dislike', + 'rude', + 'annoy', + 'grinch', + 'gross', + 'grumpy', + 'mean', + 'problem', + 'suck', + 'jerk', + 'asshole', + 'no', + 'puta', + 'pute', + 'dad', + 'papa', + 'pere', + 'father', + 'maman', + 'mommy', + 'mama', + 'mother' + ], + modifiable: true), + Emoji( + name: 'person pouting: medium skin tone', + char: '\u{1F64E}\u{1F3FD}', + shortName: 'person_pouting_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personGesture, + keywords: [ + 'gesture', + 'medium skin tone', + 'pouting', + 'uc8', + 'diversity', + 'sad', + 'men', + 'angry', + 'stressed', + 'hate', + 'bitch', + 'daddy', + 'mom', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'triste', + 'depression', + 'negative', + 'sadness', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'upset', + 'pissed', + 'pissed off', + 'unhappy', + 'frustrated', + 'anger', + 'rage', + 'frustration', + 'furious', + 'mad', + 'i hate', + 'disgust', + 'stump', + 'shout', + 'dislike', + 'rude', + 'annoy', + 'grinch', + 'gross', + 'grumpy', + 'mean', + 'problem', + 'suck', + 'jerk', + 'asshole', + 'no', + 'puta', + 'pute', + 'dad', + 'papa', + 'pere', + 'father', + 'maman', + 'mommy', + 'mama', + 'mother' + ], + modifiable: true), + Emoji( + name: 'person pouting: medium-dark skin tone', + char: '\u{1F64E}\u{1F3FE}', + shortName: 'person_pouting_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personGesture, + keywords: [ + 'gesture', + 'medium-dark skin tone', + 'pouting', + 'uc8', + 'diversity', + 'sad', + 'men', + 'angry', + 'stressed', + 'hate', + 'bitch', + 'daddy', + 'mom', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'triste', + 'depression', + 'negative', + 'sadness', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'upset', + 'pissed', + 'pissed off', + 'unhappy', + 'frustrated', + 'anger', + 'rage', + 'frustration', + 'furious', + 'mad', + 'i hate', + 'disgust', + 'stump', + 'shout', + 'dislike', + 'rude', + 'annoy', + 'grinch', + 'gross', + 'grumpy', + 'mean', + 'problem', + 'suck', + 'jerk', + 'asshole', + 'no', + 'puta', + 'pute', + 'dad', + 'papa', + 'pere', + 'father', + 'maman', + 'mommy', + 'mama', + 'mother' + ], + modifiable: true), + Emoji( + name: 'person pouting: dark skin tone', + char: '\u{1F64E}\u{1F3FF}', + shortName: 'person_pouting_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personGesture, + keywords: [ + 'dark skin tone', + 'gesture', + 'pouting', + 'uc8', + 'diversity', + 'sad', + 'men', + 'angry', + 'stressed', + 'hate', + 'bitch', + 'daddy', + 'mom', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'triste', + 'depression', + 'negative', + 'sadness', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'upset', + 'pissed', + 'pissed off', + 'unhappy', + 'frustrated', + 'anger', + 'rage', + 'frustration', + 'furious', + 'mad', + 'i hate', + 'disgust', + 'stump', + 'shout', + 'dislike', + 'rude', + 'annoy', + 'grinch', + 'gross', + 'grumpy', + 'mean', + 'problem', + 'suck', + 'jerk', + 'asshole', + 'no', + 'puta', + 'pute', + 'dad', + 'papa', + 'pere', + 'father', + 'maman', + 'mommy', + 'mama', + 'mother' + ], + modifiable: true), + Emoji( + name: 'woman pouting', + char: '\u{1F64E}\u{200D}\u{2640}\u{FE0F}', + shortName: 'woman_pouting', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personGesture, + keywords: [ + 'gesture', + 'pouting', + 'woman', + 'uc6', + 'diversity', + 'sad', + 'women', + 'stressed', + 'bitch', + 'mom', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'triste', + 'depression', + 'negative', + 'sadness', + 'woman', + 'female', + 'puta', + 'pute', + 'maman', + 'mommy', + 'mama', + 'mother' + ]), + Emoji( + name: 'woman pouting: light skin tone', + char: '\u{1F64E}\u{1F3FB}\u{200D}\u{2640}\u{FE0F}', + shortName: 'woman_pouting_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personGesture, + keywords: [ + 'gesture', + 'light skin tone', + 'pouting', + 'woman', + 'uc8', + 'diversity', + 'sad', + 'women', + 'stressed', + 'bitch', + 'mom', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'triste', + 'depression', + 'negative', + 'sadness', + 'woman', + 'female', + 'puta', + 'pute', + 'maman', + 'mommy', + 'mama', + 'mother' + ], + modifiable: true), + Emoji( + name: 'woman pouting: medium-light skin tone', + char: '\u{1F64E}\u{1F3FC}\u{200D}\u{2640}\u{FE0F}', + shortName: 'woman_pouting_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personGesture, + keywords: [ + 'gesture', + 'medium-light skin tone', + 'pouting', + 'woman', + 'uc8', + 'diversity', + 'sad', + 'women', + 'stressed', + 'bitch', + 'mom', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'triste', + 'depression', + 'negative', + 'sadness', + 'woman', + 'female', + 'puta', + 'pute', + 'maman', + 'mommy', + 'mama', + 'mother' + ], + modifiable: true), + Emoji( + name: 'woman pouting: medium skin tone', + char: '\u{1F64E}\u{1F3FD}\u{200D}\u{2640}\u{FE0F}', + shortName: 'woman_pouting_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personGesture, + keywords: [ + 'gesture', + 'medium skin tone', + 'pouting', + 'woman', + 'uc8', + 'diversity', + 'sad', + 'women', + 'stressed', + 'bitch', + 'mom', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'triste', + 'depression', + 'negative', + 'sadness', + 'woman', + 'female', + 'puta', + 'pute', + 'maman', + 'mommy', + 'mama', + 'mother' + ], + modifiable: true), + Emoji( + name: 'woman pouting: medium-dark skin tone', + char: '\u{1F64E}\u{1F3FE}\u{200D}\u{2640}\u{FE0F}', + shortName: 'woman_pouting_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personGesture, + keywords: [ + 'gesture', + 'medium-dark skin tone', + 'pouting', + 'woman', + 'uc8', + 'diversity', + 'sad', + 'women', + 'stressed', + 'bitch', + 'mom', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'triste', + 'depression', + 'negative', + 'sadness', + 'woman', + 'female', + 'puta', + 'pute', + 'maman', + 'mommy', + 'mama', + 'mother' + ], + modifiable: true), + Emoji( + name: 'woman pouting: dark skin tone', + char: '\u{1F64E}\u{1F3FF}\u{200D}\u{2640}\u{FE0F}', + shortName: 'woman_pouting_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personGesture, + keywords: [ + 'dark skin tone', + 'gesture', + 'pouting', + 'woman', + 'uc8', + 'diversity', + 'sad', + 'women', + 'stressed', + 'bitch', + 'mom', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'triste', + 'depression', + 'negative', + 'sadness', + 'woman', + 'female', + 'puta', + 'pute', + 'maman', + 'mommy', + 'mama', + 'mother' + ], + modifiable: true), + Emoji( + name: 'man pouting', + char: '\u{1F64E}\u{200D}\u{2642}\u{FE0F}', + shortName: 'man_pouting', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personGesture, + keywords: [ + 'gesture', + 'man', + 'pouting', + 'uc6', + 'diversity', + 'sad', + 'men', + 'angry', + 'stressed', + 'hate', + 'daddy', + 'husband', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'triste', + 'depression', + 'negative', + 'sadness', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'upset', + 'pissed', + 'pissed off', + 'unhappy', + 'frustrated', + 'anger', + 'rage', + 'frustration', + 'furious', + 'mad', + 'i hate', + 'disgust', + 'stump', + 'shout', + 'dislike', + 'rude', + 'annoy', + 'grinch', + 'gross', + 'grumpy', + 'mean', + 'problem', + 'suck', + 'jerk', + 'asshole', + 'no', + 'dad', + 'papa', + 'pere', + 'father' + ]), + Emoji( + name: 'man pouting: light skin tone', + char: '\u{1F64E}\u{1F3FB}\u{200D}\u{2642}\u{FE0F}', + shortName: 'man_pouting_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personGesture, + keywords: [ + 'gesture', + 'light skin tone', + 'man', + 'pouting', + 'uc8', + 'diversity', + 'sad', + 'men', + 'angry', + 'stressed', + 'hate', + 'daddy', + 'husband', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'triste', + 'depression', + 'negative', + 'sadness', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'upset', + 'pissed', + 'pissed off', + 'unhappy', + 'frustrated', + 'anger', + 'rage', + 'frustration', + 'furious', + 'mad', + 'i hate', + 'disgust', + 'stump', + 'shout', + 'dislike', + 'rude', + 'annoy', + 'grinch', + 'gross', + 'grumpy', + 'mean', + 'problem', + 'suck', + 'jerk', + 'asshole', + 'no', + 'dad', + 'papa', + 'pere', + 'father' + ], + modifiable: true), + Emoji( + name: 'man pouting: medium-light skin tone', + char: '\u{1F64E}\u{1F3FC}\u{200D}\u{2642}\u{FE0F}', + shortName: 'man_pouting_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personGesture, + keywords: [ + 'gesture', + 'man', + 'medium-light skin tone', + 'pouting', + 'uc8', + 'diversity', + 'sad', + 'men', + 'angry', + 'stressed', + 'hate', + 'daddy', + 'husband', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'triste', + 'depression', + 'negative', + 'sadness', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'upset', + 'pissed', + 'pissed off', + 'unhappy', + 'frustrated', + 'anger', + 'rage', + 'frustration', + 'furious', + 'mad', + 'i hate', + 'disgust', + 'stump', + 'shout', + 'dislike', + 'rude', + 'annoy', + 'grinch', + 'gross', + 'grumpy', + 'mean', + 'problem', + 'suck', + 'jerk', + 'asshole', + 'no', + 'dad', + 'papa', + 'pere', + 'father' + ], + modifiable: true), + Emoji( + name: 'man pouting: medium skin tone', + char: '\u{1F64E}\u{1F3FD}\u{200D}\u{2642}\u{FE0F}', + shortName: 'man_pouting_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personGesture, + keywords: [ + 'gesture', + 'man', + 'medium skin tone', + 'pouting', + 'uc8', + 'diversity', + 'sad', + 'men', + 'angry', + 'stressed', + 'hate', + 'daddy', + 'husband', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'triste', + 'depression', + 'negative', + 'sadness', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'upset', + 'pissed', + 'pissed off', + 'unhappy', + 'frustrated', + 'anger', + 'rage', + 'frustration', + 'furious', + 'mad', + 'i hate', + 'disgust', + 'stump', + 'shout', + 'dislike', + 'rude', + 'annoy', + 'grinch', + 'gross', + 'grumpy', + 'mean', + 'problem', + 'suck', + 'jerk', + 'asshole', + 'no', + 'dad', + 'papa', + 'pere', + 'father' + ], + modifiable: true), + Emoji( + name: 'man pouting: medium-dark skin tone', + char: '\u{1F64E}\u{1F3FE}\u{200D}\u{2642}\u{FE0F}', + shortName: 'man_pouting_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personGesture, + keywords: [ + 'gesture', + 'man', + 'medium-dark skin tone', + 'pouting', + 'uc8', + 'diversity', + 'sad', + 'men', + 'angry', + 'stressed', + 'hate', + 'daddy', + 'husband', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'triste', + 'depression', + 'negative', + 'sadness', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'upset', + 'pissed', + 'pissed off', + 'unhappy', + 'frustrated', + 'anger', + 'rage', + 'frustration', + 'furious', + 'mad', + 'i hate', + 'disgust', + 'stump', + 'shout', + 'dislike', + 'rude', + 'annoy', + 'grinch', + 'gross', + 'grumpy', + 'mean', + 'problem', + 'suck', + 'jerk', + 'asshole', + 'no', + 'dad', + 'papa', + 'pere', + 'father' + ], + modifiable: true), + Emoji( + name: 'man pouting: dark skin tone', + char: '\u{1F64E}\u{1F3FF}\u{200D}\u{2642}\u{FE0F}', + shortName: 'man_pouting_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personGesture, + keywords: [ + 'dark skin tone', + 'gesture', + 'man', + 'pouting', + 'uc8', + 'diversity', + 'sad', + 'men', + 'angry', + 'stressed', + 'hate', + 'daddy', + 'husband', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'triste', + 'depression', + 'negative', + 'sadness', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'upset', + 'pissed', + 'pissed off', + 'unhappy', + 'frustrated', + 'anger', + 'rage', + 'frustration', + 'furious', + 'mad', + 'i hate', + 'disgust', + 'stump', + 'shout', + 'dislike', + 'rude', + 'annoy', + 'grinch', + 'gross', + 'grumpy', + 'mean', + 'problem', + 'suck', + 'jerk', + 'asshole', + 'no', + 'dad', + 'papa', + 'pere', + 'father' + ], + modifiable: true), + Emoji( + name: 'person frowning', + char: '\u{1F64D}', + shortName: 'person_frowning', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personGesture, + keywords: [ + 'frown', + 'gesture', + 'uc6', + 'diversity', + 'sad', + 'men', + 'angry', + 'stressed', + 'hate', + 'bitch', + 'daddy', + 'husband', + 'mom', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'triste', + 'depression', + 'negative', + 'sadness', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'upset', + 'pissed', + 'pissed off', + 'unhappy', + 'frustrated', + 'anger', + 'rage', + 'frustration', + 'furious', + 'mad', + 'i hate', + 'disgust', + 'stump', + 'shout', + 'dislike', + 'rude', + 'annoy', + 'grinch', + 'gross', + 'grumpy', + 'mean', + 'problem', + 'suck', + 'jerk', + 'asshole', + 'no', + 'puta', + 'pute', + 'dad', + 'papa', + 'pere', + 'father', + 'maman', + 'mommy', + 'mama', + 'mother' + ]), + Emoji( + name: 'person frowning: light skin tone', + char: '\u{1F64D}\u{1F3FB}', + shortName: 'person_frowning_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personGesture, + keywords: [ + 'frown', + 'gesture', + 'light skin tone', + 'uc8', + 'diversity', + 'sad', + 'men', + 'angry', + 'stressed', + 'hate', + 'bitch', + 'daddy', + 'husband', + 'mom', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'triste', + 'depression', + 'negative', + 'sadness', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'upset', + 'pissed', + 'pissed off', + 'unhappy', + 'frustrated', + 'anger', + 'rage', + 'frustration', + 'furious', + 'mad', + 'i hate', + 'disgust', + 'stump', + 'shout', + 'dislike', + 'rude', + 'annoy', + 'grinch', + 'gross', + 'grumpy', + 'mean', + 'problem', + 'suck', + 'jerk', + 'asshole', + 'no', + 'puta', + 'pute', + 'dad', + 'papa', + 'pere', + 'father', + 'maman', + 'mommy', + 'mama', + 'mother' + ], + modifiable: true), + Emoji( + name: 'person frowning: medium-light skin tone', + char: '\u{1F64D}\u{1F3FC}', + shortName: 'person_frowning_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personGesture, + keywords: [ + 'frown', + 'gesture', + 'medium-light skin tone', + 'uc8', + 'diversity', + 'sad', + 'men', + 'angry', + 'stressed', + 'hate', + 'bitch', + 'daddy', + 'husband', + 'mom', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'triste', + 'depression', + 'negative', + 'sadness', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'upset', + 'pissed', + 'pissed off', + 'unhappy', + 'frustrated', + 'anger', + 'rage', + 'frustration', + 'furious', + 'mad', + 'i hate', + 'disgust', + 'stump', + 'shout', + 'dislike', + 'rude', + 'annoy', + 'grinch', + 'gross', + 'grumpy', + 'mean', + 'problem', + 'suck', + 'jerk', + 'asshole', + 'no', + 'puta', + 'pute', + 'dad', + 'papa', + 'pere', + 'father', + 'maman', + 'mommy', + 'mama', + 'mother' + ], + modifiable: true), + Emoji( + name: 'person frowning: medium skin tone', + char: '\u{1F64D}\u{1F3FD}', + shortName: 'person_frowning_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personGesture, + keywords: [ + 'frown', + 'gesture', + 'medium skin tone', + 'uc8', + 'diversity', + 'sad', + 'men', + 'angry', + 'stressed', + 'hate', + 'bitch', + 'daddy', + 'husband', + 'mom', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'triste', + 'depression', + 'negative', + 'sadness', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'upset', + 'pissed', + 'pissed off', + 'unhappy', + 'frustrated', + 'anger', + 'rage', + 'frustration', + 'furious', + 'mad', + 'i hate', + 'disgust', + 'stump', + 'shout', + 'dislike', + 'rude', + 'annoy', + 'grinch', + 'gross', + 'grumpy', + 'mean', + 'problem', + 'suck', + 'jerk', + 'asshole', + 'no', + 'puta', + 'pute', + 'dad', + 'papa', + 'pere', + 'father', + 'maman', + 'mommy', + 'mama', + 'mother' + ], + modifiable: true), + Emoji( + name: 'person frowning: medium-dark skin tone', + char: '\u{1F64D}\u{1F3FE}', + shortName: 'person_frowning_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personGesture, + keywords: [ + 'frown', + 'gesture', + 'medium-dark skin tone', + 'uc8', + 'diversity', + 'sad', + 'men', + 'angry', + 'stressed', + 'hate', + 'bitch', + 'daddy', + 'husband', + 'mom', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'triste', + 'depression', + 'negative', + 'sadness', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'upset', + 'pissed', + 'pissed off', + 'unhappy', + 'frustrated', + 'anger', + 'rage', + 'frustration', + 'furious', + 'mad', + 'i hate', + 'disgust', + 'stump', + 'shout', + 'dislike', + 'rude', + 'annoy', + 'grinch', + 'gross', + 'grumpy', + 'mean', + 'problem', + 'suck', + 'jerk', + 'asshole', + 'no', + 'puta', + 'pute', + 'dad', + 'papa', + 'pere', + 'father', + 'maman', + 'mommy', + 'mama', + 'mother' + ], + modifiable: true), + Emoji( + name: 'person frowning: dark skin tone', + char: '\u{1F64D}\u{1F3FF}', + shortName: 'person_frowning_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personGesture, + keywords: [ + 'dark skin tone', + 'frown', + 'gesture', + 'uc8', + 'diversity', + 'sad', + 'men', + 'angry', + 'stressed', + 'hate', + 'bitch', + 'daddy', + 'husband', + 'mom', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'triste', + 'depression', + 'negative', + 'sadness', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'upset', + 'pissed', + 'pissed off', + 'unhappy', + 'frustrated', + 'anger', + 'rage', + 'frustration', + 'furious', + 'mad', + 'i hate', + 'disgust', + 'stump', + 'shout', + 'dislike', + 'rude', + 'annoy', + 'grinch', + 'gross', + 'grumpy', + 'mean', + 'problem', + 'suck', + 'jerk', + 'asshole', + 'no', + 'puta', + 'pute', + 'dad', + 'papa', + 'pere', + 'father', + 'maman', + 'mommy', + 'mama', + 'mother' + ], + modifiable: true), + Emoji( + name: 'woman frowning', + char: '\u{1F64D}\u{200D}\u{2640}\u{FE0F}', + shortName: 'woman_frowning', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personGesture, + keywords: [ + 'frowning', + 'gesture', + 'woman', + 'uc6', + 'diversity', + 'sad', + 'women', + 'stressed', + 'bitch', + 'mom', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'triste', + 'depression', + 'negative', + 'sadness', + 'woman', + 'female', + 'puta', + 'pute', + 'maman', + 'mommy', + 'mama', + 'mother' + ]), + Emoji( + name: 'woman frowning: light skin tone', + char: '\u{1F64D}\u{1F3FB}\u{200D}\u{2640}\u{FE0F}', + shortName: 'woman_frowning_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personGesture, + keywords: [ + 'frowning', + 'gesture', + 'light skin tone', + 'woman', + 'uc8', + 'diversity', + 'sad', + 'women', + 'stressed', + 'bitch', + 'mom', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'triste', + 'depression', + 'negative', + 'sadness', + 'woman', + 'female', + 'puta', + 'pute', + 'maman', + 'mommy', + 'mama', + 'mother' + ], + modifiable: true), + Emoji( + name: 'woman frowning: medium-light skin tone', + char: '\u{1F64D}\u{1F3FC}\u{200D}\u{2640}\u{FE0F}', + shortName: 'woman_frowning_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personGesture, + keywords: [ + 'frowning', + 'gesture', + 'medium-light skin tone', + 'woman', + 'uc8', + 'diversity', + 'sad', + 'women', + 'stressed', + 'bitch', + 'mom', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'triste', + 'depression', + 'negative', + 'sadness', + 'woman', + 'female', + 'puta', + 'pute', + 'maman', + 'mommy', + 'mama', + 'mother' + ], + modifiable: true), + Emoji( + name: 'woman frowning: medium skin tone', + char: '\u{1F64D}\u{1F3FD}\u{200D}\u{2640}\u{FE0F}', + shortName: 'woman_frowning_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personGesture, + keywords: [ + 'frowning', + 'gesture', + 'medium skin tone', + 'woman', + 'uc8', + 'diversity', + 'sad', + 'women', + 'stressed', + 'bitch', + 'mom', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'triste', + 'depression', + 'negative', + 'sadness', + 'woman', + 'female', + 'puta', + 'pute', + 'maman', + 'mommy', + 'mama', + 'mother' + ], + modifiable: true), + Emoji( + name: 'woman frowning: medium-dark skin tone', + char: '\u{1F64D}\u{1F3FE}\u{200D}\u{2640}\u{FE0F}', + shortName: 'woman_frowning_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personGesture, + keywords: [ + 'frowning', + 'gesture', + 'medium-dark skin tone', + 'woman', + 'uc8', + 'diversity', + 'sad', + 'women', + 'stressed', + 'bitch', + 'mom', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'triste', + 'depression', + 'negative', + 'sadness', + 'woman', + 'female', + 'puta', + 'pute', + 'maman', + 'mommy', + 'mama', + 'mother' + ], + modifiable: true), + Emoji( + name: 'woman frowning: dark skin tone', + char: '\u{1F64D}\u{1F3FF}\u{200D}\u{2640}\u{FE0F}', + shortName: 'woman_frowning_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personGesture, + keywords: [ + 'dark skin tone', + 'frowning', + 'gesture', + 'woman', + 'uc8', + 'diversity', + 'sad', + 'women', + 'stressed', + 'bitch', + 'mom', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'triste', + 'depression', + 'negative', + 'sadness', + 'woman', + 'female', + 'puta', + 'pute', + 'maman', + 'mommy', + 'mama', + 'mother' + ], + modifiable: true), + Emoji( + name: 'man frowning', + char: '\u{1F64D}\u{200D}\u{2642}\u{FE0F}', + shortName: 'man_frowning', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personGesture, + keywords: [ + 'frowning', + 'gesture', + 'man', + 'uc6', + 'diversity', + 'sad', + 'men', + 'angry', + 'stressed', + 'hate', + 'daddy', + 'husband', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'triste', + 'depression', + 'negative', + 'sadness', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'upset', + 'pissed', + 'pissed off', + 'unhappy', + 'frustrated', + 'anger', + 'rage', + 'frustration', + 'furious', + 'mad', + 'i hate', + 'disgust', + 'stump', + 'shout', + 'dislike', + 'rude', + 'annoy', + 'grinch', + 'gross', + 'grumpy', + 'mean', + 'problem', + 'suck', + 'jerk', + 'asshole', + 'no', + 'dad', + 'papa', + 'pere', + 'father' + ]), + Emoji( + name: 'man frowning: light skin tone', + char: '\u{1F64D}\u{1F3FB}\u{200D}\u{2642}\u{FE0F}', + shortName: 'man_frowning_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personGesture, + keywords: [ + 'frowning', + 'gesture', + 'light skin tone', + 'man', + 'uc8', + 'diversity', + 'sad', + 'men', + 'angry', + 'stressed', + 'hate', + 'daddy', + 'husband', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'triste', + 'depression', + 'negative', + 'sadness', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'upset', + 'pissed', + 'pissed off', + 'unhappy', + 'frustrated', + 'anger', + 'rage', + 'frustration', + 'furious', + 'mad', + 'i hate', + 'disgust', + 'stump', + 'shout', + 'dislike', + 'rude', + 'annoy', + 'grinch', + 'gross', + 'grumpy', + 'mean', + 'problem', + 'suck', + 'jerk', + 'asshole', + 'no', + 'dad', + 'papa', + 'pere', + 'father' + ], + modifiable: true), + Emoji( + name: 'man frowning: medium-light skin tone', + char: '\u{1F64D}\u{1F3FC}\u{200D}\u{2642}\u{FE0F}', + shortName: 'man_frowning_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personGesture, + keywords: [ + 'frowning', + 'gesture', + 'man', + 'medium-light skin tone', + 'uc8', + 'diversity', + 'sad', + 'men', + 'angry', + 'stressed', + 'hate', + 'daddy', + 'husband', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'triste', + 'depression', + 'negative', + 'sadness', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'upset', + 'pissed', + 'pissed off', + 'unhappy', + 'frustrated', + 'anger', + 'rage', + 'frustration', + 'furious', + 'mad', + 'i hate', + 'disgust', + 'stump', + 'shout', + 'dislike', + 'rude', + 'annoy', + 'grinch', + 'gross', + 'grumpy', + 'mean', + 'problem', + 'suck', + 'jerk', + 'asshole', + 'no', + 'dad', + 'papa', + 'pere', + 'father' + ], + modifiable: true), + Emoji( + name: 'man frowning: medium skin tone', + char: '\u{1F64D}\u{1F3FD}\u{200D}\u{2642}\u{FE0F}', + shortName: 'man_frowning_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personGesture, + keywords: [ + 'frowning', + 'gesture', + 'man', + 'medium skin tone', + 'uc8', + 'diversity', + 'sad', + 'men', + 'angry', + 'stressed', + 'hate', + 'daddy', + 'husband', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'triste', + 'depression', + 'negative', + 'sadness', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'upset', + 'pissed', + 'pissed off', + 'unhappy', + 'frustrated', + 'anger', + 'rage', + 'frustration', + 'furious', + 'mad', + 'i hate', + 'disgust', + 'stump', + 'shout', + 'dislike', + 'rude', + 'annoy', + 'grinch', + 'gross', + 'grumpy', + 'mean', + 'problem', + 'suck', + 'jerk', + 'asshole', + 'no', + 'dad', + 'papa', + 'pere', + 'father' + ], + modifiable: true), + Emoji( + name: 'man frowning: medium-dark skin tone', + char: '\u{1F64D}\u{1F3FE}\u{200D}\u{2642}\u{FE0F}', + shortName: 'man_frowning_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personGesture, + keywords: [ + 'frowning', + 'gesture', + 'man', + 'medium-dark skin tone', + 'uc8', + 'diversity', + 'sad', + 'men', + 'angry', + 'stressed', + 'hate', + 'daddy', + 'husband', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'triste', + 'depression', + 'negative', + 'sadness', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'upset', + 'pissed', + 'pissed off', + 'unhappy', + 'frustrated', + 'anger', + 'rage', + 'frustration', + 'furious', + 'mad', + 'i hate', + 'disgust', + 'stump', + 'shout', + 'dislike', + 'rude', + 'annoy', + 'grinch', + 'gross', + 'grumpy', + 'mean', + 'problem', + 'suck', + 'jerk', + 'asshole', + 'no', + 'dad', + 'papa', + 'pere', + 'father' + ], + modifiable: true), + Emoji( + name: 'man frowning: dark skin tone', + char: '\u{1F64D}\u{1F3FF}\u{200D}\u{2642}\u{FE0F}', + shortName: 'man_frowning_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personGesture, + keywords: [ + 'dark skin tone', + 'frowning', + 'gesture', + 'man', + 'uc8', + 'diversity', + 'sad', + 'men', + 'angry', + 'stressed', + 'hate', + 'daddy', + 'husband', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'triste', + 'depression', + 'negative', + 'sadness', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'upset', + 'pissed', + 'pissed off', + 'unhappy', + 'frustrated', + 'anger', + 'rage', + 'frustration', + 'furious', + 'mad', + 'i hate', + 'disgust', + 'stump', + 'shout', + 'dislike', + 'rude', + 'annoy', + 'grinch', + 'gross', + 'grumpy', + 'mean', + 'problem', + 'suck', + 'jerk', + 'asshole', + 'no', + 'dad', + 'papa', + 'pere', + 'father' + ], + modifiable: true), + Emoji( + name: 'person getting haircut', + char: '\u{1F487}', + shortName: 'person_getting_haircut', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personActivity, + keywords: [ + 'barber', + 'beauty', + 'haircut', + 'parlor', + 'uc6', + 'diversity', + 'men', + 'daddy', + 'mom', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'dad', + 'papa', + 'pere', + 'father', + 'maman', + 'mommy', + 'mama', + 'mother' + ]), + Emoji( + name: 'person getting haircut: light skin tone', + char: '\u{1F487}\u{1F3FB}', + shortName: 'person_getting_haircut_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personActivity, + keywords: [ + 'barber', + 'beauty', + 'haircut', + 'light skin tone', + 'parlor', + 'uc8', + 'diversity', + 'men', + 'daddy', + 'mom', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'dad', + 'papa', + 'pere', + 'father', + 'maman', + 'mommy', + 'mama', + 'mother' + ], + modifiable: true), + Emoji( + name: 'person getting haircut: medium-light skin tone', + char: '\u{1F487}\u{1F3FC}', + shortName: 'person_getting_haircut_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personActivity, + keywords: [ + 'barber', + 'beauty', + 'haircut', + 'medium-light skin tone', + 'parlor', + 'uc8', + 'diversity', + 'men', + 'daddy', + 'mom', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'dad', + 'papa', + 'pere', + 'father', + 'maman', + 'mommy', + 'mama', + 'mother' + ], + modifiable: true), + Emoji( + name: 'person getting haircut: medium skin tone', + char: '\u{1F487}\u{1F3FD}', + shortName: 'person_getting_haircut_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personActivity, + keywords: [ + 'barber', + 'beauty', + 'haircut', + 'medium skin tone', + 'parlor', + 'uc8', + 'diversity', + 'men', + 'daddy', + 'mom', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'dad', + 'papa', + 'pere', + 'father', + 'maman', + 'mommy', + 'mama', + 'mother' + ], + modifiable: true), + Emoji( + name: 'person getting haircut: medium-dark skin tone', + char: '\u{1F487}\u{1F3FE}', + shortName: 'person_getting_haircut_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personActivity, + keywords: [ + 'barber', + 'beauty', + 'haircut', + 'medium-dark skin tone', + 'parlor', + 'uc8', + 'diversity', + 'men', + 'daddy', + 'mom', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'dad', + 'papa', + 'pere', + 'father', + 'maman', + 'mommy', + 'mama', + 'mother' + ], + modifiable: true), + Emoji( + name: 'person getting haircut: dark skin tone', + char: '\u{1F487}\u{1F3FF}', + shortName: 'person_getting_haircut_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personActivity, + keywords: [ + 'barber', + 'beauty', + 'dark skin tone', + 'haircut', + 'parlor', + 'uc8', + 'diversity', + 'men', + 'daddy', + 'mom', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'dad', + 'papa', + 'pere', + 'father', + 'maman', + 'mommy', + 'mama', + 'mother' + ], + modifiable: true), + Emoji( + name: 'woman getting haircut', + char: '\u{1F487}\u{200D}\u{2640}\u{FE0F}', + shortName: 'woman_getting_haircut', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personActivity, + keywords: [ + 'haircut', + 'woman', + 'uc6', + 'diversity', + 'women', + 'girls night', + 'mom', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'woman', + 'female', + 'ladies night', + 'girls only', + 'girlfriend', + 'maman', + 'mommy', + 'mama', + 'mother' + ]), + Emoji( + name: 'woman getting haircut: light skin tone', + char: '\u{1F487}\u{1F3FB}\u{200D}\u{2640}\u{FE0F}', + shortName: 'woman_getting_haircut_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personActivity, + keywords: [ + 'haircut', + 'light skin tone', + 'woman', + 'uc8', + 'diversity', + 'women', + 'girls night', + 'mom', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'woman', + 'female', + 'ladies night', + 'girls only', + 'girlfriend', + 'maman', + 'mommy', + 'mama', + 'mother' + ], + modifiable: true), + Emoji( + name: 'woman getting haircut: medium-light skin tone', + char: '\u{1F487}\u{1F3FC}\u{200D}\u{2640}\u{FE0F}', + shortName: 'woman_getting_haircut_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personActivity, + keywords: [ + 'haircut', + 'medium-light skin tone', + 'woman', + 'uc8', + 'diversity', + 'women', + 'girls night', + 'mom', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'woman', + 'female', + 'ladies night', + 'girls only', + 'girlfriend', + 'maman', + 'mommy', + 'mama', + 'mother' + ], + modifiable: true), + Emoji( + name: 'woman getting haircut: medium skin tone', + char: '\u{1F487}\u{1F3FD}\u{200D}\u{2640}\u{FE0F}', + shortName: 'woman_getting_haircut_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personActivity, + keywords: [ + 'haircut', + 'medium skin tone', + 'woman', + 'uc8', + 'diversity', + 'women', + 'girls night', + 'mom', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'woman', + 'female', + 'ladies night', + 'girls only', + 'girlfriend', + 'maman', + 'mommy', + 'mama', + 'mother' + ], + modifiable: true), + Emoji( + name: 'woman getting haircut: medium-dark skin tone', + char: '\u{1F487}\u{1F3FE}\u{200D}\u{2640}\u{FE0F}', + shortName: 'woman_getting_haircut_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personActivity, + keywords: [ + 'haircut', + 'medium-dark skin tone', + 'woman', + 'uc8', + 'diversity', + 'women', + 'girls night', + 'mom', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'woman', + 'female', + 'ladies night', + 'girls only', + 'girlfriend', + 'maman', + 'mommy', + 'mama', + 'mother' + ], + modifiable: true), + Emoji( + name: 'woman getting haircut: dark skin tone', + char: '\u{1F487}\u{1F3FF}\u{200D}\u{2640}\u{FE0F}', + shortName: 'woman_getting_haircut_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personActivity, + keywords: [ + 'dark skin tone', + 'haircut', + 'woman', + 'uc8', + 'diversity', + 'women', + 'girls night', + 'mom', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'woman', + 'female', + 'ladies night', + 'girls only', + 'girlfriend', + 'maman', + 'mommy', + 'mama', + 'mother' + ], + modifiable: true), + Emoji( + name: 'man getting haircut', + char: '\u{1F487}\u{200D}\u{2642}\u{FE0F}', + shortName: 'man_getting_haircut', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personActivity, + keywords: [ + 'haircut', + 'man', + 'uc6', + 'diversity', + 'men', + 'daddy', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'dad', + 'papa', + 'pere', + 'father' + ]), + Emoji( + name: 'man getting haircut: light skin tone', + char: '\u{1F487}\u{1F3FB}\u{200D}\u{2642}\u{FE0F}', + shortName: 'man_getting_haircut_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personActivity, + keywords: [ + 'haircut', + 'light skin tone', + 'man', + 'uc8', + 'diversity', + 'men', + 'daddy', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'dad', + 'papa', + 'pere', + 'father' + ], + modifiable: true), + Emoji( + name: 'man getting haircut: medium-light skin tone', + char: '\u{1F487}\u{1F3FC}\u{200D}\u{2642}\u{FE0F}', + shortName: 'man_getting_haircut_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personActivity, + keywords: [ + 'haircut', + 'man', + 'medium-light skin tone', + 'uc8', + 'diversity', + 'men', + 'daddy', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'dad', + 'papa', + 'pere', + 'father' + ], + modifiable: true), + Emoji( + name: 'man getting haircut: medium skin tone', + char: '\u{1F487}\u{1F3FD}\u{200D}\u{2642}\u{FE0F}', + shortName: 'man_getting_haircut_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personActivity, + keywords: [ + 'haircut', + 'man', + 'medium skin tone', + 'uc8', + 'diversity', + 'men', + 'daddy', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'dad', + 'papa', + 'pere', + 'father' + ], + modifiable: true), + Emoji( + name: 'man getting haircut: medium-dark skin tone', + char: '\u{1F487}\u{1F3FE}\u{200D}\u{2642}\u{FE0F}', + shortName: 'man_getting_haircut_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personActivity, + keywords: [ + 'haircut', + 'man', + 'medium-dark skin tone', + 'uc8', + 'diversity', + 'men', + 'daddy', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'dad', + 'papa', + 'pere', + 'father' + ], + modifiable: true), + Emoji( + name: 'man getting haircut: dark skin tone', + char: '\u{1F487}\u{1F3FF}\u{200D}\u{2642}\u{FE0F}', + shortName: 'man_getting_haircut_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personActivity, + keywords: [ + 'dark skin tone', + 'haircut', + 'man', + 'uc8', + 'diversity', + 'men', + 'daddy', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'dad', + 'papa', + 'pere', + 'father' + ], + modifiable: true), + Emoji( + name: 'person getting massage', + char: '\u{1F486}', + shortName: 'person_getting_massage', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personActivity, + keywords: [ + 'face', + 'massage', + 'salon', + 'uc6', + 'diversity', + 'men', + 'girls night', + 'pleased', + 'yoga', + 'calm', + 'spa', + 'mom', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'ladies night', + 'girls only', + 'girlfriend', + 'please', + 'chill', + 'confident', + 'content', + 'meditation', + 'meditate', + 'zen', + 'om', + 'aum', + 'relax', + 'sauna', + 'maman', + 'mommy', + 'mama', + 'mother' + ]), + Emoji( + name: 'person getting massage: light skin tone', + char: '\u{1F486}\u{1F3FB}', + shortName: 'person_getting_massage_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personActivity, + keywords: [ + 'face', + 'light skin tone', + 'massage', + 'salon', + 'uc8', + 'diversity', + 'men', + 'girls night', + 'pleased', + 'yoga', + 'calm', + 'spa', + 'mom', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'ladies night', + 'girls only', + 'girlfriend', + 'please', + 'chill', + 'confident', + 'content', + 'meditation', + 'meditate', + 'zen', + 'om', + 'aum', + 'relax', + 'sauna', + 'maman', + 'mommy', + 'mama', + 'mother' + ], + modifiable: true), + Emoji( + name: 'person getting massage: medium-light skin tone', + char: '\u{1F486}\u{1F3FC}', + shortName: 'person_getting_massage_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personActivity, + keywords: [ + 'face', + 'massage', + 'medium-light skin tone', + 'salon', + 'uc8', + 'diversity', + 'men', + 'girls night', + 'pleased', + 'yoga', + 'calm', + 'spa', + 'mom', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'ladies night', + 'girls only', + 'girlfriend', + 'please', + 'chill', + 'confident', + 'content', + 'meditation', + 'meditate', + 'zen', + 'om', + 'aum', + 'relax', + 'sauna', + 'maman', + 'mommy', + 'mama', + 'mother' + ], + modifiable: true), + Emoji( + name: 'person getting massage: medium skin tone', + char: '\u{1F486}\u{1F3FD}', + shortName: 'person_getting_massage_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personActivity, + keywords: [ + 'face', + 'massage', + 'medium skin tone', + 'salon', + 'uc8', + 'diversity', + 'men', + 'girls night', + 'pleased', + 'yoga', + 'calm', + 'spa', + 'mom', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'ladies night', + 'girls only', + 'girlfriend', + 'please', + 'chill', + 'confident', + 'content', + 'meditation', + 'meditate', + 'zen', + 'om', + 'aum', + 'relax', + 'sauna', + 'maman', + 'mommy', + 'mama', + 'mother' + ], + modifiable: true), + Emoji( + name: 'person getting massage: medium-dark skin tone', + char: '\u{1F486}\u{1F3FE}', + shortName: 'person_getting_massage_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personActivity, + keywords: [ + 'face', + 'massage', + 'medium-dark skin tone', + 'salon', + 'uc8', + 'diversity', + 'men', + 'girls night', + 'pleased', + 'yoga', + 'calm', + 'spa', + 'mom', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'ladies night', + 'girls only', + 'girlfriend', + 'please', + 'chill', + 'confident', + 'content', + 'meditation', + 'meditate', + 'zen', + 'om', + 'aum', + 'relax', + 'sauna', + 'maman', + 'mommy', + 'mama', + 'mother' + ], + modifiable: true), + Emoji( + name: 'person getting massage: dark skin tone', + char: '\u{1F486}\u{1F3FF}', + shortName: 'person_getting_massage_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personActivity, + keywords: [ + 'dark skin tone', + 'face', + 'massage', + 'salon', + 'uc8', + 'diversity', + 'men', + 'girls night', + 'pleased', + 'yoga', + 'calm', + 'spa', + 'mom', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'ladies night', + 'girls only', + 'girlfriend', + 'please', + 'chill', + 'confident', + 'content', + 'meditation', + 'meditate', + 'zen', + 'om', + 'aum', + 'relax', + 'sauna', + 'maman', + 'mommy', + 'mama', + 'mother' + ], + modifiable: true), + Emoji( + name: 'woman getting massage', + char: '\u{1F486}\u{200D}\u{2640}\u{FE0F}', + shortName: 'woman_getting_face_massage', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personActivity, + keywords: [ + 'face', + 'massage', + 'woman', + 'uc6', + 'diversity', + 'women', + 'girls night', + 'yoga', + 'calm', + 'spa', + 'mom', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'woman', + 'female', + 'ladies night', + 'girls only', + 'girlfriend', + 'meditation', + 'meditate', + 'zen', + 'om', + 'aum', + 'relax', + 'sauna', + 'maman', + 'mommy', + 'mama', + 'mother' + ]), + Emoji( + name: 'woman getting massage: light skin tone', + char: '\u{1F486}\u{1F3FB}\u{200D}\u{2640}\u{FE0F}', + shortName: 'woman_getting_face_massage_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personActivity, + keywords: [ + 'face', + 'light skin tone', + 'massage', + 'woman', + 'uc8', + 'diversity', + 'women', + 'girls night', + 'yoga', + 'calm', + 'spa', + 'mom', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'woman', + 'female', + 'ladies night', + 'girls only', + 'girlfriend', + 'meditation', + 'meditate', + 'zen', + 'om', + 'aum', + 'relax', + 'sauna', + 'maman', + 'mommy', + 'mama', + 'mother' + ], + modifiable: true), + Emoji( + name: 'woman getting massage: medium-light skin tone', + char: '\u{1F486}\u{1F3FC}\u{200D}\u{2640}\u{FE0F}', + shortName: 'woman_getting_face_massage_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personActivity, + keywords: [ + 'face', + 'massage', + 'medium-light skin tone', + 'woman', + 'uc8', + 'diversity', + 'women', + 'girls night', + 'yoga', + 'calm', + 'spa', + 'mom', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'woman', + 'female', + 'ladies night', + 'girls only', + 'girlfriend', + 'meditation', + 'meditate', + 'zen', + 'om', + 'aum', + 'relax', + 'sauna', + 'maman', + 'mommy', + 'mama', + 'mother' + ], + modifiable: true), + Emoji( + name: 'woman getting massage: medium skin tone', + char: '\u{1F486}\u{1F3FD}\u{200D}\u{2640}\u{FE0F}', + shortName: 'woman_getting_face_massage_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personActivity, + keywords: [ + 'face', + 'massage', + 'medium skin tone', + 'woman', + 'uc8', + 'diversity', + 'women', + 'girls night', + 'yoga', + 'calm', + 'spa', + 'mom', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'woman', + 'female', + 'ladies night', + 'girls only', + 'girlfriend', + 'meditation', + 'meditate', + 'zen', + 'om', + 'aum', + 'relax', + 'sauna', + 'maman', + 'mommy', + 'mama', + 'mother' + ], + modifiable: true), + Emoji( + name: 'woman getting massage: medium-dark skin tone', + char: '\u{1F486}\u{1F3FE}\u{200D}\u{2640}\u{FE0F}', + shortName: 'woman_getting_face_massage_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personActivity, + keywords: [ + 'face', + 'massage', + 'medium-dark skin tone', + 'woman', + 'uc8', + 'diversity', + 'women', + 'girls night', + 'yoga', + 'calm', + 'spa', + 'mom', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'woman', + 'female', + 'ladies night', + 'girls only', + 'girlfriend', + 'meditation', + 'meditate', + 'zen', + 'om', + 'aum', + 'relax', + 'sauna', + 'maman', + 'mommy', + 'mama', + 'mother' + ], + modifiable: true), + Emoji( + name: 'woman getting massage: dark skin tone', + char: '\u{1F486}\u{1F3FF}\u{200D}\u{2640}\u{FE0F}', + shortName: 'woman_getting_face_massage_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personActivity, + keywords: [ + 'dark skin tone', + 'face', + 'massage', + 'woman', + 'uc8', + 'diversity', + 'women', + 'girls night', + 'yoga', + 'calm', + 'spa', + 'mom', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'woman', + 'female', + 'ladies night', + 'girls only', + 'girlfriend', + 'meditation', + 'meditate', + 'zen', + 'om', + 'aum', + 'relax', + 'sauna', + 'maman', + 'mommy', + 'mama', + 'mother' + ], + modifiable: true), + Emoji( + name: 'man getting massage', + char: '\u{1F486}\u{200D}\u{2642}\u{FE0F}', + shortName: 'man_getting_face_massage', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personActivity, + keywords: [ + 'face', + 'man', + 'massage', + 'uc6', + 'diversity', + 'men', + 'yoga', + 'calm', + 'spa', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'meditation', + 'meditate', + 'zen', + 'om', + 'aum', + 'relax', + 'sauna' + ]), + Emoji( + name: 'man getting massage: light skin tone', + char: '\u{1F486}\u{1F3FB}\u{200D}\u{2642}\u{FE0F}', + shortName: 'man_getting_face_massage_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personActivity, + keywords: [ + 'face', + 'light skin tone', + 'man', + 'massage', + 'uc8', + 'diversity', + 'men', + 'yoga', + 'calm', + 'spa', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'meditation', + 'meditate', + 'zen', + 'om', + 'aum', + 'relax', + 'sauna' + ], + modifiable: true), + Emoji( + name: 'man getting massage: medium-light skin tone', + char: '\u{1F486}\u{1F3FC}\u{200D}\u{2642}\u{FE0F}', + shortName: 'man_getting_face_massage_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personActivity, + keywords: [ + 'face', + 'man', + 'massage', + 'medium-light skin tone', + 'uc8', + 'diversity', + 'men', + 'yoga', + 'calm', + 'spa', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'meditation', + 'meditate', + 'zen', + 'om', + 'aum', + 'relax', + 'sauna' + ], + modifiable: true), + Emoji( + name: 'man getting massage: medium skin tone', + char: '\u{1F486}\u{1F3FD}\u{200D}\u{2642}\u{FE0F}', + shortName: 'man_getting_face_massage_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personActivity, + keywords: [ + 'face', + 'man', + 'massage', + 'medium skin tone', + 'uc8', + 'diversity', + 'men', + 'yoga', + 'calm', + 'spa', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'meditation', + 'meditate', + 'zen', + 'om', + 'aum', + 'relax', + 'sauna' + ], + modifiable: true), + Emoji( + name: 'man getting massage: medium-dark skin tone', + char: '\u{1F486}\u{1F3FE}\u{200D}\u{2642}\u{FE0F}', + shortName: 'man_getting_face_massage_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personActivity, + keywords: [ + 'face', + 'man', + 'massage', + 'medium-dark skin tone', + 'uc8', + 'diversity', + 'men', + 'yoga', + 'calm', + 'spa', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'meditation', + 'meditate', + 'zen', + 'om', + 'aum', + 'relax', + 'sauna' + ], + modifiable: true), + Emoji( + name: 'man getting massage: dark skin tone', + char: '\u{1F486}\u{1F3FF}\u{200D}\u{2642}\u{FE0F}', + shortName: 'man_getting_face_massage_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personActivity, + keywords: [ + 'dark skin tone', + 'face', + 'man', + 'massage', + 'uc8', + 'diversity', + 'men', + 'yoga', + 'calm', + 'spa', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'meditation', + 'meditate', + 'zen', + 'om', + 'aum', + 'relax', + 'sauna' + ], + modifiable: true), + Emoji( + name: 'person in steamy room', + char: '\u{1F9D6}', + shortName: 'person_in_steamy_room', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personActivity, + keywords: [ + 'uc10', + 'diversity', + 'hot', + 'steam', + 'girls night', + 'spa', + 'mom', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'heat', + 'warm', + 'caliente', + 'chaud', + 'heiß', + 'steaming', + 'piping', + 'ladies night', + 'girls only', + 'girlfriend', + 'relax', + 'sauna', + 'maman', + 'mommy', + 'mama', + 'mother' + ]), + Emoji( + name: 'person in steamy room: light skin tone', + char: '\u{1F9D6}\u{1F3FB}', + shortName: 'person_in_steamy_room_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personActivity, + keywords: [ + 'light skin tone', + 'sauna', + 'steam room', + 'uc10', + 'diversity', + 'hot', + 'steam', + 'girls night', + 'spa', + 'mom', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'heat', + 'warm', + 'caliente', + 'chaud', + 'heiß', + 'steaming', + 'piping', + 'ladies night', + 'girls only', + 'girlfriend', + 'relax', + 'sauna', + 'maman', + 'mommy', + 'mama', + 'mother' + ], + modifiable: true), + Emoji( + name: 'person in steamy room: medium-light skin tone', + char: '\u{1F9D6}\u{1F3FC}', + shortName: 'person_in_steamy_room_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personActivity, + keywords: [ + 'medium-light skin tone', + 'sauna', + 'steam room', + 'uc10', + 'diversity', + 'hot', + 'steam', + 'girls night', + 'spa', + 'mom', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'heat', + 'warm', + 'caliente', + 'chaud', + 'heiß', + 'steaming', + 'piping', + 'ladies night', + 'girls only', + 'girlfriend', + 'relax', + 'sauna', + 'maman', + 'mommy', + 'mama', + 'mother' + ], + modifiable: true), + Emoji( + name: 'person in steamy room: medium skin tone', + char: '\u{1F9D6}\u{1F3FD}', + shortName: 'person_in_steamy_room_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personActivity, + keywords: [ + 'medium skin tone', + 'sauna', + 'steam room', + 'uc10', + 'diversity', + 'hot', + 'steam', + 'girls night', + 'spa', + 'mom', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'heat', + 'warm', + 'caliente', + 'chaud', + 'heiß', + 'steaming', + 'piping', + 'ladies night', + 'girls only', + 'girlfriend', + 'relax', + 'sauna', + 'maman', + 'mommy', + 'mama', + 'mother' + ], + modifiable: true), + Emoji( + name: 'person in steamy room: medium-dark skin tone', + char: '\u{1F9D6}\u{1F3FE}', + shortName: 'person_in_steamy_room_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personActivity, + keywords: [ + 'medium-dark skin tone', + 'sauna', + 'steam room', + 'uc10', + 'diversity', + 'hot', + 'steam', + 'girls night', + 'spa', + 'mom', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'heat', + 'warm', + 'caliente', + 'chaud', + 'heiß', + 'steaming', + 'piping', + 'ladies night', + 'girls only', + 'girlfriend', + 'relax', + 'sauna', + 'maman', + 'mommy', + 'mama', + 'mother' + ], + modifiable: true), + Emoji( + name: 'person in steamy room: dark skin tone', + char: '\u{1F9D6}\u{1F3FF}', + shortName: 'person_in_steamy_room_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personActivity, + keywords: [ + 'dark skin tone', + 'sauna', + 'steam room', + 'uc10', + 'diversity', + 'hot', + 'steam', + 'girls night', + 'spa', + 'mom', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'heat', + 'warm', + 'caliente', + 'chaud', + 'heiß', + 'steaming', + 'piping', + 'ladies night', + 'girls only', + 'girlfriend', + 'relax', + 'sauna', + 'maman', + 'mommy', + 'mama', + 'mother' + ], + modifiable: true), + Emoji( + name: 'woman in steamy room', + char: '\u{1F9D6}\u{200D}\u{2640}\u{FE0F}', + shortName: 'woman_in_steamy_room', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personActivity, + keywords: [ + 'sauna', + 'steam room', + 'uc10', + 'diversity', + 'women', + 'hot', + 'steam', + 'girls night', + 'spa', + 'mom', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'woman', + 'female', + 'heat', + 'warm', + 'caliente', + 'chaud', + 'heiß', + 'steaming', + 'piping', + 'ladies night', + 'girls only', + 'girlfriend', + 'relax', + 'sauna', + 'maman', + 'mommy', + 'mama', + 'mother' + ]), + Emoji( + name: 'woman in steamy room: light skin tone', + char: '\u{1F9D6}\u{1F3FB}\u{200D}\u{2640}\u{FE0F}', + shortName: 'woman_in_steamy_room_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personActivity, + keywords: [ + 'light skin tone', + 'sauna', + 'steam room', + 'uc10', + 'diversity', + 'women', + 'hot', + 'steam', + 'girls night', + 'spa', + 'mom', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'woman', + 'female', + 'heat', + 'warm', + 'caliente', + 'chaud', + 'heiß', + 'steaming', + 'piping', + 'ladies night', + 'girls only', + 'girlfriend', + 'relax', + 'sauna', + 'maman', + 'mommy', + 'mama', + 'mother' + ], + modifiable: true), + Emoji( + name: 'woman in steamy room: medium-light skin tone', + char: '\u{1F9D6}\u{1F3FC}\u{200D}\u{2640}\u{FE0F}', + shortName: 'woman_in_steamy_room_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personActivity, + keywords: [ + 'medium-light skin tone', + 'sauna', + 'steam room', + 'uc10', + 'diversity', + 'women', + 'hot', + 'steam', + 'girls night', + 'spa', + 'mom', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'woman', + 'female', + 'heat', + 'warm', + 'caliente', + 'chaud', + 'heiß', + 'steaming', + 'piping', + 'ladies night', + 'girls only', + 'girlfriend', + 'relax', + 'sauna', + 'maman', + 'mommy', + 'mama', + 'mother' + ], + modifiable: true), + Emoji( + name: 'woman in steamy room: medium skin tone', + char: '\u{1F9D6}\u{1F3FD}\u{200D}\u{2640}\u{FE0F}', + shortName: 'woman_in_steamy_room_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personActivity, + keywords: [ + 'medium skin tone', + 'sauna', + 'steam room', + 'uc10', + 'diversity', + 'women', + 'hot', + 'steam', + 'girls night', + 'spa', + 'mom', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'woman', + 'female', + 'heat', + 'warm', + 'caliente', + 'chaud', + 'heiß', + 'steaming', + 'piping', + 'ladies night', + 'girls only', + 'girlfriend', + 'relax', + 'sauna', + 'maman', + 'mommy', + 'mama', + 'mother' + ], + modifiable: true), + Emoji( + name: 'woman in steamy room: medium-dark skin tone', + char: '\u{1F9D6}\u{1F3FE}\u{200D}\u{2640}\u{FE0F}', + shortName: 'woman_in_steamy_room_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personActivity, + keywords: [ + 'medium-dark skin tone', + 'sauna', + 'steam room', + 'uc10', + 'diversity', + 'women', + 'hot', + 'steam', + 'girls night', + 'spa', + 'mom', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'woman', + 'female', + 'heat', + 'warm', + 'caliente', + 'chaud', + 'heiß', + 'steaming', + 'piping', + 'ladies night', + 'girls only', + 'girlfriend', + 'relax', + 'sauna', + 'maman', + 'mommy', + 'mama', + 'mother' + ], + modifiable: true), + Emoji( + name: 'woman in steamy room: dark skin tone', + char: '\u{1F9D6}\u{1F3FF}\u{200D}\u{2640}\u{FE0F}', + shortName: 'woman_in_steamy_room_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personActivity, + keywords: [ + 'dark skin tone', + 'sauna', + 'steam room', + 'uc10', + 'diversity', + 'women', + 'hot', + 'steam', + 'girls night', + 'spa', + 'mom', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'woman', + 'female', + 'heat', + 'warm', + 'caliente', + 'chaud', + 'heiß', + 'steaming', + 'piping', + 'ladies night', + 'girls only', + 'girlfriend', + 'relax', + 'sauna', + 'maman', + 'mommy', + 'mama', + 'mother' + ], + modifiable: true), + Emoji( + name: 'man in steamy room', + char: '\u{1F9D6}\u{200D}\u{2642}\u{FE0F}', + shortName: 'man_in_steamy_room', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personActivity, + keywords: [ + 'sauna', + 'steam room', + 'uc10', + 'diversity', + 'hot', + 'steam', + 'spa', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'heat', + 'warm', + 'caliente', + 'chaud', + 'heiß', + 'steaming', + 'piping', + 'relax', + 'sauna' + ]), + Emoji( + name: 'man in steamy room: light skin tone', + char: '\u{1F9D6}\u{1F3FB}\u{200D}\u{2642}\u{FE0F}', + shortName: 'man_in_steamy_room_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personActivity, + keywords: [ + 'light skin tone', + 'sauna', + 'steam room', + 'uc10', + 'diversity', + 'hot', + 'steam', + 'spa', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'heat', + 'warm', + 'caliente', + 'chaud', + 'heiß', + 'steaming', + 'piping', + 'relax', + 'sauna' + ], + modifiable: true), + Emoji( + name: 'man in steamy room: medium-light skin tone', + char: '\u{1F9D6}\u{1F3FC}\u{200D}\u{2642}\u{FE0F}', + shortName: 'man_in_steamy_room_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personActivity, + keywords: [ + 'medium-light skin tone', + 'sauna', + 'steam room', + 'uc10', + 'diversity', + 'hot', + 'steam', + 'spa', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'heat', + 'warm', + 'caliente', + 'chaud', + 'heiß', + 'steaming', + 'piping', + 'relax', + 'sauna' + ], + modifiable: true), + Emoji( + name: 'man in steamy room: medium skin tone', + char: '\u{1F9D6}\u{1F3FD}\u{200D}\u{2642}\u{FE0F}', + shortName: 'man_in_steamy_room_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personActivity, + keywords: [ + 'medium skin tone', + 'sauna', + 'steam room', + 'uc10', + 'diversity', + 'hot', + 'steam', + 'spa', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'heat', + 'warm', + 'caliente', + 'chaud', + 'heiß', + 'steaming', + 'piping', + 'relax', + 'sauna' + ], + modifiable: true), + Emoji( + name: 'man in steamy room: medium-dark skin tone', + char: '\u{1F9D6}\u{1F3FE}\u{200D}\u{2642}\u{FE0F}', + shortName: 'man_in_steamy_room_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personActivity, + keywords: [ + 'medium-dark skin tone', + 'sauna', + 'steam room', + 'uc10', + 'diversity', + 'hot', + 'steam', + 'spa', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'heat', + 'warm', + 'caliente', + 'chaud', + 'heiß', + 'steaming', + 'piping', + 'relax', + 'sauna' + ], + modifiable: true), + Emoji( + name: 'man in steamy room: dark skin tone', + char: '\u{1F9D6}\u{1F3FF}\u{200D}\u{2642}\u{FE0F}', + shortName: 'man_in_steamy_room_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personActivity, + keywords: [ + 'dark skin tone', + 'sauna', + 'steam room', + 'uc10', + 'diversity', + 'hot', + 'steam', + 'spa', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'heat', + 'warm', + 'caliente', + 'chaud', + 'heiß', + 'steaming', + 'piping', + 'relax', + 'sauna' + ], + modifiable: true), + Emoji( + name: 'nail polish', + char: '\u{1F485}', + shortName: 'nail_care', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.handProp, + keywords: [ + 'care', + 'cosmetics', + 'manicure', + 'nail', + 'polish', + 'uc6', + 'diversity', + 'women', + 'body', + 'hands', + 'nailpolish', + 'beautiful', + 'girls night', + 'painting', + 'mom', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'woman', + 'female', + 'body part', + 'anatomy', + 'hand', + 'finger', + 'fingers', + 'nails', + 'fingernails', + 'cute', + 'pretty', + 'adorable', + 'adore', + 'beauty', + 'cutie', + 'babe', + 'lovely', + 'ladies night', + 'girls only', + 'girlfriend', + 'painter', + 'arts', + 'maman', + 'mommy', + 'mama', + 'mother' + ]), + Emoji( + name: 'nail polish: light skin tone', + char: '\u{1F485}\u{1F3FB}', + shortName: 'nail_care_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.handProp, + keywords: [ + 'care', + 'cosmetics', + 'light skin tone', + 'manicure', + 'nail', + 'polish', + 'uc8', + 'diversity', + 'women', + 'body', + 'hands', + 'nailpolish', + 'beautiful', + 'girls night', + 'painting', + 'mom', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'woman', + 'female', + 'body part', + 'anatomy', + 'hand', + 'finger', + 'fingers', + 'nails', + 'fingernails', + 'cute', + 'pretty', + 'adorable', + 'adore', + 'beauty', + 'cutie', + 'babe', + 'lovely', + 'ladies night', + 'girls only', + 'girlfriend', + 'painter', + 'arts', + 'maman', + 'mommy', + 'mama', + 'mother' + ], + modifiable: true), + Emoji( + name: 'nail polish: medium-light skin tone', + char: '\u{1F485}\u{1F3FC}', + shortName: 'nail_care_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.handProp, + keywords: [ + 'care', + 'cosmetics', + 'manicure', + 'medium-light skin tone', + 'nail', + 'polish', + 'uc8', + 'diversity', + 'women', + 'body', + 'hands', + 'nailpolish', + 'beautiful', + 'girls night', + 'painting', + 'mom', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'woman', + 'female', + 'body part', + 'anatomy', + 'hand', + 'finger', + 'fingers', + 'nails', + 'fingernails', + 'cute', + 'pretty', + 'adorable', + 'adore', + 'beauty', + 'cutie', + 'babe', + 'lovely', + 'ladies night', + 'girls only', + 'girlfriend', + 'painter', + 'arts', + 'maman', + 'mommy', + 'mama', + 'mother' + ], + modifiable: true), + Emoji( + name: 'nail polish: medium skin tone', + char: '\u{1F485}\u{1F3FD}', + shortName: 'nail_care_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.handProp, + keywords: [ + 'care', + 'cosmetics', + 'manicure', + 'medium skin tone', + 'nail', + 'polish', + 'uc8', + 'diversity', + 'women', + 'body', + 'hands', + 'nailpolish', + 'beautiful', + 'girls night', + 'painting', + 'mom', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'woman', + 'female', + 'body part', + 'anatomy', + 'hand', + 'finger', + 'fingers', + 'nails', + 'fingernails', + 'cute', + 'pretty', + 'adorable', + 'adore', + 'beauty', + 'cutie', + 'babe', + 'lovely', + 'ladies night', + 'girls only', + 'girlfriend', + 'painter', + 'arts', + 'maman', + 'mommy', + 'mama', + 'mother' + ], + modifiable: true), + Emoji( + name: 'nail polish: medium-dark skin tone', + char: '\u{1F485}\u{1F3FE}', + shortName: 'nail_care_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.handProp, + keywords: [ + 'care', + 'cosmetics', + 'manicure', + 'medium-dark skin tone', + 'nail', + 'polish', + 'uc8', + 'diversity', + 'women', + 'body', + 'hands', + 'nailpolish', + 'beautiful', + 'girls night', + 'painting', + 'mom', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'woman', + 'female', + 'body part', + 'anatomy', + 'hand', + 'finger', + 'fingers', + 'nails', + 'fingernails', + 'cute', + 'pretty', + 'adorable', + 'adore', + 'beauty', + 'cutie', + 'babe', + 'lovely', + 'ladies night', + 'girls only', + 'girlfriend', + 'painter', + 'arts', + 'maman', + 'mommy', + 'mama', + 'mother' + ], + modifiable: true), + Emoji( + name: 'nail polish: dark skin tone', + char: '\u{1F485}\u{1F3FF}', + shortName: 'nail_care_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.handProp, + keywords: [ + 'care', + 'cosmetics', + 'dark skin tone', + 'manicure', + 'nail', + 'polish', + 'uc8', + 'diversity', + 'women', + 'body', + 'hands', + 'nailpolish', + 'beautiful', + 'girls night', + 'painting', + 'mom', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'woman', + 'female', + 'body part', + 'anatomy', + 'hand', + 'finger', + 'fingers', + 'nails', + 'fingernails', + 'cute', + 'pretty', + 'adorable', + 'adore', + 'beauty', + 'cutie', + 'babe', + 'lovely', + 'ladies night', + 'girls only', + 'girlfriend', + 'painter', + 'arts', + 'maman', + 'mommy', + 'mama', + 'mother' + ], + modifiable: true), + Emoji( + name: 'selfie', + char: '\u{1F933}', + shortName: 'selfie', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.handProp, + keywords: [ + 'camera', + 'phone', + 'selfie', + 'uc9', + 'diversity', + 'selfie', + 'fame', + 'instagram', + 'fun', + 'youtube', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'famous', + 'celebrity', + 'vlog' + ]), + Emoji( + name: 'selfie: light skin tone', + char: '\u{1F933}\u{1F3FB}', + shortName: 'selfie_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.handProp, + keywords: [ + 'camera', + 'light skin tone', + 'phone', + 'selfie', + 'uc9', + 'diversity', + 'selfie', + 'fame', + 'instagram', + 'fun', + 'youtube', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'famous', + 'celebrity', + 'vlog' + ], + modifiable: true), + Emoji( + name: 'selfie: medium-light skin tone', + char: '\u{1F933}\u{1F3FC}', + shortName: 'selfie_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.handProp, + keywords: [ + 'camera', + 'medium-light skin tone', + 'phone', + 'selfie', + 'uc9', + 'diversity', + 'selfie', + 'fame', + 'instagram', + 'fun', + 'youtube', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'famous', + 'celebrity', + 'vlog' + ], + modifiable: true), + Emoji( + name: 'selfie: medium skin tone', + char: '\u{1F933}\u{1F3FD}', + shortName: 'selfie_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.handProp, + keywords: [ + 'camera', + 'medium skin tone', + 'phone', + 'selfie', + 'uc9', + 'diversity', + 'selfie', + 'fame', + 'instagram', + 'fun', + 'youtube', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'famous', + 'celebrity', + 'vlog' + ], + modifiable: true), + Emoji( + name: 'selfie: medium-dark skin tone', + char: '\u{1F933}\u{1F3FE}', + shortName: 'selfie_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.handProp, + keywords: [ + 'camera', + 'medium-dark skin tone', + 'phone', + 'selfie', + 'uc9', + 'diversity', + 'selfie', + 'fame', + 'instagram', + 'fun', + 'youtube', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'famous', + 'celebrity', + 'vlog' + ], + modifiable: true), + Emoji( + name: 'selfie: dark skin tone', + char: '\u{1F933}\u{1F3FF}', + shortName: 'selfie_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.handProp, + keywords: [ + 'camera', + 'dark skin tone', + 'phone', + 'selfie', + 'uc9', + 'diversity', + 'selfie', + 'fame', + 'instagram', + 'fun', + 'youtube', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'famous', + 'celebrity', + 'vlog' + ], + modifiable: true), + Emoji( + name: 'woman dancing', + char: '\u{1F483}', + shortName: 'dancer', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personActivity, + keywords: [ + 'dancing', + 'woman', + 'uc6', + 'instruments', + 'diversity', + 'women', + 'mexican', + 'sexy', + 'circus', + 'beautiful', + 'girls night', + 'dance', + 'hawaii', + 'celebrate', + 'disco', + 'las vegas', + 'fun', + 'activity', + 'dress', + 'music', + 'instrument', + 'singing', + 'concert', + 'jaz', + 'listen', + 'singer', + 'song', + 'musique', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'woman', + 'female', + 'mexico', + 'cinco de mayo', + 'español', + 'circus tent', + 'clown', + 'clowns', + 'cute', + 'pretty', + 'adorable', + 'adore', + 'beauty', + 'cutie', + 'babe', + 'lovely', + 'ladies night', + 'girls only', + 'girlfriend', + 'dancers', + 'dancing', + 'ballet', + 'ballerina', + 'dabbing', + 'salsa', + 'aloha', + 'kawaii', + 'maui', + 'moana', + 'celebration', + 'event', + 'celebrating', + 'festa', + 'parties', + 'events', + 'new years', + 'new year', + 'fiesta', + 'fete', + 'newyear', + 'party', + 'festive', + 'festival', + 'yolo', + 'festejar', + 'vegas' + ]), + Emoji( + name: 'woman dancing: light skin tone', + char: '\u{1F483}\u{1F3FB}', + shortName: 'dancer_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personActivity, + keywords: [ + 'dancing', + 'light skin tone', + 'woman', + 'uc8', + 'instruments', + 'diversity', + 'women', + 'mexican', + 'sexy', + 'circus', + 'beautiful', + 'girls night', + 'dance', + 'hawaii', + 'celebrate', + 'disco', + 'las vegas', + 'fun', + 'activity', + 'dress', + 'music', + 'instrument', + 'singing', + 'concert', + 'jaz', + 'listen', + 'singer', + 'song', + 'musique', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'woman', + 'female', + 'mexico', + 'cinco de mayo', + 'español', + 'circus tent', + 'clown', + 'clowns', + 'cute', + 'pretty', + 'adorable', + 'adore', + 'beauty', + 'cutie', + 'babe', + 'lovely', + 'ladies night', + 'girls only', + 'girlfriend', + 'dancers', + 'dancing', + 'ballet', + 'ballerina', + 'dabbing', + 'salsa', + 'aloha', + 'kawaii', + 'maui', + 'moana', + 'celebration', + 'event', + 'celebrating', + 'festa', + 'parties', + 'events', + 'new years', + 'new year', + 'fiesta', + 'fete', + 'newyear', + 'party', + 'festive', + 'festival', + 'yolo', + 'festejar', + 'vegas' + ], + modifiable: true), + Emoji( + name: 'woman dancing: medium-light skin tone', + char: '\u{1F483}\u{1F3FC}', + shortName: 'dancer_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personActivity, + keywords: [ + 'dancing', + 'medium-light skin tone', + 'woman', + 'uc8', + 'instruments', + 'diversity', + 'women', + 'mexican', + 'sexy', + 'circus', + 'beautiful', + 'girls night', + 'dance', + 'hawaii', + 'celebrate', + 'disco', + 'las vegas', + 'fun', + 'activity', + 'dress', + 'music', + 'instrument', + 'singing', + 'concert', + 'jaz', + 'listen', + 'singer', + 'song', + 'musique', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'woman', + 'female', + 'mexico', + 'cinco de mayo', + 'español', + 'circus tent', + 'clown', + 'clowns', + 'cute', + 'pretty', + 'adorable', + 'adore', + 'beauty', + 'cutie', + 'babe', + 'lovely', + 'ladies night', + 'girls only', + 'girlfriend', + 'dancers', + 'dancing', + 'ballet', + 'ballerina', + 'dabbing', + 'salsa', + 'aloha', + 'kawaii', + 'maui', + 'moana', + 'celebration', + 'event', + 'celebrating', + 'festa', + 'parties', + 'events', + 'new years', + 'new year', + 'fiesta', + 'fete', + 'newyear', + 'party', + 'festive', + 'festival', + 'yolo', + 'festejar', + 'vegas' + ], + modifiable: true), + Emoji( + name: 'woman dancing: medium skin tone', + char: '\u{1F483}\u{1F3FD}', + shortName: 'dancer_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personActivity, + keywords: [ + 'dancing', + 'medium skin tone', + 'woman', + 'uc8', + 'instruments', + 'diversity', + 'women', + 'mexican', + 'sexy', + 'circus', + 'beautiful', + 'girls night', + 'dance', + 'hawaii', + 'celebrate', + 'disco', + 'las vegas', + 'fun', + 'activity', + 'dress', + 'music', + 'instrument', + 'singing', + 'concert', + 'jaz', + 'listen', + 'singer', + 'song', + 'musique', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'woman', + 'female', + 'mexico', + 'cinco de mayo', + 'español', + 'circus tent', + 'clown', + 'clowns', + 'cute', + 'pretty', + 'adorable', + 'adore', + 'beauty', + 'cutie', + 'babe', + 'lovely', + 'ladies night', + 'girls only', + 'girlfriend', + 'dancers', + 'dancing', + 'ballet', + 'ballerina', + 'dabbing', + 'salsa', + 'aloha', + 'kawaii', + 'maui', + 'moana', + 'celebration', + 'event', + 'celebrating', + 'festa', + 'parties', + 'events', + 'new years', + 'new year', + 'fiesta', + 'fete', + 'newyear', + 'party', + 'festive', + 'festival', + 'yolo', + 'festejar', + 'vegas' + ], + modifiable: true), + Emoji( + name: 'woman dancing: medium-dark skin tone', + char: '\u{1F483}\u{1F3FE}', + shortName: 'dancer_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personActivity, + keywords: [ + 'dancing', + 'medium-dark skin tone', + 'woman', + 'uc8', + 'instruments', + 'diversity', + 'women', + 'mexican', + 'sexy', + 'circus', + 'beautiful', + 'girls night', + 'dance', + 'hawaii', + 'celebrate', + 'disco', + 'las vegas', + 'fun', + 'activity', + 'dress', + 'music', + 'instrument', + 'singing', + 'concert', + 'jaz', + 'listen', + 'singer', + 'song', + 'musique', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'woman', + 'female', + 'mexico', + 'cinco de mayo', + 'español', + 'circus tent', + 'clown', + 'clowns', + 'cute', + 'pretty', + 'adorable', + 'adore', + 'beauty', + 'cutie', + 'babe', + 'lovely', + 'ladies night', + 'girls only', + 'girlfriend', + 'dancers', + 'dancing', + 'ballet', + 'ballerina', + 'dabbing', + 'salsa', + 'aloha', + 'kawaii', + 'maui', + 'moana', + 'celebration', + 'event', + 'celebrating', + 'festa', + 'parties', + 'events', + 'new years', + 'new year', + 'fiesta', + 'fete', + 'newyear', + 'party', + 'festive', + 'festival', + 'yolo', + 'festejar', + 'vegas' + ], + modifiable: true), + Emoji( + name: 'woman dancing: dark skin tone', + char: '\u{1F483}\u{1F3FF}', + shortName: 'dancer_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personActivity, + keywords: [ + 'dancing', + 'dark skin tone', + 'woman', + 'uc8', + 'instruments', + 'diversity', + 'women', + 'mexican', + 'sexy', + 'circus', + 'beautiful', + 'girls night', + 'dance', + 'hawaii', + 'celebrate', + 'disco', + 'las vegas', + 'fun', + 'activity', + 'dress', + 'music', + 'instrument', + 'singing', + 'concert', + 'jaz', + 'listen', + 'singer', + 'song', + 'musique', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'woman', + 'female', + 'mexico', + 'cinco de mayo', + 'español', + 'circus tent', + 'clown', + 'clowns', + 'cute', + 'pretty', + 'adorable', + 'adore', + 'beauty', + 'cutie', + 'babe', + 'lovely', + 'ladies night', + 'girls only', + 'girlfriend', + 'dancers', + 'dancing', + 'ballet', + 'ballerina', + 'dabbing', + 'salsa', + 'aloha', + 'kawaii', + 'maui', + 'moana', + 'celebration', + 'event', + 'celebrating', + 'festa', + 'parties', + 'events', + 'new years', + 'new year', + 'fiesta', + 'fete', + 'newyear', + 'party', + 'festive', + 'festival', + 'yolo', + 'festejar', + 'vegas' + ], + modifiable: true), + Emoji( + name: 'man dancing', + char: '\u{1F57A}', + shortName: 'man_dancing', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personActivity, + keywords: [ + 'dance', + 'man', + 'uc9', + 'instruments', + 'diversity', + 'men', + 'boys night', + 'dance', + 'celebrate', + 'disco', + 'daddy', + 'las vegas', + 'fun', + 'activity', + 'music', + 'instrument', + 'singing', + 'concert', + 'jaz', + 'listen', + 'singer', + 'song', + 'musique', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'guys night', + 'dancers', + 'dancing', + 'ballet', + 'ballerina', + 'dabbing', + 'salsa', + 'celebration', + 'event', + 'celebrating', + 'festa', + 'parties', + 'events', + 'new years', + 'new year', + 'fiesta', + 'fete', + 'newyear', + 'party', + 'festive', + 'festival', + 'yolo', + 'festejar', + 'dad', + 'papa', + 'pere', + 'father', + 'vegas' + ]), + Emoji( + name: 'man dancing: light skin tone', + char: '\u{1F57A}\u{1F3FB}', + shortName: 'man_dancing_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personActivity, + keywords: [ + 'dance', + 'light skin tone', + 'man', + 'uc9', + 'instruments', + 'diversity', + 'men', + 'boys night', + 'dance', + 'celebrate', + 'disco', + 'daddy', + 'las vegas', + 'fun', + 'activity', + 'music', + 'instrument', + 'singing', + 'concert', + 'jaz', + 'listen', + 'singer', + 'song', + 'musique', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'guys night', + 'dancers', + 'dancing', + 'ballet', + 'ballerina', + 'dabbing', + 'salsa', + 'celebration', + 'event', + 'celebrating', + 'festa', + 'parties', + 'events', + 'new years', + 'new year', + 'fiesta', + 'fete', + 'newyear', + 'party', + 'festive', + 'festival', + 'yolo', + 'festejar', + 'dad', + 'papa', + 'pere', + 'father', + 'vegas' + ], + modifiable: true), + Emoji( + name: 'man dancing: medium-light skin tone', + char: '\u{1F57A}\u{1F3FC}', + shortName: 'man_dancing_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personActivity, + keywords: [ + 'dance', + 'man', + 'medium-light skin tone', + 'uc9', + 'instruments', + 'diversity', + 'men', + 'boys night', + 'dance', + 'celebrate', + 'disco', + 'daddy', + 'las vegas', + 'fun', + 'activity', + 'music', + 'instrument', + 'singing', + 'concert', + 'jaz', + 'listen', + 'singer', + 'song', + 'musique', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'guys night', + 'dancers', + 'dancing', + 'ballet', + 'ballerina', + 'dabbing', + 'salsa', + 'celebration', + 'event', + 'celebrating', + 'festa', + 'parties', + 'events', + 'new years', + 'new year', + 'fiesta', + 'fete', + 'newyear', + 'party', + 'festive', + 'festival', + 'yolo', + 'festejar', + 'dad', + 'papa', + 'pere', + 'father', + 'vegas' + ], + modifiable: true), + Emoji( + name: 'man dancing: medium skin tone', + char: '\u{1F57A}\u{1F3FD}', + shortName: 'man_dancing_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personActivity, + keywords: [ + 'dance', + 'man', + 'medium skin tone', + 'uc9', + 'instruments', + 'diversity', + 'men', + 'boys night', + 'dance', + 'celebrate', + 'disco', + 'daddy', + 'las vegas', + 'fun', + 'activity', + 'music', + 'instrument', + 'singing', + 'concert', + 'jaz', + 'listen', + 'singer', + 'song', + 'musique', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'guys night', + 'dancers', + 'dancing', + 'ballet', + 'ballerina', + 'dabbing', + 'salsa', + 'celebration', + 'event', + 'celebrating', + 'festa', + 'parties', + 'events', + 'new years', + 'new year', + 'fiesta', + 'fete', + 'newyear', + 'party', + 'festive', + 'festival', + 'yolo', + 'festejar', + 'dad', + 'papa', + 'pere', + 'father', + 'vegas' + ], + modifiable: true), + Emoji( + name: 'man dancing: dark skin tone', + char: '\u{1F57A}\u{1F3FF}', + shortName: 'man_dancing_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personActivity, + keywords: [ + 'dance', + 'dark skin tone', + 'man', + 'uc9', + 'instruments', + 'diversity', + 'men', + 'boys night', + 'dance', + 'celebrate', + 'disco', + 'daddy', + 'las vegas', + 'fun', + 'activity', + 'music', + 'instrument', + 'singing', + 'concert', + 'jaz', + 'listen', + 'singer', + 'song', + 'musique', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'guys night', + 'dancers', + 'dancing', + 'ballet', + 'ballerina', + 'dabbing', + 'salsa', + 'celebration', + 'event', + 'celebrating', + 'festa', + 'parties', + 'events', + 'new years', + 'new year', + 'fiesta', + 'fete', + 'newyear', + 'party', + 'festive', + 'festival', + 'yolo', + 'festejar', + 'dad', + 'papa', + 'pere', + 'father', + 'vegas' + ], + modifiable: true), + Emoji( + name: 'man dancing: medium-dark skin tone', + char: '\u{1F57A}\u{1F3FE}', + shortName: 'man_dancing_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personActivity, + keywords: [ + 'dance', + 'man', + 'medium-dark skin tone', + 'uc9', + 'instruments', + 'diversity', + 'men', + 'boys night', + 'dance', + 'celebrate', + 'disco', + 'daddy', + 'las vegas', + 'fun', + 'activity', + 'music', + 'instrument', + 'singing', + 'concert', + 'jaz', + 'listen', + 'singer', + 'song', + 'musique', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'guys night', + 'dancers', + 'dancing', + 'ballet', + 'ballerina', + 'dabbing', + 'salsa', + 'celebration', + 'event', + 'celebrating', + 'festa', + 'parties', + 'events', + 'new years', + 'new year', + 'fiesta', + 'fete', + 'newyear', + 'party', + 'festive', + 'festival', + 'yolo', + 'festejar', + 'dad', + 'papa', + 'pere', + 'father', + 'vegas' + ], + modifiable: true), + Emoji( + name: 'people with bunny ears', + char: '\u{1F46F}', + shortName: 'people_with_bunny_ears_partying', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personActivity, + keywords: [ + 'bunny ear', + 'dancer', + 'partying', + 'uc6', + 'instruments', + 'silly', + 'halloween', + 'men', + 'japan', + 'sexy', + 'girls night', + 'boys night', + 'dance', + 'easter', + 'porn', + 'las vegas', + 'fun', + 'crazy', + 'playboy', + 'activity', + 'disguise', + 'music', + 'instrument', + 'singing', + 'concert', + 'jaz', + 'listen', + 'singer', + 'song', + 'musique', + 'funny', + 'samhain', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'japanese', + 'ninja', + 'ladies night', + 'girls only', + 'girlfriend', + 'guys night', + 'dancers', + 'dancing', + 'ballet', + 'ballerina', + 'dabbing', + 'salsa', + 'vegas', + 'weird', + 'awkward', + 'insane', + 'wild', + 'play boy' + ]), + Emoji( + name: 'women with bunny ears', + char: '\u{1F46F}\u{200D}\u{2640}\u{FE0F}', + shortName: 'women_with_bunny_ears_partying', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personActivity, + keywords: [ + 'bunny ear', + 'dancer', + 'partying', + 'women', + 'uc6', + 'instruments', + 'silly', + 'women', + 'halloween', + 'japan', + 'girls night', + 'boys night', + 'dance', + 'easter', + 'las vegas', + 'fun', + 'crazy', + 'playboy', + 'activity', + 'disguise', + 'music', + 'instrument', + 'singing', + 'concert', + 'jaz', + 'listen', + 'singer', + 'song', + 'musique', + 'funny', + 'woman', + 'female', + 'samhain', + 'japanese', + 'ninja', + 'ladies night', + 'girls only', + 'girlfriend', + 'guys night', + 'dancers', + 'dancing', + 'ballet', + 'ballerina', + 'dabbing', + 'salsa', + 'vegas', + 'weird', + 'awkward', + 'insane', + 'wild', + 'play boy' + ]), + Emoji( + name: 'men with bunny ears', + char: '\u{1F46F}\u{200D}\u{2642}\u{FE0F}', + shortName: 'men_with_bunny_ears_partying', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personActivity, + keywords: [ + 'bunny ear', + 'dancer', + 'men', + 'partying', + 'uc6', + 'instruments', + 'silly', + 'halloween', + 'men', + 'japan', + 'girls night', + 'boys night', + 'dance', + 'queen', + 'easter', + 'las vegas', + 'fun', + 'crazy', + 'playboy', + 'activity', + 'disguise', + 'music', + 'instrument', + 'singing', + 'concert', + 'jaz', + 'listen', + 'singer', + 'song', + 'musique', + 'funny', + 'samhain', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'japanese', + 'ninja', + 'ladies night', + 'girls only', + 'girlfriend', + 'guys night', + 'dancers', + 'dancing', + 'ballet', + 'ballerina', + 'dabbing', + 'salsa', + 'king', + 'prince', + 'princess', + 'vegas', + 'weird', + 'awkward', + 'insane', + 'wild', + 'play boy' + ]), + Emoji( + name: 'person in suit levitating', + char: '\u{1F574}\u{FE0F}', + shortName: 'levitate', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personActivity, + keywords: [ + 'business', + 'man', + 'suit', + 'uc7', + 'halloween', + 'men', + 'job', + 'business', + 'sunglasses', + 'google', + 'detective', + 'fame', + 'gangster', + 'super hero', + 'vampire', + 'las vegas', + 'mystery', + 'disguise', + 'samhain', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'profession', + 'boss', + 'career', + 'shades', + 'lunettes de soleil', + 'sun glasses', + 'famous', + 'celebrity', + 'thug', + 'superhero', + 'superman', + 'batman', + 'dracula', + 'vegas' + ]), + Emoji( + name: 'person in suit levitating: light skin tone', + char: '\u{1F574}\u{1F3FB}', + shortName: 'levitate_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personActivity, + keywords: [ + 'business', + 'light skin tone', + 'man', + 'suit', + 'uc8', + 'halloween', + 'men', + 'job', + 'business', + 'sunglasses', + 'google', + 'detective', + 'fame', + 'gangster', + 'super hero', + 'vampire', + 'las vegas', + 'mystery', + 'disguise', + 'samhain', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'profession', + 'boss', + 'career', + 'shades', + 'lunettes de soleil', + 'sun glasses', + 'famous', + 'celebrity', + 'thug', + 'superhero', + 'superman', + 'batman', + 'dracula', + 'vegas' + ], + modifiable: true), + Emoji( + name: 'person in suit levitating: medium-light skin tone', + char: '\u{1F574}\u{1F3FC}', + shortName: 'levitate_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personActivity, + keywords: [ + 'business', + 'man', + 'medium-light skin tone', + 'suit', + 'uc8', + 'halloween', + 'men', + 'job', + 'business', + 'sunglasses', + 'google', + 'detective', + 'fame', + 'gangster', + 'super hero', + 'vampire', + 'las vegas', + 'mystery', + 'disguise', + 'samhain', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'profession', + 'boss', + 'career', + 'shades', + 'lunettes de soleil', + 'sun glasses', + 'famous', + 'celebrity', + 'thug', + 'superhero', + 'superman', + 'batman', + 'dracula', + 'vegas' + ], + modifiable: true), + Emoji( + name: 'person in suit levitating: medium skin tone', + char: '\u{1F574}\u{1F3FD}', + shortName: 'levitate_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personActivity, + keywords: [ + 'business', + 'man', + 'medium skin tone', + 'suit', + 'uc8', + 'halloween', + 'men', + 'job', + 'business', + 'sunglasses', + 'google', + 'detective', + 'fame', + 'gangster', + 'super hero', + 'vampire', + 'las vegas', + 'mystery', + 'disguise', + 'samhain', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'profession', + 'boss', + 'career', + 'shades', + 'lunettes de soleil', + 'sun glasses', + 'famous', + 'celebrity', + 'thug', + 'superhero', + 'superman', + 'batman', + 'dracula', + 'vegas' + ], + modifiable: true), + Emoji( + name: 'person in suit levitating: medium-dark skin tone', + char: '\u{1F574}\u{1F3FE}', + shortName: 'levitate_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personActivity, + keywords: [ + 'business', + 'man', + 'medium-dark skin tone', + 'suit', + 'uc8', + 'halloween', + 'men', + 'job', + 'business', + 'sunglasses', + 'google', + 'detective', + 'fame', + 'gangster', + 'super hero', + 'vampire', + 'las vegas', + 'mystery', + 'disguise', + 'samhain', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'profession', + 'boss', + 'career', + 'shades', + 'lunettes de soleil', + 'sun glasses', + 'famous', + 'celebrity', + 'thug', + 'superhero', + 'superman', + 'batman', + 'dracula', + 'vegas' + ], + modifiable: true), + Emoji( + name: 'person in suit levitating: dark skin tone', + char: '\u{1F574}\u{1F3FF}', + shortName: 'levitate_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personActivity, + keywords: [ + 'business', + 'dark skin tone', + 'man', + 'suit', + 'uc8', + 'halloween', + 'men', + 'job', + 'business', + 'sunglasses', + 'google', + 'detective', + 'fame', + 'gangster', + 'super hero', + 'vampire', + 'las vegas', + 'mystery', + 'disguise', + 'samhain', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'profession', + 'boss', + 'career', + 'shades', + 'lunettes de soleil', + 'sun glasses', + 'famous', + 'celebrity', + 'thug', + 'superhero', + 'superman', + 'batman', + 'dracula', + 'vegas' + ], + modifiable: true), + Emoji( + name: 'person in manual wheelchair', + char: '\u{1F9D1}\u{200D}\u{1F9BD}', + shortName: 'person_in_manual_wheelchair', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personActivity, + keywords: [ + 'uc12', + 'old people', + 'handicap', + 'accessibility', + 'grandparents', + 'elderly', + 'grandma', + 'grandpa', + 'grandmother', + 'grandfather', + 'mamie', + 'papy', + 'disabled', + 'disability' + ]), + Emoji( + name: 'person in manual wheelchair: light skin tone', + char: '\u{1F9D1}\u{1F3FB}\u{200D}\u{1F9BD}', + shortName: 'person_in_manual_wheelchair_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personActivity, + keywords: [ + 'uc12', + 'old people', + 'handicap', + 'accessibility', + 'grandparents', + 'elderly', + 'grandma', + 'grandpa', + 'grandmother', + 'grandfather', + 'mamie', + 'papy', + 'disabled', + 'disability' + ], + modifiable: true), + Emoji( + name: 'person in manual wheelchair: medium-light skin tone', + char: '\u{1F9D1}\u{1F3FC}\u{200D}\u{1F9BD}', + shortName: 'person_in_manual_wheelchair_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personActivity, + keywords: [ + 'uc12', + 'old people', + 'handicap', + 'accessibility', + 'grandparents', + 'elderly', + 'grandma', + 'grandpa', + 'grandmother', + 'grandfather', + 'mamie', + 'papy', + 'disabled', + 'disability' + ], + modifiable: true), + Emoji( + name: 'person in manual wheelchair: medium skin tone', + char: '\u{1F9D1}\u{1F3FD}\u{200D}\u{1F9BD}', + shortName: 'person_in_manual_wheelchair_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personActivity, + keywords: [ + 'uc12', + 'old people', + 'handicap', + 'accessibility', + 'grandparents', + 'elderly', + 'grandma', + 'grandpa', + 'grandmother', + 'grandfather', + 'mamie', + 'papy', + 'disabled', + 'disability' + ], + modifiable: true), + Emoji( + name: 'person in manual wheelchair: medium-dark skin tone', + char: '\u{1F9D1}\u{1F3FE}\u{200D}\u{1F9BD}', + shortName: 'person_in_manual_wheelchair_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personActivity, + keywords: [ + 'uc12', + 'old people', + 'handicap', + 'accessibility', + 'grandparents', + 'elderly', + 'grandma', + 'grandpa', + 'grandmother', + 'grandfather', + 'mamie', + 'papy', + 'disabled', + 'disability' + ], + modifiable: true), + Emoji( + name: 'person in manual wheelchair: dark skin tone', + char: '\u{1F9D1}\u{1F3FF}\u{200D}\u{1F9BD}', + shortName: 'person_in_manual_wheelchair_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personActivity, + keywords: [ + 'uc12', + 'old people', + 'handicap', + 'accessibility', + 'grandparents', + 'elderly', + 'grandma', + 'grandpa', + 'grandmother', + 'grandfather', + 'mamie', + 'papy', + 'disabled', + 'disability' + ], + modifiable: true), + Emoji( + name: 'woman in manual wheelchair', + char: '\u{1F469}\u{200D}\u{1F9BD}', + shortName: 'woman_in_manual_wheelchair', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personActivity, + keywords: [ + 'uc12', + 'old people', + 'diversity', + 'handicap', + 'accessibility', + 'grandparents', + 'elderly', + 'grandma', + 'grandpa', + 'grandmother', + 'grandfather', + 'mamie', + 'papy', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'disabled', + 'disability' + ]), + Emoji( + name: 'woman in manual wheelchair: light skin tone', + char: '\u{1F469}\u{1F3FB}\u{200D}\u{1F9BD}', + shortName: 'woman_in_manual_wheelchair_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personActivity, + keywords: [ + 'uc12', + 'old people', + 'diversity', + 'handicap', + 'accessibility', + 'grandparents', + 'elderly', + 'grandma', + 'grandpa', + 'grandmother', + 'grandfather', + 'mamie', + 'papy', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'disabled', + 'disability' + ], + modifiable: true), + Emoji( + name: 'woman in manual wheelchair: medium-light skin tone', + char: '\u{1F469}\u{1F3FC}\u{200D}\u{1F9BD}', + shortName: 'woman_in_manual_wheelchair_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personActivity, + keywords: [ + 'uc12', + 'old people', + 'diversity', + 'handicap', + 'accessibility', + 'grandparents', + 'elderly', + 'grandma', + 'grandpa', + 'grandmother', + 'grandfather', + 'mamie', + 'papy', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'disabled', + 'disability' + ], + modifiable: true), + Emoji( + name: 'woman in manual wheelchair: medium skin tone', + char: '\u{1F469}\u{1F3FD}\u{200D}\u{1F9BD}', + shortName: 'woman_in_manual_wheelchair_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personActivity, + keywords: [ + 'uc12', + 'old people', + 'diversity', + 'handicap', + 'accessibility', + 'grandparents', + 'elderly', + 'grandma', + 'grandpa', + 'grandmother', + 'grandfather', + 'mamie', + 'papy', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'disabled', + 'disability' + ], + modifiable: true), + Emoji( + name: 'woman in manual wheelchair: medium-dark skin tone', + char: '\u{1F469}\u{1F3FE}\u{200D}\u{1F9BD}', + shortName: 'woman_in_manual_wheelchair_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personActivity, + keywords: [ + 'uc12', + 'old people', + 'diversity', + 'handicap', + 'accessibility', + 'grandparents', + 'elderly', + 'grandma', + 'grandpa', + 'grandmother', + 'grandfather', + 'mamie', + 'papy', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'disabled', + 'disability' + ], + modifiable: true), + Emoji( + name: 'woman in manual wheelchair: dark skin tone', + char: '\u{1F469}\u{1F3FF}\u{200D}\u{1F9BD}', + shortName: 'woman_in_manual_wheelchair_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personActivity, + keywords: [ + 'uc12', + 'old people', + 'diversity', + 'handicap', + 'accessibility', + 'grandparents', + 'elderly', + 'grandma', + 'grandpa', + 'grandmother', + 'grandfather', + 'mamie', + 'papy', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'disabled', + 'disability' + ], + modifiable: true), + Emoji( + name: 'man in manual wheelchair', + char: '\u{1F468}\u{200D}\u{1F9BD}', + shortName: 'man_in_manual_wheelchair', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personActivity, + keywords: [ + 'uc12', + 'old people', + 'diversity', + 'handicap', + 'accessibility', + 'grandparents', + 'elderly', + 'grandma', + 'grandpa', + 'grandmother', + 'grandfather', + 'mamie', + 'papy', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'disabled', + 'disability' + ]), + Emoji( + name: 'man in manual wheelchair: light skin tone', + char: '\u{1F468}\u{1F3FB}\u{200D}\u{1F9BD}', + shortName: 'man_in_manual_wheelchair_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personActivity, + keywords: [ + 'uc12', + 'old people', + 'diversity', + 'handicap', + 'accessibility', + 'grandparents', + 'elderly', + 'grandma', + 'grandpa', + 'grandmother', + 'grandfather', + 'mamie', + 'papy', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'disabled', + 'disability' + ], + modifiable: true), + Emoji( + name: 'man in manual wheelchair: medium-light skin tone', + char: '\u{1F468}\u{1F3FC}\u{200D}\u{1F9BD}', + shortName: 'man_in_manual_wheelchair_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personActivity, + keywords: [ + 'uc12', + 'old people', + 'diversity', + 'handicap', + 'accessibility', + 'grandparents', + 'elderly', + 'grandma', + 'grandpa', + 'grandmother', + 'grandfather', + 'mamie', + 'papy', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'disabled', + 'disability' + ], + modifiable: true), + Emoji( + name: 'man in manual wheelchair: medium skin tone', + char: '\u{1F468}\u{1F3FD}\u{200D}\u{1F9BD}', + shortName: 'man_in_manual_wheelchair_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personActivity, + keywords: [ + 'uc12', + 'old people', + 'diversity', + 'handicap', + 'accessibility', + 'grandparents', + 'elderly', + 'grandma', + 'grandpa', + 'grandmother', + 'grandfather', + 'mamie', + 'papy', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'disabled', + 'disability' + ], + modifiable: true), + Emoji( + name: 'man in manual wheelchair: medium-dark skin tone', + char: '\u{1F468}\u{1F3FE}\u{200D}\u{1F9BD}', + shortName: 'man_in_manual_wheelchair_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personActivity, + keywords: [ + 'uc12', + 'old people', + 'diversity', + 'handicap', + 'accessibility', + 'grandparents', + 'elderly', + 'grandma', + 'grandpa', + 'grandmother', + 'grandfather', + 'mamie', + 'papy', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'disabled', + 'disability' + ], + modifiable: true), + Emoji( + name: 'man in manual wheelchair: dark skin tone', + char: '\u{1F468}\u{1F3FF}\u{200D}\u{1F9BD}', + shortName: 'man_in_manual_wheelchair_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personActivity, + keywords: [ + 'uc12', + 'old people', + 'diversity', + 'handicap', + 'accessibility', + 'grandparents', + 'elderly', + 'grandma', + 'grandpa', + 'grandmother', + 'grandfather', + 'mamie', + 'papy', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'disabled', + 'disability' + ], + modifiable: true), + Emoji( + name: 'person in motorized wheelchair', + char: '\u{1F9D1}\u{200D}\u{1F9BC}', + shortName: 'person_in_motorized_wheelchair', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personActivity, + keywords: [ + 'uc12', + 'old people', + 'handicap', + 'accessibility', + 'grandparents', + 'elderly', + 'grandma', + 'grandpa', + 'grandmother', + 'grandfather', + 'mamie', + 'papy', + 'disabled', + 'disability' + ]), + Emoji( + name: 'person in motorized wheelchair: light skin tone', + char: '\u{1F9D1}\u{1F3FB}\u{200D}\u{1F9BC}', + shortName: 'person_in_motorized_wheelchair_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personActivity, + keywords: [ + 'uc12', + 'old people', + 'handicap', + 'accessibility', + 'grandparents', + 'elderly', + 'grandma', + 'grandpa', + 'grandmother', + 'grandfather', + 'mamie', + 'papy', + 'disabled', + 'disability' + ], + modifiable: true), + Emoji( + name: 'person in motorized wheelchair: medium-light skin tone', + char: '\u{1F9D1}\u{1F3FC}\u{200D}\u{1F9BC}', + shortName: 'person_in_motorized_wheelchair_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personActivity, + keywords: [ + 'uc12', + 'old people', + 'handicap', + 'accessibility', + 'grandparents', + 'elderly', + 'grandma', + 'grandpa', + 'grandmother', + 'grandfather', + 'mamie', + 'papy', + 'disabled', + 'disability' + ], + modifiable: true), + Emoji( + name: 'person in motorized wheelchair: medium skin tone', + char: '\u{1F9D1}\u{1F3FD}\u{200D}\u{1F9BC}', + shortName: 'person_in_motorized_wheelchair_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personActivity, + keywords: [ + 'uc12', + 'old people', + 'handicap', + 'accessibility', + 'grandparents', + 'elderly', + 'grandma', + 'grandpa', + 'grandmother', + 'grandfather', + 'mamie', + 'papy', + 'disabled', + 'disability' + ], + modifiable: true), + Emoji( + name: 'person in motorized wheelchair: medium-dark skin tone', + char: '\u{1F9D1}\u{1F3FE}\u{200D}\u{1F9BC}', + shortName: 'person_in_motorized_wheelchair_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personActivity, + keywords: [ + 'uc12', + 'old people', + 'handicap', + 'accessibility', + 'grandparents', + 'elderly', + 'grandma', + 'grandpa', + 'grandmother', + 'grandfather', + 'mamie', + 'papy', + 'disabled', + 'disability' + ], + modifiable: true), + Emoji( + name: 'person in motorized wheelchair: dark skin tone', + char: '\u{1F9D1}\u{1F3FF}\u{200D}\u{1F9BC}', + shortName: 'person_in_motorized_wheelchair_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personActivity, + keywords: [ + 'uc12', + 'old people', + 'handicap', + 'accessibility', + 'grandparents', + 'elderly', + 'grandma', + 'grandpa', + 'grandmother', + 'grandfather', + 'mamie', + 'papy', + 'disabled', + 'disability' + ], + modifiable: true), + Emoji( + name: 'woman in motorized wheelchair', + char: '\u{1F469}\u{200D}\u{1F9BC}', + shortName: 'woman_in_motorized_wheelchair', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personActivity, + keywords: [ + 'uc12', + 'old people', + 'diversity', + 'handicap', + 'accessibility', + 'grandparents', + 'elderly', + 'grandma', + 'grandpa', + 'grandmother', + 'grandfather', + 'mamie', + 'papy', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'disabled', + 'disability' + ]), + Emoji( + name: 'woman in motorized wheelchair: light skin tone', + char: '\u{1F469}\u{1F3FB}\u{200D}\u{1F9BC}', + shortName: 'woman_in_motorized_wheelchair_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personActivity, + keywords: [ + 'uc12', + 'old people', + 'diversity', + 'handicap', + 'accessibility', + 'grandparents', + 'elderly', + 'grandma', + 'grandpa', + 'grandmother', + 'grandfather', + 'mamie', + 'papy', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'disabled', + 'disability' + ], + modifiable: true), + Emoji( + name: 'woman in motorized wheelchair: medium-light skin tone', + char: '\u{1F469}\u{1F3FC}\u{200D}\u{1F9BC}', + shortName: 'woman_in_motorized_wheelchair_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personActivity, + keywords: [ + 'uc12', + 'old people', + 'diversity', + 'handicap', + 'accessibility', + 'grandparents', + 'elderly', + 'grandma', + 'grandpa', + 'grandmother', + 'grandfather', + 'mamie', + 'papy', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'disabled', + 'disability' + ], + modifiable: true), + Emoji( + name: 'woman in motorized wheelchair: medium skin tone', + char: '\u{1F469}\u{1F3FD}\u{200D}\u{1F9BC}', + shortName: 'woman_in_motorized_wheelchair_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personActivity, + keywords: [ + 'uc12', + 'old people', + 'diversity', + 'handicap', + 'accessibility', + 'grandparents', + 'elderly', + 'grandma', + 'grandpa', + 'grandmother', + 'grandfather', + 'mamie', + 'papy', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'disabled', + 'disability' + ], + modifiable: true), + Emoji( + name: 'woman in motorized wheelchair: medium-dark skin tone', + char: '\u{1F469}\u{1F3FE}\u{200D}\u{1F9BC}', + shortName: 'woman_in_motorized_wheelchair_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personActivity, + keywords: [ + 'uc12', + 'old people', + 'diversity', + 'handicap', + 'accessibility', + 'grandparents', + 'elderly', + 'grandma', + 'grandpa', + 'grandmother', + 'grandfather', + 'mamie', + 'papy', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'disabled', + 'disability' + ], + modifiable: true), + Emoji( + name: 'woman in motorized wheelchair: dark skin tone', + char: '\u{1F469}\u{1F3FF}\u{200D}\u{1F9BC}', + shortName: 'woman_in_motorized_wheelchair_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personActivity, + keywords: [ + 'uc12', + 'old people', + 'diversity', + 'handicap', + 'accessibility', + 'grandparents', + 'elderly', + 'grandma', + 'grandpa', + 'grandmother', + 'grandfather', + 'mamie', + 'papy', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'disabled', + 'disability' + ], + modifiable: true), + Emoji( + name: 'man in motorized wheelchair', + char: '\u{1F468}\u{200D}\u{1F9BC}', + shortName: 'man_in_motorized_wheelchair', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personActivity, + keywords: [ + 'uc12', + 'old people', + 'diversity', + 'handicap', + 'accessibility', + 'grandparents', + 'elderly', + 'grandma', + 'grandpa', + 'grandmother', + 'grandfather', + 'mamie', + 'papy', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'disabled', + 'disability' + ]), + Emoji( + name: 'man in motorized wheelchair: light skin tone', + char: '\u{1F468}\u{1F3FB}\u{200D}\u{1F9BC}', + shortName: 'man_in_motorized_wheelchair_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personActivity, + keywords: [ + 'uc12', + 'old people', + 'diversity', + 'handicap', + 'accessibility', + 'grandparents', + 'elderly', + 'grandma', + 'grandpa', + 'grandmother', + 'grandfather', + 'mamie', + 'papy', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'disabled', + 'disability' + ], + modifiable: true), + Emoji( + name: 'man in motorized wheelchair: medium-light skin tone', + char: '\u{1F468}\u{1F3FC}\u{200D}\u{1F9BC}', + shortName: 'man_in_motorized_wheelchair_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personActivity, + keywords: [ + 'uc12', + 'old people', + 'diversity', + 'handicap', + 'accessibility', + 'grandparents', + 'elderly', + 'grandma', + 'grandpa', + 'grandmother', + 'grandfather', + 'mamie', + 'papy', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'disabled', + 'disability' + ], + modifiable: true), + Emoji( + name: 'man in motorized wheelchair: medium skin tone', + char: '\u{1F468}\u{1F3FD}\u{200D}\u{1F9BC}', + shortName: 'man_in_motorized_wheelchair_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personActivity, + keywords: [ + 'uc12', + 'old people', + 'diversity', + 'handicap', + 'accessibility', + 'grandparents', + 'elderly', + 'grandma', + 'grandpa', + 'grandmother', + 'grandfather', + 'mamie', + 'papy', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'disabled', + 'disability' + ], + modifiable: true), + Emoji( + name: 'man in motorized wheelchair: medium-dark skin tone', + char: '\u{1F468}\u{1F3FE}\u{200D}\u{1F9BC}', + shortName: 'man_in_motorized_wheelchair_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personActivity, + keywords: [ + 'uc12', + 'old people', + 'diversity', + 'handicap', + 'accessibility', + 'grandparents', + 'elderly', + 'grandma', + 'grandpa', + 'grandmother', + 'grandfather', + 'mamie', + 'papy', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'disabled', + 'disability' + ], + modifiable: true), + Emoji( + name: 'man in motorized wheelchair: dark skin tone', + char: '\u{1F468}\u{1F3FF}\u{200D}\u{1F9BC}', + shortName: 'man_in_motorized_wheelchair_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personActivity, + keywords: [ + 'uc12', + 'old people', + 'diversity', + 'handicap', + 'accessibility', + 'grandparents', + 'elderly', + 'grandma', + 'grandpa', + 'grandmother', + 'grandfather', + 'mamie', + 'papy', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'disabled', + 'disability' + ], + modifiable: true), + Emoji( + name: 'person walking', + char: '\u{1F6B6}', + shortName: 'person_walking', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personActivity, + keywords: [ + 'hike', + 'walk', + 'walking', + 'uc6', + 'sport', + 'diversity', + 'men', + 'activity', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male' + ]), + Emoji( + name: 'person walking: light skin tone', + char: '\u{1F6B6}\u{1F3FB}', + shortName: 'person_walking_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personActivity, + keywords: [ + 'hike', + 'light skin tone', + 'walk', + 'walking', + 'uc8', + 'sport', + 'diversity', + 'men', + 'activity', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male' + ], + modifiable: true), + Emoji( + name: 'person walking: medium-light skin tone', + char: '\u{1F6B6}\u{1F3FC}', + shortName: 'person_walking_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personActivity, + keywords: [ + 'hike', + 'medium-light skin tone', + 'walk', + 'walking', + 'uc8', + 'sport', + 'diversity', + 'men', + 'activity', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male' + ], + modifiable: true), + Emoji( + name: 'person walking: medium skin tone', + char: '\u{1F6B6}\u{1F3FD}', + shortName: 'person_walking_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personActivity, + keywords: [ + 'hike', + 'medium skin tone', + 'walk', + 'walking', + 'uc8', + 'sport', + 'diversity', + 'men', + 'activity', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male' + ], + modifiable: true), + Emoji( + name: 'person walking: medium-dark skin tone', + char: '\u{1F6B6}\u{1F3FE}', + shortName: 'person_walking_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personActivity, + keywords: [ + 'hike', + 'medium-dark skin tone', + 'walk', + 'walking', + 'uc8', + 'sport', + 'diversity', + 'men', + 'activity', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male' + ], + modifiable: true), + Emoji( + name: 'person walking: dark skin tone', + char: '\u{1F6B6}\u{1F3FF}', + shortName: 'person_walking_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personActivity, + keywords: [ + 'dark skin tone', + 'hike', + 'walk', + 'walking', + 'uc8', + 'sport', + 'diversity', + 'men', + 'activity', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male' + ], + modifiable: true), + Emoji( + name: 'woman walking', + char: '\u{1F6B6}\u{200D}\u{2640}\u{FE0F}', + shortName: 'woman_walking', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personActivity, + keywords: [ + 'hike', + 'walk', + 'woman', + 'uc6', + 'sport', + 'diversity', + 'women', + 'activity', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'woman', + 'female' + ]), + Emoji( + name: 'woman walking: light skin tone', + char: '\u{1F6B6}\u{1F3FB}\u{200D}\u{2640}\u{FE0F}', + shortName: 'woman_walking_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personActivity, + keywords: [ + 'hike', + 'light skin tone', + 'walk', + 'woman', + 'uc8', + 'sport', + 'diversity', + 'women', + 'activity', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'woman', + 'female' + ], + modifiable: true), + Emoji( + name: 'woman walking: medium-light skin tone', + char: '\u{1F6B6}\u{1F3FC}\u{200D}\u{2640}\u{FE0F}', + shortName: 'woman_walking_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personActivity, + keywords: [ + 'hike', + 'medium-light skin tone', + 'walk', + 'woman', + 'uc8', + 'sport', + 'diversity', + 'women', + 'activity', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'woman', + 'female' + ], + modifiable: true), + Emoji( + name: 'woman walking: medium skin tone', + char: '\u{1F6B6}\u{1F3FD}\u{200D}\u{2640}\u{FE0F}', + shortName: 'woman_walking_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personActivity, + keywords: [ + 'hike', + 'medium skin tone', + 'walk', + 'woman', + 'uc8', + 'sport', + 'diversity', + 'women', + 'activity', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'woman', + 'female' + ], + modifiable: true), + Emoji( + name: 'woman walking: medium-dark skin tone', + char: '\u{1F6B6}\u{1F3FE}\u{200D}\u{2640}\u{FE0F}', + shortName: 'woman_walking_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personActivity, + keywords: [ + 'hike', + 'medium-dark skin tone', + 'walk', + 'woman', + 'uc8', + 'sport', + 'diversity', + 'women', + 'activity', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'woman', + 'female' + ], + modifiable: true), + Emoji( + name: 'woman walking: dark skin tone', + char: '\u{1F6B6}\u{1F3FF}\u{200D}\u{2640}\u{FE0F}', + shortName: 'woman_walking_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personActivity, + keywords: [ + 'dark skin tone', + 'hike', + 'walk', + 'woman', + 'uc8', + 'sport', + 'diversity', + 'women', + 'activity', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'woman', + 'female' + ], + modifiable: true), + Emoji( + name: 'man walking', + char: '\u{1F6B6}\u{200D}\u{2642}\u{FE0F}', + shortName: 'man_walking', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personActivity, + keywords: [ + 'hike', + 'man', + 'walk', + 'uc6', + 'sport', + 'diversity', + 'men', + 'activity', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male' + ]), + Emoji( + name: 'man walking: light skin tone', + char: '\u{1F6B6}\u{1F3FB}\u{200D}\u{2642}\u{FE0F}', + shortName: 'man_walking_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personActivity, + keywords: [ + 'hike', + 'light skin tone', + 'man', + 'walk', + 'uc8', + 'sport', + 'diversity', + 'men', + 'activity', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male' + ], + modifiable: true), + Emoji( + name: 'man walking: medium-light skin tone', + char: '\u{1F6B6}\u{1F3FC}\u{200D}\u{2642}\u{FE0F}', + shortName: 'man_walking_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personActivity, + keywords: [ + 'hike', + 'man', + 'medium-light skin tone', + 'walk', + 'uc8', + 'sport', + 'diversity', + 'men', + 'activity', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male' + ], + modifiable: true), + Emoji( + name: 'man walking: medium skin tone', + char: '\u{1F6B6}\u{1F3FD}\u{200D}\u{2642}\u{FE0F}', + shortName: 'man_walking_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personActivity, + keywords: [ + 'hike', + 'man', + 'medium skin tone', + 'walk', + 'uc8', + 'sport', + 'diversity', + 'men', + 'activity', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male' + ], + modifiable: true), + Emoji( + name: 'man walking: medium-dark skin tone', + char: '\u{1F6B6}\u{1F3FE}\u{200D}\u{2642}\u{FE0F}', + shortName: 'man_walking_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personActivity, + keywords: [ + 'hike', + 'man', + 'medium-dark skin tone', + 'walk', + 'uc8', + 'sport', + 'diversity', + 'men', + 'activity', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male' + ], + modifiable: true), + Emoji( + name: 'man walking: dark skin tone', + char: '\u{1F6B6}\u{1F3FF}\u{200D}\u{2642}\u{FE0F}', + shortName: 'man_walking_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personActivity, + keywords: [ + 'dark skin tone', + 'hike', + 'man', + 'walk', + 'uc8', + 'sport', + 'diversity', + 'men', + 'activity', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male' + ], + modifiable: true), + Emoji( + name: 'person with white cane: light skin tone', + char: '\u{1F9D1}\u{1F3FB}\u{200D}\u{1F9AF}', + shortName: 'person_with_probing_cane_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personActivity, + keywords: [ + 'uc12', + 'accessibility', + 'cane', + 'handicap', + 'blind', + 'probe', + 'disabled', + 'disability', + 'white cane' + ], + modifiable: true), + Emoji( + name: 'person with white cane', + char: '\u{1F9D1}\u{200D}\u{1F9AF}', + shortName: 'person_with_probing_cane', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personActivity, + keywords: [ + 'uc12', + 'cane', + 'handicap', + 'blind', + 'probe', + 'disabled', + 'disability', + 'white cane' + ]), + Emoji( + name: 'person with white cane: medium-light skin tone', + char: '\u{1F9D1}\u{1F3FC}\u{200D}\u{1F9AF}', + shortName: 'person_with_probing_cane_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personActivity, + keywords: [ + 'uc12', + 'cane', + 'handicap', + 'blind', + 'probe', + 'disabled', + 'disability', + 'white cane' + ], + modifiable: true), + Emoji( + name: 'person with white cane: medium skin tone', + char: '\u{1F9D1}\u{1F3FD}\u{200D}\u{1F9AF}', + shortName: 'person_with_probing_cane_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personActivity, + keywords: [ + 'uc12', + 'cane', + 'handicap', + 'blind', + 'probe', + 'disabled', + 'disability', + 'white cane' + ], + modifiable: true), + Emoji( + name: 'person with white cane: medium-dark skin tone', + char: '\u{1F9D1}\u{1F3FE}\u{200D}\u{1F9AF}', + shortName: 'person_with_probing_cane_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personActivity, + keywords: [ + 'uc12', + 'cane', + 'handicap', + 'blind', + 'probe', + 'disabled', + 'disability', + 'white cane' + ], + modifiable: true), + Emoji( + name: 'person with white cane: dark skin tone', + char: '\u{1F9D1}\u{1F3FF}\u{200D}\u{1F9AF}', + shortName: 'person_with_probing_cane_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personActivity, + keywords: [ + 'uc12', + 'cane', + 'handicap', + 'blind', + 'probe', + 'disabled', + 'disability', + 'white cane' + ], + modifiable: true), + Emoji( + name: 'woman with white cane', + char: '\u{1F469}\u{200D}\u{1F9AF}', + shortName: 'woman_with_probing_cane', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personActivity, + keywords: [ + 'uc12', + 'diversity', + 'handicap', + 'navigate', + 'blind', + 'probe', + 'accessibility', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'disabled', + 'disability', + 'white cane' + ]), + Emoji( + name: 'woman with white cane: light skin tone', + char: '\u{1F469}\u{1F3FB}\u{200D}\u{1F9AF}', + shortName: 'woman_with_probing_cane_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personActivity, + keywords: [ + 'uc12', + 'cane', + 'diversity', + 'handicap', + 'navigate', + 'blind', + 'probe', + 'accessibility', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'disabled', + 'disability', + 'white cane' + ], + modifiable: true), + Emoji( + name: 'woman with white cane: medium-light skin tone', + char: '\u{1F469}\u{1F3FC}\u{200D}\u{1F9AF}', + shortName: 'woman_with_probing_cane_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personActivity, + keywords: [ + 'uc12', + 'diversity', + 'handicap', + 'navigate', + 'blind', + 'probe', + 'accessibility', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'disabled', + 'disability', + 'white cane' + ], + modifiable: true), + Emoji( + name: 'woman with white cane: medium skin tone', + char: '\u{1F469}\u{1F3FD}\u{200D}\u{1F9AF}', + shortName: 'woman_with_probing_cane_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personActivity, + keywords: [ + 'uc12', + 'diversity', + 'handicap', + 'navigate', + 'blind', + 'probe', + 'accessibility', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'disabled', + 'disability', + 'white cane' + ], + modifiable: true), + Emoji( + name: 'woman with white cane: medium-dark skin tone', + char: '\u{1F469}\u{1F3FE}\u{200D}\u{1F9AF}', + shortName: 'woman_with_probing_cane_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personActivity, + keywords: [ + 'uc12', + 'diversity', + 'handicap', + 'navigate', + 'blind', + 'probe', + 'accessibility', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'disabled', + 'disability', + 'white cane' + ], + modifiable: true), + Emoji( + name: 'woman with white cane: dark skin tone', + char: '\u{1F469}\u{1F3FF}\u{200D}\u{1F9AF}', + shortName: 'woman_with_probing_cane_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personActivity, + keywords: [ + 'uc12', + 'diversity', + 'handicap', + 'navigate', + 'blind', + 'probe', + 'accessibility', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'disabled', + 'disability', + 'white cane' + ], + modifiable: true), + Emoji( + name: 'man with white cane', + char: '\u{1F468}\u{200D}\u{1F9AF}', + shortName: 'man_with_probing_cane', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personActivity, + keywords: [ + 'uc12', + 'diversity', + 'cane', + 'handicap', + 'navigate', + 'blind', + 'probe', + 'accessibility', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'disabled', + 'disability', + 'white cane' + ]), + Emoji( + name: 'man with white cane: light skin tone', + char: '\u{1F468}\u{1F3FB}\u{200D}\u{1F9AF}', + shortName: 'man_with_probing_cane_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personActivity, + keywords: [ + 'uc12', + 'diversity', + 'cane', + 'handicap', + 'navigate', + 'blind', + 'probe', + 'accessibility', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'disabled', + 'disability', + 'white cane' + ], + modifiable: true), + Emoji( + name: 'man with white cane: medium skin tone', + char: '\u{1F468}\u{1F3FD}\u{200D}\u{1F9AF}', + shortName: 'man_with_probing_cane_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personActivity, + keywords: [ + 'uc12', + 'diversity', + 'cane', + 'handicap', + 'navigate', + 'blind', + 'probe', + 'accessibility', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'disabled', + 'disability', + 'white cane' + ], + modifiable: true), + Emoji( + name: 'man with white cane: medium-light skin tone', + char: '\u{1F468}\u{1F3FC}\u{200D}\u{1F9AF}', + shortName: 'man_with_probing_cane_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personActivity, + keywords: [ + 'uc12', + 'diversity', + 'cane', + 'handicap', + 'navigate', + 'blind', + 'probe', + 'accessibility', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'disabled', + 'disability', + 'white cane' + ], + modifiable: true), + Emoji( + name: 'man with white cane: medium-dark skin tone', + char: '\u{1F468}\u{1F3FE}\u{200D}\u{1F9AF}', + shortName: 'man_with_probing_cane_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personActivity, + keywords: [ + 'uc12', + 'diversity', + 'cane', + 'handicap', + 'navigate', + 'blind', + 'probe', + 'accessibility', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'disabled', + 'disability', + 'white cane' + ], + modifiable: true), + Emoji( + name: 'man with white cane: dark skin tone', + char: '\u{1F468}\u{1F3FF}\u{200D}\u{1F9AF}', + shortName: 'man_with_probing_cane_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personActivity, + keywords: [ + 'uc12', + 'diversity', + 'cane', + 'handicap', + 'navigate', + 'blind', + 'probe', + 'accessibility', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'disabled', + 'disability', + 'white cane' + ], + modifiable: true), + Emoji( + name: 'person kneeling', + char: '\u{1F9CE}', + shortName: 'person_kneeling', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personActivity, + keywords: [ + 'uc12', + 'diversity', + 'sit', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'sitting', + 'kneel', + 'kneeling' + ]), + Emoji( + name: 'person kneeling: light skin tone', + char: '\u{1F9CE}\u{1F3FB}', + shortName: 'person_kneeling_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personActivity, + keywords: [ + 'uc12', + 'diversity', + 'sit', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'sitting', + 'kneel', + 'kneeling' + ], + modifiable: true), + Emoji( + name: 'person kneeling: medium-light skin tone', + char: '\u{1F9CE}\u{1F3FC}', + shortName: 'person_kneeling_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personActivity, + keywords: [ + 'uc12', + 'diversity', + 'sit', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'sitting', + 'kneel', + 'kneeling' + ], + modifiable: true), + Emoji( + name: 'person kneeling: medium skin tone', + char: '\u{1F9CE}\u{1F3FD}', + shortName: 'person_kneeling_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personActivity, + keywords: [ + 'uc12', + 'diversity', + 'sit', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'sitting', + 'kneel', + 'kneeling' + ], + modifiable: true), + Emoji( + name: 'person kneeling: medium-dark skin tone', + char: '\u{1F9CE}\u{1F3FE}', + shortName: 'person_kneeling_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personActivity, + keywords: [ + 'uc12', + 'diversity', + 'sit', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'sitting', + 'kneel', + 'kneeling' + ], + modifiable: true), + Emoji( + name: 'person kneeling: dark skin tone', + char: '\u{1F9CE}\u{1F3FF}', + shortName: 'person_kneeling_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personActivity, + keywords: [ + 'uc12', + 'diversity', + 'sit', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'sitting', + 'kneel', + 'kneeling' + ], + modifiable: true), + Emoji( + name: 'woman kneeling', + char: '\u{1F9CE}\u{200D}\u{2640}\u{FE0F}', + shortName: 'woman_kneeling', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personActivity, + keywords: [ + 'uc12', + 'diversity', + 'sit', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'sitting', + 'kneel', + 'kneeling' + ]), + Emoji( + name: 'woman kneeling: light skin tone', + char: '\u{1F9CE}\u{1F3FB}\u{200D}\u{2640}\u{FE0F}', + shortName: 'woman_kneeling_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personActivity, + keywords: [ + 'uc12', + 'diversity', + 'sit', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'sitting', + 'kneel', + 'kneeling' + ], + modifiable: true), + Emoji( + name: 'woman kneeling: medium-light skin tone', + char: '\u{1F9CE}\u{1F3FC}\u{200D}\u{2640}\u{FE0F}', + shortName: 'woman_kneeling_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personActivity, + keywords: [ + 'uc12', + 'diversity', + 'sit', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'sitting', + 'kneel', + 'kneeling' + ], + modifiable: true), + Emoji( + name: 'woman kneeling: medium skin tone', + char: '\u{1F9CE}\u{1F3FD}\u{200D}\u{2640}\u{FE0F}', + shortName: 'woman_kneeling_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personActivity, + keywords: [ + 'uc12', + 'diversity', + 'sit', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'sitting', + 'kneel', + 'kneeling' + ], + modifiable: true), + Emoji( + name: 'woman kneeling: medium-dark skin tone', + char: '\u{1F9CE}\u{1F3FE}\u{200D}\u{2640}\u{FE0F}', + shortName: 'woman_kneeling_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personActivity, + keywords: [ + 'uc12', + 'diversity', + 'sit', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'sitting', + 'kneel', + 'kneeling' + ], + modifiable: true), + Emoji( + name: 'woman kneeling: dark skin tone', + char: '\u{1F9CE}\u{1F3FF}\u{200D}\u{2640}\u{FE0F}', + shortName: 'woman_kneeling_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personActivity, + keywords: [ + 'uc12', + 'diversity', + 'sit', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'sitting', + 'kneel', + 'kneeling' + ], + modifiable: true), + Emoji( + name: 'man kneeling', + char: '\u{1F9CE}\u{200D}\u{2642}\u{FE0F}', + shortName: 'man_kneeling', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personActivity, + keywords: [ + 'uc12', + 'diversity', + 'sit', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'sitting', + 'kneel', + 'kneeling' + ]), + Emoji( + name: 'man kneeling: light skin tone', + char: '\u{1F9CE}\u{1F3FB}\u{200D}\u{2642}\u{FE0F}', + shortName: 'man_kneeling_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personActivity, + keywords: [ + 'uc12', + 'diversity', + 'sit', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'sitting', + 'kneel', + 'kneeling' + ], + modifiable: true), + Emoji( + name: 'man kneeling: medium-light skin tone', + char: '\u{1F9CE}\u{1F3FC}\u{200D}\u{2642}\u{FE0F}', + shortName: 'man_kneeling_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personActivity, + keywords: [ + 'uc12', + 'diversity', + 'sit', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'sitting', + 'kneel', + 'kneeling' + ], + modifiable: true), + Emoji( + name: 'man kneeling: medium skin tone', + char: '\u{1F9CE}\u{1F3FD}\u{200D}\u{2642}\u{FE0F}', + shortName: 'man_kneeling_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personActivity, + keywords: [ + 'uc12', + 'diversity', + 'sit', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'sitting', + 'kneel', + 'kneeling' + ], + modifiable: true), + Emoji( + name: 'man kneeling: medium-dark skin tone', + char: '\u{1F9CE}\u{1F3FE}\u{200D}\u{2642}\u{FE0F}', + shortName: 'man_kneeling_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personActivity, + keywords: [ + 'uc12', + 'diversity', + 'sit', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'sitting', + 'kneel', + 'kneeling' + ], + modifiable: true), + Emoji( + name: 'man kneeling: dark skin tone', + char: '\u{1F9CE}\u{1F3FF}\u{200D}\u{2642}\u{FE0F}', + shortName: 'man_kneeling_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personActivity, + keywords: [ + 'uc12', + 'diversity', + 'sit', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'sitting', + 'kneel', + 'kneeling' + ], + modifiable: true), + Emoji( + name: 'person running', + char: '\u{1F3C3}', + shortName: 'person_running', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personActivity, + keywords: [ + 'marathon', + 'running', + 'uc6', + 'sport', + 'diversity', + 'men', + 'boys night', + 'run', + 'activity', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'guys night', + 'running', + 'jog', + 'runner' + ]), + Emoji( + name: 'person running: light skin tone', + char: '\u{1F3C3}\u{1F3FB}', + shortName: 'person_running_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personActivity, + keywords: [ + 'light skin tone', + 'marathon', + 'running', + 'uc8', + 'sport', + 'diversity', + 'men', + 'boys night', + 'run', + 'activity', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'guys night', + 'running', + 'jog', + 'runner' + ], + modifiable: true), + Emoji( + name: 'person running: medium-light skin tone', + char: '\u{1F3C3}\u{1F3FC}', + shortName: 'person_running_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personActivity, + keywords: [ + 'marathon', + 'medium-light skin tone', + 'running', + 'uc8', + 'sport', + 'diversity', + 'men', + 'boys night', + 'run', + 'activity', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'guys night', + 'running', + 'jog', + 'runner' + ], + modifiable: true), + Emoji( + name: 'person running: medium skin tone', + char: '\u{1F3C3}\u{1F3FD}', + shortName: 'person_running_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personActivity, + keywords: [ + 'marathon', + 'medium skin tone', + 'running', + 'uc8', + 'sport', + 'diversity', + 'men', + 'boys night', + 'run', + 'activity', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'guys night', + 'running', + 'jog', + 'runner' + ], + modifiable: true), + Emoji( + name: 'person running: medium-dark skin tone', + char: '\u{1F3C3}\u{1F3FE}', + shortName: 'person_running_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personActivity, + keywords: [ + 'marathon', + 'medium-dark skin tone', + 'running', + 'uc8', + 'sport', + 'diversity', + 'men', + 'boys night', + 'run', + 'activity', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'guys night', + 'running', + 'jog', + 'runner' + ], + modifiable: true), + Emoji( + name: 'person running: dark skin tone', + char: '\u{1F3C3}\u{1F3FF}', + shortName: 'person_running_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personActivity, + keywords: [ + 'dark skin tone', + 'marathon', + 'running', + 'uc8', + 'sport', + 'diversity', + 'men', + 'boys night', + 'run', + 'activity', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'guys night', + 'running', + 'jog', + 'runner' + ], + modifiable: true), + Emoji( + name: 'woman running', + char: '\u{1F3C3}\u{200D}\u{2640}\u{FE0F}', + shortName: 'woman_running', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personActivity, + keywords: [ + 'marathon', + 'racing', + 'running', + 'woman', + 'uc6', + 'sport', + 'diversity', + 'women', + 'run', + 'activity', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'woman', + 'female', + 'running', + 'jog', + 'runner' + ]), + Emoji( + name: 'woman running: light skin tone', + char: '\u{1F3C3}\u{1F3FB}\u{200D}\u{2640}\u{FE0F}', + shortName: 'woman_running_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personActivity, + keywords: [ + 'light skin tone', + 'marathon', + 'racing', + 'running', + 'woman', + 'uc8', + 'sport', + 'diversity', + 'women', + 'run', + 'activity', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'woman', + 'female', + 'running', + 'jog', + 'runner' + ], + modifiable: true), + Emoji( + name: 'woman running: medium-light skin tone', + char: '\u{1F3C3}\u{1F3FC}\u{200D}\u{2640}\u{FE0F}', + shortName: 'woman_running_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personActivity, + keywords: [ + 'marathon', + 'medium-light skin tone', + 'racing', + 'running', + 'woman', + 'uc8', + 'sport', + 'diversity', + 'women', + 'run', + 'activity', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'woman', + 'female', + 'running', + 'jog', + 'runner' + ], + modifiable: true), + Emoji( + name: 'woman running: medium skin tone', + char: '\u{1F3C3}\u{1F3FD}\u{200D}\u{2640}\u{FE0F}', + shortName: 'woman_running_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personActivity, + keywords: [ + 'marathon', + 'medium skin tone', + 'racing', + 'running', + 'woman', + 'uc8', + 'sport', + 'diversity', + 'women', + 'run', + 'activity', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'woman', + 'female', + 'running', + 'jog', + 'runner' + ], + modifiable: true), + Emoji( + name: 'woman running: medium-dark skin tone', + char: '\u{1F3C3}\u{1F3FE}\u{200D}\u{2640}\u{FE0F}', + shortName: 'woman_running_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personActivity, + keywords: [ + 'marathon', + 'medium-dark skin tone', + 'racing', + 'running', + 'woman', + 'uc8', + 'sport', + 'diversity', + 'women', + 'run', + 'activity', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'woman', + 'female', + 'running', + 'jog', + 'runner' + ], + modifiable: true), + Emoji( + name: 'woman running: dark skin tone', + char: '\u{1F3C3}\u{1F3FF}\u{200D}\u{2640}\u{FE0F}', + shortName: 'woman_running_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personActivity, + keywords: [ + 'dark skin tone', + 'marathon', + 'racing', + 'running', + 'woman', + 'uc8', + 'sport', + 'diversity', + 'women', + 'run', + 'activity', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'woman', + 'female', + 'running', + 'jog', + 'runner' + ], + modifiable: true), + Emoji( + name: 'man running', + char: '\u{1F3C3}\u{200D}\u{2642}\u{FE0F}', + shortName: 'man_running', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personActivity, + keywords: [ + 'man', + 'marathon', + 'racing', + 'running', + 'uc6', + 'sport', + 'men', + 'boys night', + 'run', + 'activity', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'guys night', + 'running', + 'jog', + 'runner' + ]), + Emoji( + name: 'man running: light skin tone', + char: '\u{1F3C3}\u{1F3FB}\u{200D}\u{2642}\u{FE0F}', + shortName: 'man_running_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personActivity, + keywords: [ + 'light skin tone', + 'man', + 'marathon', + 'racing', + 'running', + 'uc8', + 'sport', + 'men', + 'boys night', + 'run', + 'activity', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'guys night', + 'running', + 'jog', + 'runner' + ], + modifiable: true), + Emoji( + name: 'man running: medium-light skin tone', + char: '\u{1F3C3}\u{1F3FC}\u{200D}\u{2642}\u{FE0F}', + shortName: 'man_running_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personActivity, + keywords: [ + 'man', + 'marathon', + 'medium-light skin tone', + 'racing', + 'running', + 'uc8', + 'sport', + 'men', + 'boys night', + 'run', + 'activity', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'guys night', + 'running', + 'jog', + 'runner' + ], + modifiable: true), + Emoji( + name: 'man running: medium skin tone', + char: '\u{1F3C3}\u{1F3FD}\u{200D}\u{2642}\u{FE0F}', + shortName: 'man_running_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personActivity, + keywords: [ + 'man', + 'marathon', + 'medium skin tone', + 'racing', + 'running', + 'uc8', + 'sport', + 'men', + 'boys night', + 'run', + 'activity', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'guys night', + 'running', + 'jog', + 'runner' + ], + modifiable: true), + Emoji( + name: 'man running: medium-dark skin tone', + char: '\u{1F3C3}\u{1F3FE}\u{200D}\u{2642}\u{FE0F}', + shortName: 'man_running_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personActivity, + keywords: [ + 'man', + 'marathon', + 'medium-dark skin tone', + 'racing', + 'running', + 'uc8', + 'sport', + 'men', + 'boys night', + 'run', + 'activity', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'guys night', + 'running', + 'jog', + 'runner' + ], + modifiable: true), + Emoji( + name: 'man running: dark skin tone', + char: '\u{1F3C3}\u{1F3FF}\u{200D}\u{2642}\u{FE0F}', + shortName: 'man_running_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personActivity, + keywords: [ + 'dark skin tone', + 'man', + 'marathon', + 'racing', + 'running', + 'uc8', + 'sport', + 'men', + 'boys night', + 'run', + 'activity', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'guys night', + 'running', + 'jog', + 'runner' + ], + modifiable: true), + Emoji( + name: 'person standing', + char: '\u{1F9CD}', + shortName: 'person_standing', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personActivity, + keywords: [ + 'uc12', + 'diversity', + 'men', + 'human', + 'daddy', + 'parent', + 'wife', + 'husband', + 'mom', + 'stand', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'gender', + 'people', + 'dad', + 'papa', + 'pere', + 'father', + 'parents', + 'adult', + 'maman', + 'mommy', + 'mama', + 'mother' + ]), + Emoji( + name: 'person standing: light skin tone', + char: '\u{1F9CD}\u{1F3FB}', + shortName: 'person_standing_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personActivity, + keywords: [ + 'uc12', + 'diversity', + 'men', + 'human', + 'daddy', + 'parent', + 'wife', + 'husband', + 'mom', + 'stand', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'gender', + 'people', + 'dad', + 'papa', + 'pere', + 'father', + 'parents', + 'adult', + 'maman', + 'mommy', + 'mama', + 'mother' + ], + modifiable: true), + Emoji( + name: 'person standing: medium-light skin tone', + char: '\u{1F9CD}\u{1F3FC}', + shortName: 'person_standing_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personActivity, + keywords: [ + 'uc12', + 'diversity', + 'men', + 'human', + 'daddy', + 'parent', + 'wife', + 'husband', + 'mom', + 'stand', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'gender', + 'people', + 'dad', + 'papa', + 'pere', + 'father', + 'parents', + 'adult', + 'maman', + 'mommy', + 'mama', + 'mother' + ], + modifiable: true), + Emoji( + name: 'person standing: medium skin tone', + char: '\u{1F9CD}\u{1F3FD}', + shortName: 'person_standing_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personActivity, + keywords: [ + 'uc12', + 'diversity', + 'men', + 'human', + 'daddy', + 'parent', + 'wife', + 'husband', + 'mom', + 'stand', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'gender', + 'people', + 'dad', + 'papa', + 'pere', + 'father', + 'parents', + 'adult', + 'maman', + 'mommy', + 'mama', + 'mother' + ], + modifiable: true), + Emoji( + name: 'person standing: medium-dark skin tone', + char: '\u{1F9CD}\u{1F3FE}', + shortName: 'person_standing_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personActivity, + keywords: [ + 'uc12', + 'diversity', + 'men', + 'human', + 'daddy', + 'parent', + 'wife', + 'husband', + 'mom', + 'stand', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'gender', + 'people', + 'dad', + 'papa', + 'pere', + 'father', + 'parents', + 'adult', + 'maman', + 'mommy', + 'mama', + 'mother' + ], + modifiable: true), + Emoji( + name: 'person standing: dark skin tone', + char: '\u{1F9CD}\u{1F3FF}', + shortName: 'person_standing_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personActivity, + keywords: [ + 'uc12', + 'diversity', + 'men', + 'human', + 'daddy', + 'parent', + 'wife', + 'husband', + 'mom', + 'stand', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'gender', + 'people', + 'dad', + 'papa', + 'pere', + 'father', + 'parents', + 'adult', + 'maman', + 'mommy', + 'mama', + 'mother' + ], + modifiable: true), + Emoji( + name: 'woman standing', + char: '\u{1F9CD}\u{200D}\u{2640}\u{FE0F}', + shortName: 'woman_standing', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personActivity, + keywords: [ + 'uc12', + 'diversity', + 'human', + 'parent', + 'wife', + 'mom', + 'stand', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'gender', + 'people', + 'parents', + 'adult', + 'maman', + 'mommy', + 'mama', + 'mother' + ]), + Emoji( + name: 'woman standing: light skin tone', + char: '\u{1F9CD}\u{1F3FB}\u{200D}\u{2640}\u{FE0F}', + shortName: 'woman_standing_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personActivity, + keywords: [ + 'uc12', + 'diversity', + 'human', + 'parent', + 'wife', + 'mom', + 'stand', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'gender', + 'people', + 'parents', + 'adult', + 'maman', + 'mommy', + 'mama', + 'mother' + ], + modifiable: true), + Emoji( + name: 'woman standing: medium-light skin tone', + char: '\u{1F9CD}\u{1F3FC}\u{200D}\u{2640}\u{FE0F}', + shortName: 'woman_standing_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personActivity, + keywords: [ + 'uc12', + 'diversity', + 'human', + 'parent', + 'wife', + 'mom', + 'stand', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'gender', + 'people', + 'parents', + 'adult', + 'maman', + 'mommy', + 'mama', + 'mother' + ], + modifiable: true), + Emoji( + name: 'woman standing: medium skin tone', + char: '\u{1F9CD}\u{1F3FD}\u{200D}\u{2640}\u{FE0F}', + shortName: 'woman_standing_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personActivity, + keywords: [ + 'uc12', + 'diversity', + 'human', + 'parent', + 'wife', + 'mom', + 'stand', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'gender', + 'people', + 'parents', + 'adult', + 'maman', + 'mommy', + 'mama', + 'mother' + ], + modifiable: true), + Emoji( + name: 'woman standing: medium-dark skin tone', + char: '\u{1F9CD}\u{1F3FE}\u{200D}\u{2640}\u{FE0F}', + shortName: 'woman_standing_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personActivity, + keywords: [ + 'uc12', + 'diversity', + 'human', + 'parent', + 'wife', + 'mom', + 'stand', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'gender', + 'people', + 'parents', + 'adult', + 'maman', + 'mommy', + 'mama', + 'mother' + ], + modifiable: true), + Emoji( + name: 'woman standing: dark skin tone', + char: '\u{1F9CD}\u{1F3FF}\u{200D}\u{2640}\u{FE0F}', + shortName: 'woman_standing_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personActivity, + keywords: [ + 'uc12', + 'diversity', + 'human', + 'parent', + 'wife', + 'mom', + 'stand', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'gender', + 'people', + 'parents', + 'adult', + 'maman', + 'mommy', + 'mama', + 'mother' + ], + modifiable: true), + Emoji( + name: 'man standing', + char: '\u{1F9CD}\u{200D}\u{2642}\u{FE0F}', + shortName: 'man_standing', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personActivity, + keywords: [ + 'uc12', + 'diversity', + 'men', + 'human', + 'daddy', + 'parent', + 'husband', + 'stand', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'gender', + 'people', + 'dad', + 'papa', + 'pere', + 'father', + 'parents', + 'adult' + ]), + Emoji( + name: 'man standing: light skin tone', + char: '\u{1F9CD}\u{1F3FB}\u{200D}\u{2642}\u{FE0F}', + shortName: 'man_standing_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personActivity, + keywords: [ + 'uc12', + 'diversity', + 'men', + 'human', + 'daddy', + 'parent', + 'husband', + 'stand', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'gender', + 'people', + 'dad', + 'papa', + 'pere', + 'father', + 'parents', + 'adult' + ], + modifiable: true), + Emoji( + name: 'man standing: medium-light skin tone', + char: '\u{1F9CD}\u{1F3FC}\u{200D}\u{2642}\u{FE0F}', + shortName: 'man_standing_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personActivity, + keywords: [ + 'uc12', + 'diversity', + 'men', + 'human', + 'daddy', + 'parent', + 'husband', + 'stand', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'gender', + 'people', + 'dad', + 'papa', + 'pere', + 'father', + 'parents', + 'adult' + ], + modifiable: true), + Emoji( + name: 'man standing: medium skin tone', + char: '\u{1F9CD}\u{1F3FD}\u{200D}\u{2642}\u{FE0F}', + shortName: 'man_standing_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personActivity, + keywords: [ + 'uc12', + 'diversity', + 'men', + 'human', + 'daddy', + 'parent', + 'husband', + 'stand', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'gender', + 'people', + 'dad', + 'papa', + 'pere', + 'father', + 'parents', + 'adult' + ], + modifiable: true), + Emoji( + name: 'man standing: medium-dark skin tone', + char: '\u{1F9CD}\u{1F3FE}\u{200D}\u{2642}\u{FE0F}', + shortName: 'man_standing_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personActivity, + keywords: [ + 'uc12', + 'diversity', + 'men', + 'human', + 'daddy', + 'parent', + 'husband', + 'stand', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'gender', + 'people', + 'dad', + 'papa', + 'pere', + 'father', + 'parents', + 'adult' + ], + modifiable: true), + Emoji( + name: 'man standing: dark skin tone', + char: '\u{1F9CD}\u{1F3FF}\u{200D}\u{2642}\u{FE0F}', + shortName: 'man_standing_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personActivity, + keywords: [ + 'uc12', + 'diversity', + 'men', + 'human', + 'daddy', + 'parent', + 'husband', + 'stand', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'gender', + 'people', + 'dad', + 'papa', + 'pere', + 'father', + 'parents', + 'adult' + ], + modifiable: true), + Emoji( + name: 'people holding hands', + char: '\u{1F9D1}\u{200D}\u{1F91D}\u{200D}\u{1F9D1}', + shortName: 'people_holding_hands', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.family, + keywords: [ + 'uc12', + 'family', + 'diversity', + 'wedding', + 'lesbian', + 'gay', + 'men', + 'lgbt', + 'friend', + 'human', + 'daddy', + 'parent', + 'husband', + 'families', + 'group', + 'brother', + 'sister', + 'daughter', + 'son', + 'together', + 'sibling', + 'twins', + 'brothers', + 'sisters', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'weddings', + 'marriage', + 'newlywed', + 'bride', + 'groome', + 'groom', + 'married', + 'marry', + 'twink', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'homosexual', + 'bisex', + 'transgender', + 'non binary', + 'pansexuality', + 'intersex', + 'friends', + 'friendship', + 'best friends', + 'bestfriends', + 'ami', + 'amiga', + 'amigo', + 'gender', + 'people', + 'dad', + 'papa', + 'pere', + 'father', + 'parents', + 'adult' + ]), + Emoji( + name: 'people holding hands: light skin tone', + char: '\u{1F9D1}\u{1F3FB}\u{200D}\u{1F91D}\u{200D}\u{1F9D1}\u{1F3FB}', + shortName: 'people_holding_hands_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.family, + keywords: [ + 'uc12', + 'family', + 'diversity', + 'wedding', + 'lesbian', + 'gay', + 'men', + 'lgbt', + 'friend', + 'human', + 'daddy', + 'parent', + 'husband', + 'families', + 'group', + 'brother', + 'sister', + 'daughter', + 'son', + 'together', + 'sibling', + 'twins', + 'brothers', + 'sisters', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'weddings', + 'marriage', + 'newlywed', + 'bride', + 'groome', + 'groom', + 'married', + 'marry', + 'twink', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'homosexual', + 'bisex', + 'transgender', + 'non binary', + 'pansexuality', + 'intersex', + 'friends', + 'friendship', + 'best friends', + 'bestfriends', + 'ami', + 'amiga', + 'amigo', + 'gender', + 'people', + 'dad', + 'papa', + 'pere', + 'father', + 'parents', + 'adult' + ], + modifiable: true), + Emoji( + name: 'people holding hands: light skin tone, medium-light skin tone', + char: '\u{1F9D1}\u{1F3FB}\u{200D}\u{1F91D}\u{200D}\u{1F9D1}\u{1F3FC}', + shortName: 'people_holding_hands_tone1_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.family, + keywords: [ + 'uc12', + 'family', + 'diversity', + 'wedding', + 'lesbian', + 'gay', + 'men', + 'lgbt', + 'friend', + 'human', + 'daddy', + 'parent', + 'husband', + 'families', + 'group', + 'brother', + 'sister', + 'daughter', + 'son', + 'together', + 'sibling', + 'twins', + 'brothers', + 'sisters', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'weddings', + 'marriage', + 'newlywed', + 'bride', + 'groome', + 'groom', + 'married', + 'marry', + 'twink', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'homosexual', + 'bisex', + 'transgender', + 'non binary', + 'pansexuality', + 'intersex', + 'friends', + 'friendship', + 'best friends', + 'bestfriends', + 'ami', + 'amiga', + 'amigo', + 'gender', + 'people', + 'dad', + 'papa', + 'pere', + 'father', + 'parents', + 'adult' + ], + modifiable: true), + Emoji( + name: 'people holding hands: light skin tone, medium skin tone', + char: '\u{1F9D1}\u{1F3FB}\u{200D}\u{1F91D}\u{200D}\u{1F9D1}\u{1F3FD}', + shortName: 'people_holding_hands_tone1_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.family, + keywords: [ + 'uc12', + 'family', + 'diversity', + 'wedding', + 'lesbian', + 'gay', + 'men', + 'lgbt', + 'friend', + 'human', + 'daddy', + 'parent', + 'husband', + 'families', + 'group', + 'brother', + 'sister', + 'daughter', + 'son', + 'together', + 'sibling', + 'twins', + 'brothers', + 'sisters', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'weddings', + 'marriage', + 'newlywed', + 'bride', + 'groome', + 'groom', + 'married', + 'marry', + 'twink', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'homosexual', + 'bisex', + 'transgender', + 'non binary', + 'pansexuality', + 'intersex', + 'friends', + 'friendship', + 'best friends', + 'bestfriends', + 'ami', + 'amiga', + 'amigo', + 'gender', + 'people', + 'dad', + 'papa', + 'pere', + 'father', + 'parents', + 'adult' + ], + modifiable: true), + Emoji( + name: 'people holding hands: light skin tone, medium-dark skin tone', + char: '\u{1F9D1}\u{1F3FB}\u{200D}\u{1F91D}\u{200D}\u{1F9D1}\u{1F3FE}', + shortName: 'people_holding_hands_tone1_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.family, + keywords: [ + 'uc12', + 'family', + 'diversity', + 'wedding', + 'lesbian', + 'gay', + 'men', + 'lgbt', + 'friend', + 'human', + 'daddy', + 'parent', + 'husband', + 'families', + 'group', + 'brother', + 'sister', + 'daughter', + 'son', + 'together', + 'sibling', + 'twins', + 'brothers', + 'sisters', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'weddings', + 'marriage', + 'newlywed', + 'bride', + 'groome', + 'groom', + 'married', + 'marry', + 'twink', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'homosexual', + 'bisex', + 'transgender', + 'non binary', + 'pansexuality', + 'intersex', + 'friends', + 'friendship', + 'best friends', + 'bestfriends', + 'ami', + 'amiga', + 'amigo', + 'gender', + 'people', + 'dad', + 'papa', + 'pere', + 'father', + 'parents', + 'adult' + ], + modifiable: true), + Emoji( + name: 'people holding hands: light skin tone, dark skin tone', + char: '\u{1F9D1}\u{1F3FB}\u{200D}\u{1F91D}\u{200D}\u{1F9D1}\u{1F3FF}', + shortName: 'people_holding_hands_tone1_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.family, + keywords: [ + 'uc12', + 'family', + 'diversity', + 'wedding', + 'lesbian', + 'gay', + 'men', + 'lgbt', + 'friend', + 'human', + 'daddy', + 'parent', + 'husband', + 'families', + 'group', + 'brother', + 'sister', + 'daughter', + 'son', + 'together', + 'sibling', + 'twins', + 'brothers', + 'sisters', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'weddings', + 'marriage', + 'newlywed', + 'bride', + 'groome', + 'groom', + 'married', + 'marry', + 'twink', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'homosexual', + 'bisex', + 'transgender', + 'non binary', + 'pansexuality', + 'intersex', + 'friends', + 'friendship', + 'best friends', + 'bestfriends', + 'ami', + 'amiga', + 'amigo', + 'gender', + 'people', + 'dad', + 'papa', + 'pere', + 'father', + 'parents', + 'adult' + ], + modifiable: true), + Emoji( + name: 'people holding hands: medium-light skin tone, light skin tone', + char: '\u{1F9D1}\u{1F3FC}\u{200D}\u{1F91D}\u{200D}\u{1F9D1}\u{1F3FB}', + shortName: 'people_holding_hands_tone2_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.family, + keywords: [ + 'uc12', + 'family', + 'diversity', + 'wedding', + 'lesbian', + 'gay', + 'men', + 'lgbt', + 'friend', + 'human', + 'daddy', + 'parent', + 'husband', + 'families', + 'group', + 'brother', + 'sister', + 'daughter', + 'son', + 'together', + 'sibling', + 'twins', + 'brothers', + 'sisters', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'weddings', + 'marriage', + 'newlywed', + 'bride', + 'groome', + 'groom', + 'married', + 'marry', + 'twink', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'homosexual', + 'bisex', + 'transgender', + 'non binary', + 'pansexuality', + 'intersex', + 'friends', + 'friendship', + 'best friends', + 'bestfriends', + 'ami', + 'amiga', + 'amigo', + 'gender', + 'people', + 'dad', + 'papa', + 'pere', + 'father', + 'parents', + 'adult' + ], + modifiable: true), + Emoji( + name: 'people holding hands: medium-light skin tone', + char: '\u{1F9D1}\u{1F3FC}\u{200D}\u{1F91D}\u{200D}\u{1F9D1}\u{1F3FC}', + shortName: 'people_holding_hands_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.family, + keywords: [ + 'uc12', + 'family', + 'diversity', + 'wedding', + 'lesbian', + 'gay', + 'men', + 'lgbt', + 'friend', + 'human', + 'daddy', + 'parent', + 'husband', + 'families', + 'group', + 'brother', + 'sister', + 'daughter', + 'son', + 'together', + 'sibling', + 'twins', + 'brothers', + 'sisters', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'weddings', + 'marriage', + 'newlywed', + 'bride', + 'groome', + 'groom', + 'married', + 'marry', + 'twink', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'homosexual', + 'bisex', + 'transgender', + 'non binary', + 'pansexuality', + 'intersex', + 'friends', + 'friendship', + 'best friends', + 'bestfriends', + 'ami', + 'amiga', + 'amigo', + 'gender', + 'people', + 'dad', + 'papa', + 'pere', + 'father', + 'parents', + 'adult' + ], + modifiable: true), + Emoji( + name: 'people holding hands: medium-light skin tone, medium skin tone', + char: '\u{1F9D1}\u{1F3FC}\u{200D}\u{1F91D}\u{200D}\u{1F9D1}\u{1F3FD}', + shortName: 'people_holding_hands_tone2_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.family, + keywords: [ + 'uc12', + 'family', + 'diversity', + 'wedding', + 'lesbian', + 'gay', + 'men', + 'lgbt', + 'friend', + 'human', + 'daddy', + 'parent', + 'husband', + 'families', + 'group', + 'brother', + 'sister', + 'daughter', + 'son', + 'together', + 'sibling', + 'twins', + 'brothers', + 'sisters', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'weddings', + 'marriage', + 'newlywed', + 'bride', + 'groome', + 'groom', + 'married', + 'marry', + 'twink', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'homosexual', + 'bisex', + 'transgender', + 'non binary', + 'pansexuality', + 'intersex', + 'friends', + 'friendship', + 'best friends', + 'bestfriends', + 'ami', + 'amiga', + 'amigo', + 'gender', + 'people', + 'dad', + 'papa', + 'pere', + 'father', + 'parents', + 'adult' + ], + modifiable: true), + Emoji( + name: + 'people holding hands: medium-light skin tone, medium-dark skin tone', + char: '\u{1F9D1}\u{1F3FC}\u{200D}\u{1F91D}\u{200D}\u{1F9D1}\u{1F3FE}', + shortName: 'people_holding_hands_tone2_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.family, + keywords: [ + 'uc12', + 'family', + 'diversity', + 'wedding', + 'lesbian', + 'gay', + 'men', + 'lgbt', + 'friend', + 'human', + 'daddy', + 'parent', + 'husband', + 'families', + 'group', + 'brother', + 'sister', + 'daughter', + 'son', + 'together', + 'sibling', + 'twins', + 'brothers', + 'sisters', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'weddings', + 'marriage', + 'newlywed', + 'bride', + 'groome', + 'groom', + 'married', + 'marry', + 'twink', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'homosexual', + 'bisex', + 'transgender', + 'non binary', + 'pansexuality', + 'intersex', + 'friends', + 'friendship', + 'best friends', + 'bestfriends', + 'ami', + 'amiga', + 'amigo', + 'gender', + 'people', + 'dad', + 'papa', + 'pere', + 'father', + 'parents', + 'adult' + ], + modifiable: true), + Emoji( + name: 'people holding hands: medium-light skin tone, dark skin tone', + char: '\u{1F9D1}\u{1F3FC}\u{200D}\u{1F91D}\u{200D}\u{1F9D1}\u{1F3FF}', + shortName: 'people_holding_hands_tone2_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.family, + keywords: [ + 'uc12', + 'family', + 'diversity', + 'wedding', + 'lesbian', + 'gay', + 'men', + 'lgbt', + 'friend', + 'human', + 'daddy', + 'parent', + 'husband', + 'families', + 'group', + 'brother', + 'sister', + 'daughter', + 'son', + 'together', + 'sibling', + 'twins', + 'brothers', + 'sisters', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'weddings', + 'marriage', + 'newlywed', + 'bride', + 'groome', + 'groom', + 'married', + 'marry', + 'twink', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'homosexual', + 'bisex', + 'transgender', + 'non binary', + 'pansexuality', + 'intersex', + 'friends', + 'friendship', + 'best friends', + 'bestfriends', + 'ami', + 'amiga', + 'amigo', + 'gender', + 'people', + 'dad', + 'papa', + 'pere', + 'father', + 'parents', + 'adult' + ], + modifiable: true), + Emoji( + name: 'people holding hands: medium skin tone, light skin tone', + char: '\u{1F9D1}\u{1F3FD}\u{200D}\u{1F91D}\u{200D}\u{1F9D1}\u{1F3FB}', + shortName: 'people_holding_hands_tone3_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.family, + keywords: [ + 'uc12', + 'family', + 'diversity', + 'wedding', + 'lesbian', + 'gay', + 'men', + 'lgbt', + 'friend', + 'human', + 'daddy', + 'parent', + 'husband', + 'families', + 'group', + 'brother', + 'sister', + 'daughter', + 'son', + 'together', + 'sibling', + 'twins', + 'brothers', + 'sisters', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'weddings', + 'marriage', + 'newlywed', + 'bride', + 'groome', + 'groom', + 'married', + 'marry', + 'twink', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'homosexual', + 'bisex', + 'transgender', + 'non binary', + 'pansexuality', + 'intersex', + 'friends', + 'friendship', + 'best friends', + 'bestfriends', + 'ami', + 'amiga', + 'amigo', + 'gender', + 'people', + 'dad', + 'papa', + 'pere', + 'father', + 'parents', + 'adult' + ], + modifiable: true), + Emoji( + name: 'people holding hands: medium skin tone, medium-light skin tone', + char: '\u{1F9D1}\u{1F3FD}\u{200D}\u{1F91D}\u{200D}\u{1F9D1}\u{1F3FC}', + shortName: 'people_holding_hands_tone3_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.family, + keywords: [ + 'uc12', + 'family', + 'diversity', + 'wedding', + 'lesbian', + 'gay', + 'men', + 'lgbt', + 'friend', + 'human', + 'daddy', + 'parent', + 'husband', + 'families', + 'group', + 'brother', + 'sister', + 'daughter', + 'son', + 'together', + 'sibling', + 'twins', + 'brothers', + 'sisters', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'weddings', + 'marriage', + 'newlywed', + 'bride', + 'groome', + 'groom', + 'married', + 'marry', + 'twink', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'homosexual', + 'bisex', + 'transgender', + 'non binary', + 'pansexuality', + 'intersex', + 'friends', + 'friendship', + 'best friends', + 'bestfriends', + 'ami', + 'amiga', + 'amigo', + 'gender', + 'people', + 'dad', + 'papa', + 'pere', + 'father', + 'parents', + 'adult' + ], + modifiable: true), + Emoji( + name: 'people holding hands: medium skin tone', + char: '\u{1F9D1}\u{1F3FD}\u{200D}\u{1F91D}\u{200D}\u{1F9D1}\u{1F3FD}', + shortName: 'people_holding_hands_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.family, + keywords: [ + 'uc12', + 'family', + 'diversity', + 'wedding', + 'lesbian', + 'gay', + 'men', + 'lgbt', + 'friend', + 'human', + 'daddy', + 'parent', + 'husband', + 'families', + 'group', + 'brother', + 'sister', + 'daughter', + 'son', + 'together', + 'sibling', + 'twins', + 'brothers', + 'sisters', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'weddings', + 'marriage', + 'newlywed', + 'bride', + 'groome', + 'groom', + 'married', + 'marry', + 'twink', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'homosexual', + 'bisex', + 'transgender', + 'non binary', + 'pansexuality', + 'intersex', + 'friends', + 'friendship', + 'best friends', + 'bestfriends', + 'ami', + 'amiga', + 'amigo', + 'gender', + 'people', + 'dad', + 'papa', + 'pere', + 'father', + 'parents', + 'adult' + ], + modifiable: true), + Emoji( + name: 'people holding hands: medium skin tone, medium-dark skin tone', + char: '\u{1F9D1}\u{1F3FD}\u{200D}\u{1F91D}\u{200D}\u{1F9D1}\u{1F3FE}', + shortName: 'people_holding_hands_tone3_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.family, + keywords: [ + 'uc12', + 'family', + 'diversity', + 'wedding', + 'lesbian', + 'gay', + 'men', + 'lgbt', + 'friend', + 'human', + 'daddy', + 'parent', + 'husband', + 'families', + 'group', + 'brother', + 'sister', + 'daughter', + 'son', + 'together', + 'sibling', + 'twins', + 'brothers', + 'sisters', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'weddings', + 'marriage', + 'newlywed', + 'bride', + 'groome', + 'groom', + 'married', + 'marry', + 'twink', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'homosexual', + 'bisex', + 'transgender', + 'non binary', + 'pansexuality', + 'intersex', + 'friends', + 'friendship', + 'best friends', + 'bestfriends', + 'ami', + 'amiga', + 'amigo', + 'gender', + 'people', + 'dad', + 'papa', + 'pere', + 'father', + 'parents', + 'adult' + ], + modifiable: true), + Emoji( + name: 'people holding hands: medium skin tone, dark skin tone', + char: '\u{1F9D1}\u{1F3FD}\u{200D}\u{1F91D}\u{200D}\u{1F9D1}\u{1F3FF}', + shortName: 'people_holding_hands_tone3_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.family, + keywords: [ + 'uc12', + 'family', + 'diversity', + 'wedding', + 'lesbian', + 'gay', + 'men', + 'lgbt', + 'friend', + 'human', + 'daddy', + 'parent', + 'husband', + 'families', + 'group', + 'brother', + 'sister', + 'daughter', + 'son', + 'together', + 'sibling', + 'twins', + 'brothers', + 'sisters', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'weddings', + 'marriage', + 'newlywed', + 'bride', + 'groome', + 'groom', + 'married', + 'marry', + 'twink', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'homosexual', + 'bisex', + 'transgender', + 'non binary', + 'pansexuality', + 'intersex', + 'friends', + 'friendship', + 'best friends', + 'bestfriends', + 'ami', + 'amiga', + 'amigo', + 'gender', + 'people', + 'dad', + 'papa', + 'pere', + 'father', + 'parents', + 'adult' + ], + modifiable: true), + Emoji( + name: 'people holding hands: medium-dark skin tone, light skin tone', + char: '\u{1F9D1}\u{1F3FE}\u{200D}\u{1F91D}\u{200D}\u{1F9D1}\u{1F3FB}', + shortName: 'people_holding_hands_tone4_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.family, + keywords: [ + 'uc12', + 'family', + 'diversity', + 'wedding', + 'lesbian', + 'gay', + 'men', + 'lgbt', + 'friend', + 'human', + 'daddy', + 'parent', + 'husband', + 'families', + 'group', + 'brother', + 'sister', + 'daughter', + 'son', + 'together', + 'sibling', + 'twins', + 'brothers', + 'sisters', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'weddings', + 'marriage', + 'newlywed', + 'bride', + 'groome', + 'groom', + 'married', + 'marry', + 'twink', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'homosexual', + 'bisex', + 'transgender', + 'non binary', + 'pansexuality', + 'intersex', + 'friends', + 'friendship', + 'best friends', + 'bestfriends', + 'ami', + 'amiga', + 'amigo', + 'gender', + 'people', + 'dad', + 'papa', + 'pere', + 'father', + 'parents', + 'adult' + ], + modifiable: true), + Emoji( + name: + 'people holding hands: medium-dark skin tone, medium-light skin tone', + char: '\u{1F9D1}\u{1F3FE}\u{200D}\u{1F91D}\u{200D}\u{1F9D1}\u{1F3FC}', + shortName: 'people_holding_hands_tone4_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.family, + keywords: [ + 'uc12', + 'family', + 'diversity', + 'wedding', + 'lesbian', + 'gay', + 'men', + 'lgbt', + 'friend', + 'human', + 'daddy', + 'parent', + 'husband', + 'families', + 'group', + 'brother', + 'sister', + 'daughter', + 'son', + 'together', + 'sibling', + 'twins', + 'brothers', + 'sisters', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'weddings', + 'marriage', + 'newlywed', + 'bride', + 'groome', + 'groom', + 'married', + 'marry', + 'twink', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'homosexual', + 'bisex', + 'transgender', + 'non binary', + 'pansexuality', + 'intersex', + 'friends', + 'friendship', + 'best friends', + 'bestfriends', + 'ami', + 'amiga', + 'amigo', + 'gender', + 'people', + 'dad', + 'papa', + 'pere', + 'father', + 'parents', + 'adult' + ], + modifiable: true), + Emoji( + name: 'people holding hands: medium-dark skin tone, medium skin tone', + char: '\u{1F9D1}\u{1F3FE}\u{200D}\u{1F91D}\u{200D}\u{1F9D1}\u{1F3FD}', + shortName: 'people_holding_hands_tone4_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.family, + keywords: [ + 'uc12', + 'family', + 'diversity', + 'wedding', + 'lesbian', + 'gay', + 'men', + 'lgbt', + 'friend', + 'human', + 'daddy', + 'parent', + 'husband', + 'families', + 'group', + 'brother', + 'sister', + 'daughter', + 'son', + 'together', + 'sibling', + 'twins', + 'brothers', + 'sisters', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'weddings', + 'marriage', + 'newlywed', + 'bride', + 'groome', + 'groom', + 'married', + 'marry', + 'twink', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'homosexual', + 'bisex', + 'transgender', + 'non binary', + 'pansexuality', + 'intersex', + 'friends', + 'friendship', + 'best friends', + 'bestfriends', + 'ami', + 'amiga', + 'amigo', + 'gender', + 'people', + 'dad', + 'papa', + 'pere', + 'father', + 'parents', + 'adult' + ], + modifiable: true), + Emoji( + name: 'people holding hands: medium-dark skin tone', + char: '\u{1F9D1}\u{1F3FE}\u{200D}\u{1F91D}\u{200D}\u{1F9D1}\u{1F3FE}', + shortName: 'people_holding_hands_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.family, + keywords: [ + 'uc12', + 'family', + 'diversity', + 'wedding', + 'lesbian', + 'gay', + 'men', + 'lgbt', + 'friend', + 'human', + 'daddy', + 'parent', + 'husband', + 'families', + 'group', + 'brother', + 'sister', + 'daughter', + 'son', + 'together', + 'sibling', + 'twins', + 'brothers', + 'sisters', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'weddings', + 'marriage', + 'newlywed', + 'bride', + 'groome', + 'groom', + 'married', + 'marry', + 'twink', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'homosexual', + 'bisex', + 'transgender', + 'non binary', + 'pansexuality', + 'intersex', + 'friends', + 'friendship', + 'best friends', + 'bestfriends', + 'ami', + 'amiga', + 'amigo', + 'gender', + 'people', + 'dad', + 'papa', + 'pere', + 'father', + 'parents', + 'adult' + ], + modifiable: true), + Emoji( + name: 'people holding hands: medium-dark skin tone, dark skin tone', + char: '\u{1F9D1}\u{1F3FE}\u{200D}\u{1F91D}\u{200D}\u{1F9D1}\u{1F3FF}', + shortName: 'people_holding_hands_tone4_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.family, + keywords: [ + 'uc12', + 'family', + 'diversity', + 'wedding', + 'lesbian', + 'gay', + 'men', + 'lgbt', + 'friend', + 'human', + 'daddy', + 'parent', + 'husband', + 'families', + 'group', + 'brother', + 'sister', + 'daughter', + 'son', + 'together', + 'sibling', + 'twins', + 'brothers', + 'sisters', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'weddings', + 'marriage', + 'newlywed', + 'bride', + 'groome', + 'groom', + 'married', + 'marry', + 'twink', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'homosexual', + 'bisex', + 'transgender', + 'non binary', + 'pansexuality', + 'intersex', + 'friends', + 'friendship', + 'best friends', + 'bestfriends', + 'ami', + 'amiga', + 'amigo', + 'gender', + 'people', + 'dad', + 'papa', + 'pere', + 'father', + 'parents', + 'adult' + ], + modifiable: true), + Emoji( + name: 'people holding hands: dark skin tone, light skin tone', + char: '\u{1F9D1}\u{1F3FF}\u{200D}\u{1F91D}\u{200D}\u{1F9D1}\u{1F3FB}', + shortName: 'people_holding_hands_tone5_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.family, + keywords: [ + 'uc12', + 'family', + 'diversity', + 'wedding', + 'lesbian', + 'gay', + 'men', + 'lgbt', + 'friend', + 'human', + 'daddy', + 'parent', + 'husband', + 'families', + 'group', + 'brother', + 'sister', + 'daughter', + 'son', + 'together', + 'sibling', + 'twins', + 'brothers', + 'sisters', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'weddings', + 'marriage', + 'newlywed', + 'bride', + 'groome', + 'groom', + 'married', + 'marry', + 'twink', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'homosexual', + 'bisex', + 'transgender', + 'non binary', + 'pansexuality', + 'intersex', + 'friends', + 'friendship', + 'best friends', + 'bestfriends', + 'ami', + 'amiga', + 'amigo', + 'gender', + 'people', + 'dad', + 'papa', + 'pere', + 'father', + 'parents', + 'adult' + ], + modifiable: true), + Emoji( + name: 'people holding hands: dark skin tone, medium-light skin tone', + char: '\u{1F9D1}\u{1F3FF}\u{200D}\u{1F91D}\u{200D}\u{1F9D1}\u{1F3FC}', + shortName: 'people_holding_hands_tone5_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.family, + keywords: [ + 'uc12', + 'family', + 'diversity', + 'wedding', + 'lesbian', + 'gay', + 'men', + 'lgbt', + 'friend', + 'human', + 'daddy', + 'parent', + 'husband', + 'families', + 'group', + 'brother', + 'sister', + 'daughter', + 'son', + 'together', + 'sibling', + 'twins', + 'brothers', + 'sisters', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'weddings', + 'marriage', + 'newlywed', + 'bride', + 'groome', + 'groom', + 'married', + 'marry', + 'twink', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'homosexual', + 'bisex', + 'transgender', + 'non binary', + 'pansexuality', + 'intersex', + 'friends', + 'friendship', + 'best friends', + 'bestfriends', + 'ami', + 'amiga', + 'amigo', + 'gender', + 'people', + 'dad', + 'papa', + 'pere', + 'father', + 'parents', + 'adult' + ], + modifiable: true), + Emoji( + name: 'people holding hands: dark skin tone, medium skin tone', + char: '\u{1F9D1}\u{1F3FF}\u{200D}\u{1F91D}\u{200D}\u{1F9D1}\u{1F3FD}', + shortName: 'people_holding_hands_tone5_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.family, + keywords: [ + 'uc12', + 'family', + 'diversity', + 'wedding', + 'lesbian', + 'gay', + 'men', + 'lgbt', + 'friend', + 'human', + 'daddy', + 'parent', + 'husband', + 'families', + 'group', + 'brother', + 'sister', + 'daughter', + 'son', + 'together', + 'sibling', + 'twins', + 'brothers', + 'sisters', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'weddings', + 'marriage', + 'newlywed', + 'bride', + 'groome', + 'groom', + 'married', + 'marry', + 'twink', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'homosexual', + 'bisex', + 'transgender', + 'non binary', + 'pansexuality', + 'intersex', + 'friends', + 'friendship', + 'best friends', + 'bestfriends', + 'ami', + 'amiga', + 'amigo', + 'gender', + 'people', + 'dad', + 'papa', + 'pere', + 'father', + 'parents', + 'adult' + ], + modifiable: true), + Emoji( + name: 'people holding hands: dark skin tone, medium-dark skin tone', + char: '\u{1F9D1}\u{1F3FF}\u{200D}\u{1F91D}\u{200D}\u{1F9D1}\u{1F3FE}', + shortName: 'people_holding_hands_tone5_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.family, + keywords: [ + 'uc12', + 'family', + 'diversity', + 'wedding', + 'lesbian', + 'gay', + 'men', + 'lgbt', + 'friend', + 'human', + 'daddy', + 'parent', + 'husband', + 'families', + 'group', + 'brother', + 'sister', + 'daughter', + 'son', + 'together', + 'sibling', + 'twins', + 'brothers', + 'sisters', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'weddings', + 'marriage', + 'newlywed', + 'bride', + 'groome', + 'groom', + 'married', + 'marry', + 'twink', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'homosexual', + 'bisex', + 'transgender', + 'non binary', + 'pansexuality', + 'intersex', + 'friends', + 'friendship', + 'best friends', + 'bestfriends', + 'ami', + 'amiga', + 'amigo', + 'gender', + 'people', + 'dad', + 'papa', + 'pere', + 'father', + 'parents', + 'adult' + ], + modifiable: true), + Emoji( + name: 'people holding hands: dark skin tone', + char: '\u{1F9D1}\u{1F3FF}\u{200D}\u{1F91D}\u{200D}\u{1F9D1}\u{1F3FF}', + shortName: 'people_holding_hands_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.family, + keywords: [ + 'uc12', + 'family', + 'diversity', + 'wedding', + 'lesbian', + 'gay', + 'men', + 'lgbt', + 'friend', + 'human', + 'daddy', + 'parent', + 'husband', + 'families', + 'group', + 'brother', + 'sister', + 'daughter', + 'son', + 'together', + 'sibling', + 'twins', + 'brothers', + 'sisters', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'weddings', + 'marriage', + 'newlywed', + 'bride', + 'groome', + 'groom', + 'married', + 'marry', + 'twink', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'homosexual', + 'bisex', + 'transgender', + 'non binary', + 'pansexuality', + 'intersex', + 'friends', + 'friendship', + 'best friends', + 'bestfriends', + 'ami', + 'amiga', + 'amigo', + 'gender', + 'people', + 'dad', + 'papa', + 'pere', + 'father', + 'parents', + 'adult' + ], + modifiable: true), + Emoji( + name: 'woman and man holding hands', + char: '\u{1F46B}', + shortName: 'couple', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.family, + keywords: [ + 'couple', + 'hand', + 'hold', + 'man', + 'woman', + 'uc6', + 'family', + 'diversity', + 'wedding', + 'creationism', + 'friend', + 'human', + 'porn', + 'parent', + 'wife', + 'husband', + 'families', + 'group', + 'brother', + 'sister', + 'daughter', + 'son', + 'together', + 'sibling', + 'twins', + 'brothers', + 'sisters', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'weddings', + 'marriage', + 'newlywed', + 'bride', + 'groome', + 'groom', + 'married', + 'marry', + 'adam & eve', + 'adam and eve', + 'friends', + 'friendship', + 'best friends', + 'bestfriends', + 'ami', + 'amiga', + 'amigo', + 'gender', + 'people', + 'parents', + 'adult' + ]), + Emoji( + name: 'woman and man holding hands: light skin tone', + char: '\u{1F46B}\u{1F3FB}', + shortName: 'woman_and_man_holding_hands_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.family, + keywords: [ + 'uc12', + 'family', + 'diversity', + 'wedding', + 'creationism', + 'friend', + 'human', + 'porn', + 'parent', + 'wife', + 'husband', + 'families', + 'group', + 'brother', + 'sister', + 'daughter', + 'son', + 'together', + 'sibling', + 'twins', + 'brothers', + 'sisters', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'weddings', + 'marriage', + 'newlywed', + 'bride', + 'groome', + 'groom', + 'married', + 'marry', + 'adam & eve', + 'adam and eve', + 'friends', + 'friendship', + 'best friends', + 'bestfriends', + 'ami', + 'amiga', + 'amigo', + 'gender', + 'people', + 'parents', + 'adult' + ], + modifiable: true), + Emoji( + name: + 'woman and man holding hands: light skin tone, medium-light skin tone', + char: '\u{1F469}\u{1F3FB}\u{200D}\u{1F91D}\u{200D}\u{1F468}\u{1F3FC}', + shortName: 'woman_and_man_holding_hands_tone1_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.family, + keywords: [ + 'uc12', + 'family', + 'diversity', + 'wedding', + 'creationism', + 'friend', + 'human', + 'porn', + 'parent', + 'wife', + 'husband', + 'families', + 'group', + 'brother', + 'sister', + 'daughter', + 'son', + 'together', + 'sibling', + 'twins', + 'brothers', + 'sisters', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'weddings', + 'marriage', + 'newlywed', + 'bride', + 'groome', + 'groom', + 'married', + 'marry', + 'adam & eve', + 'adam and eve', + 'friends', + 'friendship', + 'best friends', + 'bestfriends', + 'ami', + 'amiga', + 'amigo', + 'gender', + 'people', + 'parents', + 'adult' + ], + modifiable: true), + Emoji( + name: 'woman and man holding hands: light skin tone, medium skin tone', + char: '\u{1F469}\u{1F3FB}\u{200D}\u{1F91D}\u{200D}\u{1F468}\u{1F3FD}', + shortName: 'woman_and_man_holding_hands_tone1_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.family, + keywords: [ + 'uc12', + 'family', + 'diversity', + 'wedding', + 'creationism', + 'friend', + 'human', + 'porn', + 'parent', + 'wife', + 'husband', + 'families', + 'group', + 'brother', + 'sister', + 'daughter', + 'son', + 'together', + 'sibling', + 'twins', + 'brothers', + 'sisters', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'weddings', + 'marriage', + 'newlywed', + 'bride', + 'groome', + 'groom', + 'married', + 'marry', + 'adam & eve', + 'adam and eve', + 'friends', + 'friendship', + 'best friends', + 'bestfriends', + 'ami', + 'amiga', + 'amigo', + 'gender', + 'people', + 'parents', + 'adult' + ], + modifiable: true), + Emoji( + name: + 'woman and man holding hands: light skin tone, medium-dark skin tone', + char: '\u{1F469}\u{1F3FB}\u{200D}\u{1F91D}\u{200D}\u{1F468}\u{1F3FE}', + shortName: 'woman_and_man_holding_hands_tone1_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.family, + keywords: [ + 'uc12', + 'family', + 'diversity', + 'wedding', + 'creationism', + 'friend', + 'human', + 'porn', + 'parent', + 'wife', + 'husband', + 'families', + 'group', + 'brother', + 'sister', + 'daughter', + 'son', + 'together', + 'sibling', + 'twins', + 'brothers', + 'sisters', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'weddings', + 'marriage', + 'newlywed', + 'bride', + 'groome', + 'groom', + 'married', + 'marry', + 'adam & eve', + 'adam and eve', + 'friends', + 'friendship', + 'best friends', + 'bestfriends', + 'ami', + 'amiga', + 'amigo', + 'gender', + 'people', + 'parents', + 'adult' + ], + modifiable: true), + Emoji( + name: 'woman and man holding hands: light skin tone, dark skin tone', + char: '\u{1F469}\u{1F3FB}\u{200D}\u{1F91D}\u{200D}\u{1F468}\u{1F3FF}', + shortName: 'woman_and_man_holding_hands_tone1_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.family, + keywords: [ + 'uc12', + 'family', + 'diversity', + 'wedding', + 'creationism', + 'friend', + 'human', + 'porn', + 'parent', + 'wife', + 'husband', + 'families', + 'group', + 'brother', + 'sister', + 'daughter', + 'son', + 'together', + 'sibling', + 'twins', + 'brothers', + 'sisters', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'weddings', + 'marriage', + 'newlywed', + 'bride', + 'groome', + 'groom', + 'married', + 'marry', + 'adam & eve', + 'adam and eve', + 'friends', + 'friendship', + 'best friends', + 'bestfriends', + 'ami', + 'amiga', + 'amigo', + 'gender', + 'people', + 'parents', + 'adult' + ], + modifiable: true), + Emoji( + name: + 'woman and man holding hands: medium-light skin tone, light skin tone', + char: '\u{1F469}\u{1F3FC}\u{200D}\u{1F91D}\u{200D}\u{1F468}\u{1F3FB}', + shortName: 'woman_and_man_holding_hands_tone2_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.family, + keywords: [ + 'uc12', + 'family', + 'diversity', + 'wedding', + 'creationism', + 'friend', + 'human', + 'porn', + 'parent', + 'wife', + 'husband', + 'families', + 'group', + 'brother', + 'sister', + 'daughter', + 'son', + 'together', + 'sibling', + 'twins', + 'brothers', + 'sisters', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'weddings', + 'marriage', + 'newlywed', + 'bride', + 'groome', + 'groom', + 'married', + 'marry', + 'adam & eve', + 'adam and eve', + 'friends', + 'friendship', + 'best friends', + 'bestfriends', + 'ami', + 'amiga', + 'amigo', + 'gender', + 'people', + 'parents', + 'adult' + ], + modifiable: true), + Emoji( + name: 'woman and man holding hands: medium-light skin tone', + char: '\u{1F46B}\u{1F3FC}', + shortName: 'woman_and_man_holding_hands_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.family, + keywords: [ + 'uc12', + 'family', + 'diversity', + 'wedding', + 'creationism', + 'friend', + 'human', + 'porn', + 'parent', + 'wife', + 'husband', + 'families', + 'group', + 'brother', + 'sister', + 'daughter', + 'son', + 'together', + 'sibling', + 'twins', + 'brothers', + 'sisters', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'weddings', + 'marriage', + 'newlywed', + 'bride', + 'groome', + 'groom', + 'married', + 'marry', + 'adam & eve', + 'adam and eve', + 'friends', + 'friendship', + 'best friends', + 'bestfriends', + 'ami', + 'amiga', + 'amigo', + 'gender', + 'people', + 'parents', + 'adult' + ], + modifiable: true), + Emoji( + name: + 'woman and man holding hands: medium-light skin tone, medium skin tone', + char: '\u{1F469}\u{1F3FC}\u{200D}\u{1F91D}\u{200D}\u{1F468}\u{1F3FD}', + shortName: 'woman_and_man_holding_hands_tone2_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.family, + keywords: [ + 'uc12', + 'family', + 'diversity', + 'wedding', + 'creationism', + 'friend', + 'human', + 'porn', + 'parent', + 'wife', + 'husband', + 'families', + 'group', + 'brother', + 'sister', + 'daughter', + 'son', + 'together', + 'sibling', + 'twins', + 'brothers', + 'sisters', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'weddings', + 'marriage', + 'newlywed', + 'bride', + 'groome', + 'groom', + 'married', + 'marry', + 'adam & eve', + 'adam and eve', + 'friends', + 'friendship', + 'best friends', + 'bestfriends', + 'ami', + 'amiga', + 'amigo', + 'gender', + 'people', + 'parents', + 'adult' + ], + modifiable: true), + Emoji( + name: + 'woman and man holding hands: medium-light skin tone, medium-dark skin tone', + char: '\u{1F469}\u{1F3FC}\u{200D}\u{1F91D}\u{200D}\u{1F468}\u{1F3FE}', + shortName: 'woman_and_man_holding_hands_tone2_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.family, + keywords: [ + 'uc12', + 'family', + 'diversity', + 'wedding', + 'creationism', + 'friend', + 'human', + 'porn', + 'parent', + 'wife', + 'husband', + 'families', + 'group', + 'brother', + 'sister', + 'daughter', + 'son', + 'together', + 'sibling', + 'twins', + 'brothers', + 'sisters', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'weddings', + 'marriage', + 'newlywed', + 'bride', + 'groome', + 'groom', + 'married', + 'marry', + 'adam & eve', + 'adam and eve', + 'friends', + 'friendship', + 'best friends', + 'bestfriends', + 'ami', + 'amiga', + 'amigo', + 'gender', + 'people', + 'parents', + 'adult' + ], + modifiable: true), + Emoji( + name: + 'woman and man holding hands: medium-light skin tone, dark skin tone', + char: '\u{1F469}\u{1F3FC}\u{200D}\u{1F91D}\u{200D}\u{1F468}\u{1F3FF}', + shortName: 'woman_and_man_holding_hands_tone2_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.family, + keywords: [ + 'uc12', + 'family', + 'diversity', + 'wedding', + 'creationism', + 'friend', + 'human', + 'porn', + 'parent', + 'wife', + 'husband', + 'families', + 'group', + 'brother', + 'sister', + 'daughter', + 'son', + 'together', + 'sibling', + 'twins', + 'brothers', + 'sisters', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'weddings', + 'marriage', + 'newlywed', + 'bride', + 'groome', + 'groom', + 'married', + 'marry', + 'adam & eve', + 'adam and eve', + 'friends', + 'friendship', + 'best friends', + 'bestfriends', + 'ami', + 'amiga', + 'amigo', + 'gender', + 'people', + 'parents', + 'adult' + ], + modifiable: true), + Emoji( + name: 'woman and man holding hands: medium skin tone, light skin tone', + char: '\u{1F469}\u{1F3FD}\u{200D}\u{1F91D}\u{200D}\u{1F468}\u{1F3FB}', + shortName: 'woman_and_man_holding_hands_tone3_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.family, + keywords: [ + 'uc12', + 'family', + 'diversity', + 'wedding', + 'creationism', + 'friend', + 'human', + 'porn', + 'parent', + 'wife', + 'husband', + 'families', + 'group', + 'brother', + 'sister', + 'daughter', + 'son', + 'together', + 'sibling', + 'twins', + 'brothers', + 'sisters', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'weddings', + 'marriage', + 'newlywed', + 'bride', + 'groome', + 'groom', + 'married', + 'marry', + 'adam & eve', + 'adam and eve', + 'friends', + 'friendship', + 'best friends', + 'bestfriends', + 'ami', + 'amiga', + 'amigo', + 'gender', + 'people', + 'parents', + 'adult' + ], + modifiable: true), + Emoji( + name: + 'woman and man holding hands: medium skin tone, medium-light skin tone', + char: '\u{1F469}\u{1F3FD}\u{200D}\u{1F91D}\u{200D}\u{1F468}\u{1F3FC}', + shortName: 'woman_and_man_holding_hands_tone3_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.family, + keywords: [ + 'uc12', + 'family', + 'diversity', + 'wedding', + 'creationism', + 'friend', + 'human', + 'porn', + 'parent', + 'wife', + 'husband', + 'families', + 'group', + 'brother', + 'sister', + 'daughter', + 'son', + 'together', + 'sibling', + 'twins', + 'brothers', + 'sisters', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'weddings', + 'marriage', + 'newlywed', + 'bride', + 'groome', + 'groom', + 'married', + 'marry', + 'adam & eve', + 'adam and eve', + 'friends', + 'friendship', + 'best friends', + 'bestfriends', + 'ami', + 'amiga', + 'amigo', + 'gender', + 'people', + 'parents', + 'adult' + ], + modifiable: true), + Emoji( + name: 'woman and man holding hands: medium skin tone', + char: '\u{1F46B}\u{1F3FD}', + shortName: 'woman_and_man_holding_hands_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.family, + keywords: [ + 'uc12', + 'family', + 'diversity', + 'wedding', + 'creationism', + 'friend', + 'human', + 'porn', + 'parent', + 'wife', + 'husband', + 'families', + 'group', + 'brother', + 'sister', + 'daughter', + 'son', + 'together', + 'sibling', + 'twins', + 'brothers', + 'sisters', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'weddings', + 'marriage', + 'newlywed', + 'bride', + 'groome', + 'groom', + 'married', + 'marry', + 'adam & eve', + 'adam and eve', + 'friends', + 'friendship', + 'best friends', + 'bestfriends', + 'ami', + 'amiga', + 'amigo', + 'gender', + 'people', + 'parents', + 'adult' + ], + modifiable: true), + Emoji( + name: + 'woman and man holding hands: medium skin tone, medium-dark skin tone', + char: '\u{1F469}\u{1F3FD}\u{200D}\u{1F91D}\u{200D}\u{1F468}\u{1F3FE}', + shortName: 'woman_and_man_holding_hands_tone3_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.family, + keywords: [ + 'uc12', + 'family', + 'diversity', + 'wedding', + 'creationism', + 'friend', + 'human', + 'porn', + 'parent', + 'wife', + 'husband', + 'families', + 'group', + 'brother', + 'sister', + 'daughter', + 'son', + 'together', + 'sibling', + 'twins', + 'brothers', + 'sisters', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'weddings', + 'marriage', + 'newlywed', + 'bride', + 'groome', + 'groom', + 'married', + 'marry', + 'adam & eve', + 'adam and eve', + 'friends', + 'friendship', + 'best friends', + 'bestfriends', + 'ami', + 'amiga', + 'amigo', + 'gender', + 'people', + 'parents', + 'adult' + ], + modifiable: true), + Emoji( + name: 'woman and man holding hands: medium skin tone, dark skin tone', + char: '\u{1F469}\u{1F3FD}\u{200D}\u{1F91D}\u{200D}\u{1F468}\u{1F3FF}', + shortName: 'woman_and_man_holding_hands_tone3_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.family, + keywords: [ + 'uc12', + 'family', + 'diversity', + 'wedding', + 'creationism', + 'friend', + 'human', + 'porn', + 'parent', + 'wife', + 'husband', + 'families', + 'group', + 'brother', + 'sister', + 'daughter', + 'son', + 'together', + 'sibling', + 'twins', + 'brothers', + 'sisters', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'weddings', + 'marriage', + 'newlywed', + 'bride', + 'groome', + 'groom', + 'married', + 'marry', + 'adam & eve', + 'adam and eve', + 'friends', + 'friendship', + 'best friends', + 'bestfriends', + 'ami', + 'amiga', + 'amigo', + 'gender', + 'people', + 'parents', + 'adult' + ], + modifiable: true), + Emoji( + name: + 'woman and man holding hands: medium-dark skin tone, light skin tone', + char: '\u{1F469}\u{1F3FE}\u{200D}\u{1F91D}\u{200D}\u{1F468}\u{1F3FB}', + shortName: 'woman_and_man_holding_hands_tone4_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.family, + keywords: [ + 'uc12', + 'family', + 'diversity', + 'wedding', + 'creationism', + 'friend', + 'human', + 'porn', + 'parent', + 'wife', + 'husband', + 'families', + 'group', + 'brother', + 'sister', + 'daughter', + 'son', + 'together', + 'sibling', + 'twins', + 'brothers', + 'sisters', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'weddings', + 'marriage', + 'newlywed', + 'bride', + 'groome', + 'groom', + 'married', + 'marry', + 'adam & eve', + 'adam and eve', + 'friends', + 'friendship', + 'best friends', + 'bestfriends', + 'ami', + 'amiga', + 'amigo', + 'gender', + 'people', + 'parents', + 'adult' + ], + modifiable: true), + Emoji( + name: + 'woman and man holding hands: medium-dark skin tone, medium-light skin tone', + char: '\u{1F469}\u{1F3FE}\u{200D}\u{1F91D}\u{200D}\u{1F468}\u{1F3FC}', + shortName: 'woman_and_man_holding_hands_tone4_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.family, + keywords: [ + 'uc12', + 'family', + 'diversity', + 'wedding', + 'creationism', + 'friend', + 'human', + 'porn', + 'parent', + 'wife', + 'husband', + 'families', + 'group', + 'brother', + 'sister', + 'daughter', + 'son', + 'together', + 'sibling', + 'twins', + 'brothers', + 'sisters', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'weddings', + 'marriage', + 'newlywed', + 'bride', + 'groome', + 'groom', + 'married', + 'marry', + 'adam & eve', + 'adam and eve', + 'friends', + 'friendship', + 'best friends', + 'bestfriends', + 'ami', + 'amiga', + 'amigo', + 'gender', + 'people', + 'parents', + 'adult' + ], + modifiable: true), + Emoji( + name: + 'woman and man holding hands: medium-dark skin tone, medium skin tone', + char: '\u{1F469}\u{1F3FE}\u{200D}\u{1F91D}\u{200D}\u{1F468}\u{1F3FD}', + shortName: 'woman_and_man_holding_hands_tone4_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.family, + keywords: [ + 'uc12', + 'family', + 'diversity', + 'wedding', + 'creationism', + 'friend', + 'human', + 'porn', + 'parent', + 'wife', + 'husband', + 'families', + 'group', + 'brother', + 'sister', + 'daughter', + 'son', + 'together', + 'sibling', + 'twins', + 'brothers', + 'sisters', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'weddings', + 'marriage', + 'newlywed', + 'bride', + 'groome', + 'groom', + 'married', + 'marry', + 'adam & eve', + 'adam and eve', + 'friends', + 'friendship', + 'best friends', + 'bestfriends', + 'ami', + 'amiga', + 'amigo', + 'gender', + 'people', + 'parents', + 'adult' + ], + modifiable: true), + Emoji( + name: 'woman and man holding hands: medium-dark skin tone', + char: '\u{1F46B}\u{1F3FE}', + shortName: 'woman_and_man_holding_hands_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.family, + keywords: [ + 'uc12', + 'family', + 'diversity', + 'wedding', + 'creationism', + 'friend', + 'human', + 'porn', + 'parent', + 'wife', + 'husband', + 'families', + 'group', + 'brother', + 'sister', + 'daughter', + 'son', + 'together', + 'sibling', + 'twins', + 'brothers', + 'sisters', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'weddings', + 'marriage', + 'newlywed', + 'bride', + 'groome', + 'groom', + 'married', + 'marry', + 'adam & eve', + 'adam and eve', + 'friends', + 'friendship', + 'best friends', + 'bestfriends', + 'ami', + 'amiga', + 'amigo', + 'gender', + 'people', + 'parents', + 'adult' + ], + modifiable: true), + Emoji( + name: + 'woman and man holding hands: medium-dark skin tone, dark skin tone', + char: '\u{1F469}\u{1F3FE}\u{200D}\u{1F91D}\u{200D}\u{1F468}\u{1F3FF}', + shortName: 'woman_and_man_holding_hands_tone4_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.family, + keywords: [ + 'uc12', + 'family', + 'diversity', + 'wedding', + 'creationism', + 'friend', + 'human', + 'porn', + 'parent', + 'wife', + 'husband', + 'families', + 'group', + 'brother', + 'sister', + 'daughter', + 'son', + 'together', + 'sibling', + 'twins', + 'brothers', + 'sisters', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'weddings', + 'marriage', + 'newlywed', + 'bride', + 'groome', + 'groom', + 'married', + 'marry', + 'adam & eve', + 'adam and eve', + 'friends', + 'friendship', + 'best friends', + 'bestfriends', + 'ami', + 'amiga', + 'amigo', + 'gender', + 'people', + 'parents', + 'adult' + ], + modifiable: true), + Emoji( + name: 'woman and man holding hands: dark skin tone, light skin tone', + char: '\u{1F469}\u{1F3FF}\u{200D}\u{1F91D}\u{200D}\u{1F468}\u{1F3FB}', + shortName: 'woman_and_man_holding_hands_tone5_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.family, + keywords: [ + 'uc12', + 'family', + 'diversity', + 'wedding', + 'creationism', + 'friend', + 'human', + 'porn', + 'parent', + 'wife', + 'husband', + 'families', + 'group', + 'brother', + 'sister', + 'daughter', + 'son', + 'together', + 'sibling', + 'twins', + 'brothers', + 'sisters', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'weddings', + 'marriage', + 'newlywed', + 'bride', + 'groome', + 'groom', + 'married', + 'marry', + 'adam & eve', + 'adam and eve', + 'friends', + 'friendship', + 'best friends', + 'bestfriends', + 'ami', + 'amiga', + 'amigo', + 'gender', + 'people', + 'parents', + 'adult' + ], + modifiable: true), + Emoji( + name: + 'woman and man holding hands: dark skin tone, medium-light skin tone', + char: '\u{1F469}\u{1F3FF}\u{200D}\u{1F91D}\u{200D}\u{1F468}\u{1F3FC}', + shortName: 'woman_and_man_holding_hands_tone5_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.family, + keywords: [ + 'uc12', + 'family', + 'diversity', + 'wedding', + 'creationism', + 'friend', + 'human', + 'porn', + 'parent', + 'wife', + 'husband', + 'families', + 'group', + 'brother', + 'sister', + 'daughter', + 'son', + 'together', + 'sibling', + 'twins', + 'brothers', + 'sisters', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'weddings', + 'marriage', + 'newlywed', + 'bride', + 'groome', + 'groom', + 'married', + 'marry', + 'adam & eve', + 'adam and eve', + 'friends', + 'friendship', + 'best friends', + 'bestfriends', + 'ami', + 'amiga', + 'amigo', + 'gender', + 'people', + 'parents', + 'adult' + ], + modifiable: true), + Emoji( + name: 'woman and man holding hands: dark skin tone, medium skin tone', + char: '\u{1F469}\u{1F3FF}\u{200D}\u{1F91D}\u{200D}\u{1F468}\u{1F3FD}', + shortName: 'woman_and_man_holding_hands_tone5_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.family, + keywords: [ + 'uc12', + 'family', + 'diversity', + 'wedding', + 'creationism', + 'friend', + 'human', + 'porn', + 'parent', + 'wife', + 'husband', + 'families', + 'group', + 'brother', + 'sister', + 'daughter', + 'son', + 'together', + 'sibling', + 'twins', + 'brothers', + 'sisters', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'weddings', + 'marriage', + 'newlywed', + 'bride', + 'groome', + 'groom', + 'married', + 'marry', + 'adam & eve', + 'adam and eve', + 'friends', + 'friendship', + 'best friends', + 'bestfriends', + 'ami', + 'amiga', + 'amigo', + 'gender', + 'people', + 'parents', + 'adult' + ], + modifiable: true), + Emoji( + name: + 'woman and man holding hands: dark skin tone, medium-dark skin tone', + char: '\u{1F469}\u{1F3FF}\u{200D}\u{1F91D}\u{200D}\u{1F468}\u{1F3FE}', + shortName: 'woman_and_man_holding_hands_tone5_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.family, + keywords: [ + 'uc12', + 'family', + 'diversity', + 'wedding', + 'creationism', + 'friend', + 'human', + 'porn', + 'parent', + 'wife', + 'husband', + 'families', + 'group', + 'brother', + 'sister', + 'daughter', + 'son', + 'together', + 'sibling', + 'twins', + 'brothers', + 'sisters', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'weddings', + 'marriage', + 'newlywed', + 'bride', + 'groome', + 'groom', + 'married', + 'marry', + 'adam & eve', + 'adam and eve', + 'friends', + 'friendship', + 'best friends', + 'bestfriends', + 'ami', + 'amiga', + 'amigo', + 'gender', + 'people', + 'parents', + 'adult' + ], + modifiable: true), + Emoji( + name: 'woman and man holding hands: dark skin tone', + char: '\u{1F46B}\u{1F3FF}', + shortName: 'woman_and_man_holding_hands_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.family, + keywords: [ + 'uc12', + 'family', + 'diversity', + 'wedding', + 'creationism', + 'friend', + 'human', + 'porn', + 'parent', + 'wife', + 'husband', + 'families', + 'group', + 'brother', + 'sister', + 'daughter', + 'son', + 'together', + 'sibling', + 'twins', + 'brothers', + 'sisters', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'weddings', + 'marriage', + 'newlywed', + 'bride', + 'groome', + 'groom', + 'married', + 'marry', + 'adam & eve', + 'adam and eve', + 'friends', + 'friendship', + 'best friends', + 'bestfriends', + 'ami', + 'amiga', + 'amigo', + 'gender', + 'people', + 'parents', + 'adult' + ], + modifiable: true), + Emoji( + name: 'women holding hands', + char: '\u{1F46D}', + shortName: 'two_women_holding_hands', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.family, + keywords: [ + 'couple', + 'hand', + 'hold', + 'woman', + 'uc6', + 'family', + 'diversity', + 'wedding', + 'lesbian', + 'gay', + 'women', + 'lgbt', + 'girls night', + 'friend', + 'human', + 'porn', + 'parent', + 'wife', + 'mom', + 'families', + 'group', + 'brother', + 'sister', + 'daughter', + 'son', + 'together', + 'sibling', + 'twins', + 'brothers', + 'sisters', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'weddings', + 'marriage', + 'newlywed', + 'bride', + 'groome', + 'groom', + 'married', + 'marry', + 'twink', + 'woman', + 'female', + 'homosexual', + 'bisex', + 'transgender', + 'non binary', + 'pansexuality', + 'intersex', + 'ladies night', + 'girls only', + 'girlfriend', + 'friends', + 'friendship', + 'best friends', + 'bestfriends', + 'ami', + 'amiga', + 'amigo', + 'gender', + 'people', + 'parents', + 'adult', + 'maman', + 'mommy', + 'mama', + 'mother' + ]), + Emoji( + name: 'women holding hands: light skin tone', + char: '\u{1F46D}\u{1F3FB}', + shortName: 'women_holding_hands_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.family, + keywords: [ + 'uc12', + 'family', + 'diversity', + 'wedding', + 'lesbian', + 'gay', + 'women', + 'lgbt', + 'girls night', + 'friend', + 'human', + 'porn', + 'parent', + 'wife', + 'mom', + 'families', + 'group', + 'brother', + 'sister', + 'daughter', + 'son', + 'together', + 'sibling', + 'twins', + 'brothers', + 'sisters', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'weddings', + 'marriage', + 'newlywed', + 'bride', + 'groome', + 'groom', + 'married', + 'marry', + 'twink', + 'woman', + 'female', + 'homosexual', + 'bisex', + 'transgender', + 'non binary', + 'pansexuality', + 'intersex', + 'ladies night', + 'girls only', + 'girlfriend', + 'friends', + 'friendship', + 'best friends', + 'bestfriends', + 'ami', + 'amiga', + 'amigo', + 'gender', + 'people', + 'parents', + 'adult', + 'maman', + 'mommy', + 'mama', + 'mother' + ], + modifiable: true), + Emoji( + name: 'women holding hands: light skin tone, medium-light skin tone', + char: '\u{1F469}\u{1F3FB}\u{200D}\u{1F91D}\u{200D}\u{1F469}\u{1F3FC}', + shortName: 'women_holding_hands_tone1_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.family, + keywords: [ + 'uc12', + 'family', + 'diversity', + 'wedding', + 'lesbian', + 'gay', + 'women', + 'lgbt', + 'girls night', + 'friend', + 'human', + 'porn', + 'parent', + 'wife', + 'mom', + 'families', + 'group', + 'brother', + 'sister', + 'daughter', + 'son', + 'together', + 'sibling', + 'twins', + 'brothers', + 'sisters', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'weddings', + 'marriage', + 'newlywed', + 'bride', + 'groome', + 'groom', + 'married', + 'marry', + 'twink', + 'woman', + 'female', + 'homosexual', + 'bisex', + 'transgender', + 'non binary', + 'pansexuality', + 'intersex', + 'ladies night', + 'girls only', + 'girlfriend', + 'friends', + 'friendship', + 'best friends', + 'bestfriends', + 'ami', + 'amiga', + 'amigo', + 'gender', + 'people', + 'parents', + 'adult', + 'maman', + 'mommy', + 'mama', + 'mother' + ], + modifiable: true), + Emoji( + name: 'women holding hands: light skin tone, medium skin tone', + char: '\u{1F469}\u{1F3FB}\u{200D}\u{1F91D}\u{200D}\u{1F469}\u{1F3FD}', + shortName: 'women_holding_hands_tone1_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.family, + keywords: [ + 'uc12', + 'family', + 'diversity', + 'wedding', + 'lesbian', + 'gay', + 'women', + 'lgbt', + 'girls night', + 'friend', + 'human', + 'porn', + 'parent', + 'wife', + 'mom', + 'families', + 'group', + 'brother', + 'sister', + 'daughter', + 'son', + 'together', + 'sibling', + 'twins', + 'brothers', + 'sisters', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'weddings', + 'marriage', + 'newlywed', + 'bride', + 'groome', + 'groom', + 'married', + 'marry', + 'twink', + 'woman', + 'female', + 'homosexual', + 'bisex', + 'transgender', + 'non binary', + 'pansexuality', + 'intersex', + 'ladies night', + 'girls only', + 'girlfriend', + 'friends', + 'friendship', + 'best friends', + 'bestfriends', + 'ami', + 'amiga', + 'amigo', + 'gender', + 'people', + 'parents', + 'adult', + 'maman', + 'mommy', + 'mama', + 'mother' + ], + modifiable: true), + Emoji( + name: 'women holding hands: light skin tone, medium-dark skin tone', + char: '\u{1F469}\u{1F3FB}\u{200D}\u{1F91D}\u{200D}\u{1F469}\u{1F3FE}', + shortName: 'women_holding_hands_tone1_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.family, + keywords: [ + 'uc12', + 'family', + 'diversity', + 'wedding', + 'lesbian', + 'gay', + 'women', + 'lgbt', + 'girls night', + 'friend', + 'human', + 'porn', + 'parent', + 'wife', + 'mom', + 'families', + 'group', + 'brother', + 'sister', + 'daughter', + 'son', + 'together', + 'sibling', + 'twins', + 'brothers', + 'sisters', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'weddings', + 'marriage', + 'newlywed', + 'bride', + 'groome', + 'groom', + 'married', + 'marry', + 'twink', + 'woman', + 'female', + 'homosexual', + 'bisex', + 'transgender', + 'non binary', + 'pansexuality', + 'intersex', + 'ladies night', + 'girls only', + 'girlfriend', + 'friends', + 'friendship', + 'best friends', + 'bestfriends', + 'ami', + 'amiga', + 'amigo', + 'gender', + 'people', + 'parents', + 'adult', + 'maman', + 'mommy', + 'mama', + 'mother' + ], + modifiable: true), + Emoji( + name: 'women holding hands: light skin tone, dark skin tone', + char: '\u{1F469}\u{1F3FB}\u{200D}\u{1F91D}\u{200D}\u{1F469}\u{1F3FF}', + shortName: 'women_holding_hands_tone1_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.family, + keywords: [ + 'uc12', + 'family', + 'diversity', + 'wedding', + 'lesbian', + 'gay', + 'women', + 'lgbt', + 'girls night', + 'friend', + 'human', + 'porn', + 'parent', + 'wife', + 'mom', + 'families', + 'group', + 'brother', + 'sister', + 'daughter', + 'son', + 'together', + 'sibling', + 'twins', + 'brothers', + 'sisters', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'weddings', + 'marriage', + 'newlywed', + 'bride', + 'groome', + 'groom', + 'married', + 'marry', + 'twink', + 'woman', + 'female', + 'homosexual', + 'bisex', + 'transgender', + 'non binary', + 'pansexuality', + 'intersex', + 'ladies night', + 'girls only', + 'girlfriend', + 'friends', + 'friendship', + 'best friends', + 'bestfriends', + 'ami', + 'amiga', + 'amigo', + 'gender', + 'people', + 'parents', + 'adult', + 'maman', + 'mommy', + 'mama', + 'mother' + ], + modifiable: true), + Emoji( + name: 'women holding hands: medium-light skin tone, light skin tone', + char: '\u{1F469}\u{1F3FC}\u{200D}\u{1F91D}\u{200D}\u{1F469}\u{1F3FB}', + shortName: 'women_holding_hands_tone2_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.family, + keywords: [ + 'uc12', + 'family', + 'diversity', + 'wedding', + 'lesbian', + 'gay', + 'women', + 'lgbt', + 'girls night', + 'friend', + 'human', + 'porn', + 'parent', + 'wife', + 'mom', + 'families', + 'group', + 'brother', + 'sister', + 'daughter', + 'son', + 'together', + 'sibling', + 'twins', + 'brothers', + 'sisters', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'weddings', + 'marriage', + 'newlywed', + 'bride', + 'groome', + 'groom', + 'married', + 'marry', + 'twink', + 'woman', + 'female', + 'homosexual', + 'bisex', + 'transgender', + 'non binary', + 'pansexuality', + 'intersex', + 'ladies night', + 'girls only', + 'girlfriend', + 'friends', + 'friendship', + 'best friends', + 'bestfriends', + 'ami', + 'amiga', + 'amigo', + 'gender', + 'people', + 'parents', + 'adult', + 'maman', + 'mommy', + 'mama', + 'mother' + ], + modifiable: true), + Emoji( + name: 'women holding hands: medium-light skin tone', + char: '\u{1F46D}\u{1F3FC}', + shortName: 'women_holding_hands_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.family, + keywords: [ + 'uc12', + 'family', + 'diversity', + 'wedding', + 'lesbian', + 'gay', + 'women', + 'lgbt', + 'girls night', + 'friend', + 'human', + 'porn', + 'parent', + 'wife', + 'mom', + 'families', + 'group', + 'brother', + 'sister', + 'daughter', + 'son', + 'together', + 'sibling', + 'twins', + 'brothers', + 'sisters', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'weddings', + 'marriage', + 'newlywed', + 'bride', + 'groome', + 'groom', + 'married', + 'marry', + 'twink', + 'woman', + 'female', + 'homosexual', + 'bisex', + 'transgender', + 'non binary', + 'pansexuality', + 'intersex', + 'ladies night', + 'girls only', + 'girlfriend', + 'friends', + 'friendship', + 'best friends', + 'bestfriends', + 'ami', + 'amiga', + 'amigo', + 'gender', + 'people', + 'parents', + 'adult', + 'maman', + 'mommy', + 'mama', + 'mother' + ], + modifiable: true), + Emoji( + name: 'women holding hands: medium-light skin tone, medium skin tone', + char: '\u{1F469}\u{1F3FC}\u{200D}\u{1F91D}\u{200D}\u{1F469}\u{1F3FD}', + shortName: 'women_holding_hands_tone2_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.family, + keywords: [ + 'uc12', + 'family', + 'diversity', + 'wedding', + 'lesbian', + 'gay', + 'women', + 'lgbt', + 'girls night', + 'friend', + 'human', + 'porn', + 'parent', + 'wife', + 'mom', + 'families', + 'group', + 'brother', + 'sister', + 'daughter', + 'son', + 'together', + 'sibling', + 'twins', + 'brothers', + 'sisters', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'weddings', + 'marriage', + 'newlywed', + 'bride', + 'groome', + 'groom', + 'married', + 'marry', + 'twink', + 'woman', + 'female', + 'homosexual', + 'bisex', + 'transgender', + 'non binary', + 'pansexuality', + 'intersex', + 'ladies night', + 'girls only', + 'girlfriend', + 'friends', + 'friendship', + 'best friends', + 'bestfriends', + 'ami', + 'amiga', + 'amigo', + 'gender', + 'people', + 'parents', + 'adult', + 'maman', + 'mommy', + 'mama', + 'mother' + ], + modifiable: true), + Emoji( + name: + 'women holding hands: medium-light skin tone, medium-dark skin tone', + char: '\u{1F469}\u{1F3FC}\u{200D}\u{1F91D}\u{200D}\u{1F469}\u{1F3FE}', + shortName: 'women_holding_hands_tone2_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.family, + keywords: [ + 'uc12', + 'family', + 'diversity', + 'wedding', + 'lesbian', + 'gay', + 'women', + 'lgbt', + 'girls night', + 'friend', + 'human', + 'porn', + 'parent', + 'wife', + 'mom', + 'families', + 'group', + 'brother', + 'sister', + 'daughter', + 'son', + 'together', + 'sibling', + 'twins', + 'brothers', + 'sisters', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'weddings', + 'marriage', + 'newlywed', + 'bride', + 'groome', + 'groom', + 'married', + 'marry', + 'twink', + 'woman', + 'female', + 'homosexual', + 'bisex', + 'transgender', + 'non binary', + 'pansexuality', + 'intersex', + 'ladies night', + 'girls only', + 'girlfriend', + 'friends', + 'friendship', + 'best friends', + 'bestfriends', + 'ami', + 'amiga', + 'amigo', + 'gender', + 'people', + 'parents', + 'adult', + 'maman', + 'mommy', + 'mama', + 'mother' + ], + modifiable: true), + Emoji( + name: 'women holding hands: medium-light skin tone, dark skin tone', + char: '\u{1F469}\u{1F3FC}\u{200D}\u{1F91D}\u{200D}\u{1F469}\u{1F3FF}', + shortName: 'women_holding_hands_tone2_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.family, + keywords: [ + 'uc12', + 'family', + 'diversity', + 'wedding', + 'lesbian', + 'gay', + 'women', + 'lgbt', + 'girls night', + 'friend', + 'human', + 'porn', + 'parent', + 'wife', + 'mom', + 'families', + 'group', + 'brother', + 'sister', + 'daughter', + 'son', + 'together', + 'sibling', + 'twins', + 'brothers', + 'sisters', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'weddings', + 'marriage', + 'newlywed', + 'bride', + 'groome', + 'groom', + 'married', + 'marry', + 'twink', + 'woman', + 'female', + 'homosexual', + 'bisex', + 'transgender', + 'non binary', + 'pansexuality', + 'intersex', + 'ladies night', + 'girls only', + 'girlfriend', + 'friends', + 'friendship', + 'best friends', + 'bestfriends', + 'ami', + 'amiga', + 'amigo', + 'gender', + 'people', + 'parents', + 'adult', + 'maman', + 'mommy', + 'mama', + 'mother' + ], + modifiable: true), + Emoji( + name: 'women holding hands: medium skin tone, light skin tone', + char: '\u{1F469}\u{1F3FD}\u{200D}\u{1F91D}\u{200D}\u{1F469}\u{1F3FB}', + shortName: 'women_holding_hands_tone3_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.family, + keywords: [ + 'uc12', + 'family', + 'diversity', + 'wedding', + 'lesbian', + 'gay', + 'women', + 'lgbt', + 'girls night', + 'friend', + 'human', + 'porn', + 'parent', + 'wife', + 'mom', + 'families', + 'group', + 'brother', + 'sister', + 'daughter', + 'son', + 'together', + 'sibling', + 'twins', + 'brothers', + 'sisters', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'weddings', + 'marriage', + 'newlywed', + 'bride', + 'groome', + 'groom', + 'married', + 'marry', + 'twink', + 'woman', + 'female', + 'homosexual', + 'bisex', + 'transgender', + 'non binary', + 'pansexuality', + 'intersex', + 'ladies night', + 'girls only', + 'girlfriend', + 'friends', + 'friendship', + 'best friends', + 'bestfriends', + 'ami', + 'amiga', + 'amigo', + 'gender', + 'people', + 'parents', + 'adult', + 'maman', + 'mommy', + 'mama', + 'mother' + ], + modifiable: true), + Emoji( + name: 'women holding hands: medium skin tone, medium-light skin tone', + char: '\u{1F469}\u{1F3FD}\u{200D}\u{1F91D}\u{200D}\u{1F469}\u{1F3FC}', + shortName: 'women_holding_hands_tone3_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.family, + keywords: [ + 'uc12', + 'family', + 'diversity', + 'wedding', + 'lesbian', + 'gay', + 'women', + 'lgbt', + 'girls night', + 'friend', + 'human', + 'porn', + 'parent', + 'wife', + 'mom', + 'families', + 'group', + 'brother', + 'sister', + 'daughter', + 'son', + 'together', + 'sibling', + 'twins', + 'brothers', + 'sisters', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'weddings', + 'marriage', + 'newlywed', + 'bride', + 'groome', + 'groom', + 'married', + 'marry', + 'twink', + 'woman', + 'female', + 'homosexual', + 'bisex', + 'transgender', + 'non binary', + 'pansexuality', + 'intersex', + 'ladies night', + 'girls only', + 'girlfriend', + 'friends', + 'friendship', + 'best friends', + 'bestfriends', + 'ami', + 'amiga', + 'amigo', + 'gender', + 'people', + 'parents', + 'adult', + 'maman', + 'mommy', + 'mama', + 'mother' + ], + modifiable: true), + Emoji( + name: 'women holding hands: medium skin tone', + char: '\u{1F46D}\u{1F3FD}', + shortName: 'women_holding_hands_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.family, + keywords: [ + 'uc12', + 'family', + 'diversity', + 'wedding', + 'lesbian', + 'gay', + 'women', + 'lgbt', + 'girls night', + 'friend', + 'human', + 'porn', + 'parent', + 'wife', + 'mom', + 'families', + 'group', + 'brother', + 'sister', + 'daughter', + 'son', + 'together', + 'sibling', + 'twins', + 'brothers', + 'sisters', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'weddings', + 'marriage', + 'newlywed', + 'bride', + 'groome', + 'groom', + 'married', + 'marry', + 'twink', + 'woman', + 'female', + 'homosexual', + 'bisex', + 'transgender', + 'non binary', + 'pansexuality', + 'intersex', + 'ladies night', + 'girls only', + 'girlfriend', + 'friends', + 'friendship', + 'best friends', + 'bestfriends', + 'ami', + 'amiga', + 'amigo', + 'gender', + 'people', + 'parents', + 'adult', + 'maman', + 'mommy', + 'mama', + 'mother' + ], + modifiable: true), + Emoji( + name: 'women holding hands: medium skin tone, medium-dark skin tone', + char: '\u{1F469}\u{1F3FD}\u{200D}\u{1F91D}\u{200D}\u{1F469}\u{1F3FE}', + shortName: 'women_holding_hands_tone3_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.family, + keywords: [ + 'uc12', + 'family', + 'diversity', + 'wedding', + 'lesbian', + 'gay', + 'women', + 'lgbt', + 'girls night', + 'friend', + 'human', + 'porn', + 'parent', + 'wife', + 'mom', + 'families', + 'group', + 'brother', + 'sister', + 'daughter', + 'son', + 'together', + 'sibling', + 'twins', + 'brothers', + 'sisters', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'weddings', + 'marriage', + 'newlywed', + 'bride', + 'groome', + 'groom', + 'married', + 'marry', + 'twink', + 'woman', + 'female', + 'homosexual', + 'bisex', + 'transgender', + 'non binary', + 'pansexuality', + 'intersex', + 'ladies night', + 'girls only', + 'girlfriend', + 'friends', + 'friendship', + 'best friends', + 'bestfriends', + 'ami', + 'amiga', + 'amigo', + 'gender', + 'people', + 'parents', + 'adult', + 'maman', + 'mommy', + 'mama', + 'mother' + ], + modifiable: true), + Emoji( + name: 'women holding hands: medium skin tone, dark skin tone', + char: '\u{1F469}\u{1F3FD}\u{200D}\u{1F91D}\u{200D}\u{1F469}\u{1F3FF}', + shortName: 'women_holding_hands_tone3_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.family, + keywords: [ + 'uc12', + 'family', + 'diversity', + 'wedding', + 'lesbian', + 'gay', + 'women', + 'lgbt', + 'girls night', + 'friend', + 'human', + 'porn', + 'parent', + 'wife', + 'mom', + 'families', + 'group', + 'brother', + 'sister', + 'daughter', + 'son', + 'together', + 'sibling', + 'twins', + 'brothers', + 'sisters', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'weddings', + 'marriage', + 'newlywed', + 'bride', + 'groome', + 'groom', + 'married', + 'marry', + 'twink', + 'woman', + 'female', + 'homosexual', + 'bisex', + 'transgender', + 'non binary', + 'pansexuality', + 'intersex', + 'ladies night', + 'girls only', + 'girlfriend', + 'friends', + 'friendship', + 'best friends', + 'bestfriends', + 'ami', + 'amiga', + 'amigo', + 'gender', + 'people', + 'parents', + 'adult', + 'maman', + 'mommy', + 'mama', + 'mother' + ], + modifiable: true), + Emoji( + name: 'women holding hands: medium-dark skin tone, light skin tone', + char: '\u{1F469}\u{1F3FE}\u{200D}\u{1F91D}\u{200D}\u{1F469}\u{1F3FB}', + shortName: 'women_holding_hands_tone4_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.family, + keywords: [ + 'uc12', + 'family', + 'diversity', + 'wedding', + 'lesbian', + 'gay', + 'women', + 'lgbt', + 'girls night', + 'friend', + 'human', + 'porn', + 'parent', + 'wife', + 'mom', + 'families', + 'group', + 'brother', + 'sister', + 'daughter', + 'son', + 'together', + 'sibling', + 'twins', + 'brothers', + 'sisters', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'weddings', + 'marriage', + 'newlywed', + 'bride', + 'groome', + 'groom', + 'married', + 'marry', + 'twink', + 'woman', + 'female', + 'homosexual', + 'bisex', + 'transgender', + 'non binary', + 'pansexuality', + 'intersex', + 'ladies night', + 'girls only', + 'girlfriend', + 'friends', + 'friendship', + 'best friends', + 'bestfriends', + 'ami', + 'amiga', + 'amigo', + 'gender', + 'people', + 'parents', + 'adult', + 'maman', + 'mommy', + 'mama', + 'mother' + ], + modifiable: true), + Emoji( + name: + 'women holding hands: medium-dark skin tone, medium-light skin tone', + char: '\u{1F469}\u{1F3FE}\u{200D}\u{1F91D}\u{200D}\u{1F469}\u{1F3FC}', + shortName: 'women_holding_hands_tone4_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.family, + keywords: [ + 'uc12', + 'family', + 'diversity', + 'wedding', + 'lesbian', + 'gay', + 'women', + 'lgbt', + 'girls night', + 'friend', + 'human', + 'porn', + 'parent', + 'wife', + 'mom', + 'families', + 'group', + 'brother', + 'sister', + 'daughter', + 'son', + 'together', + 'sibling', + 'twins', + 'brothers', + 'sisters', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'weddings', + 'marriage', + 'newlywed', + 'bride', + 'groome', + 'groom', + 'married', + 'marry', + 'twink', + 'woman', + 'female', + 'homosexual', + 'bisex', + 'transgender', + 'non binary', + 'pansexuality', + 'intersex', + 'ladies night', + 'girls only', + 'girlfriend', + 'friends', + 'friendship', + 'best friends', + 'bestfriends', + 'ami', + 'amiga', + 'amigo', + 'gender', + 'people', + 'parents', + 'adult', + 'maman', + 'mommy', + 'mama', + 'mother' + ], + modifiable: true), + Emoji( + name: 'women holding hands: medium-dark skin tone, medium skin tone', + char: '\u{1F469}\u{1F3FE}\u{200D}\u{1F91D}\u{200D}\u{1F469}\u{1F3FD}', + shortName: 'women_holding_hands_tone4_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.family, + keywords: [ + 'uc12', + 'family', + 'diversity', + 'wedding', + 'lesbian', + 'gay', + 'women', + 'lgbt', + 'girls night', + 'friend', + 'human', + 'porn', + 'parent', + 'wife', + 'mom', + 'families', + 'group', + 'brother', + 'sister', + 'daughter', + 'son', + 'together', + 'sibling', + 'twins', + 'brothers', + 'sisters', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'weddings', + 'marriage', + 'newlywed', + 'bride', + 'groome', + 'groom', + 'married', + 'marry', + 'twink', + 'woman', + 'female', + 'homosexual', + 'bisex', + 'transgender', + 'non binary', + 'pansexuality', + 'intersex', + 'ladies night', + 'girls only', + 'girlfriend', + 'friends', + 'friendship', + 'best friends', + 'bestfriends', + 'ami', + 'amiga', + 'amigo', + 'gender', + 'people', + 'parents', + 'adult', + 'maman', + 'mommy', + 'mama', + 'mother' + ], + modifiable: true), + Emoji( + name: 'women holding hands: medium-dark skin tone', + char: '\u{1F46D}\u{1F3FE}', + shortName: 'women_holding_hands_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.family, + keywords: [ + 'uc12', + 'family', + 'diversity', + 'wedding', + 'lesbian', + 'gay', + 'women', + 'lgbt', + 'girls night', + 'friend', + 'human', + 'porn', + 'parent', + 'wife', + 'mom', + 'families', + 'group', + 'brother', + 'sister', + 'daughter', + 'son', + 'together', + 'sibling', + 'twins', + 'brothers', + 'sisters', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'weddings', + 'marriage', + 'newlywed', + 'bride', + 'groome', + 'groom', + 'married', + 'marry', + 'twink', + 'woman', + 'female', + 'homosexual', + 'bisex', + 'transgender', + 'non binary', + 'pansexuality', + 'intersex', + 'ladies night', + 'girls only', + 'girlfriend', + 'friends', + 'friendship', + 'best friends', + 'bestfriends', + 'ami', + 'amiga', + 'amigo', + 'gender', + 'people', + 'parents', + 'adult', + 'maman', + 'mommy', + 'mama', + 'mother' + ], + modifiable: true), + Emoji( + name: 'women holding hands: medium-dark skin tone, dark skin tone', + char: '\u{1F469}\u{1F3FE}\u{200D}\u{1F91D}\u{200D}\u{1F469}\u{1F3FF}', + shortName: 'women_holding_hands_tone4_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.family, + keywords: [ + 'uc12', + 'family', + 'diversity', + 'wedding', + 'lesbian', + 'gay', + 'women', + 'lgbt', + 'girls night', + 'friend', + 'human', + 'porn', + 'parent', + 'wife', + 'mom', + 'families', + 'group', + 'brother', + 'sister', + 'daughter', + 'son', + 'together', + 'sibling', + 'twins', + 'brothers', + 'sisters', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'weddings', + 'marriage', + 'newlywed', + 'bride', + 'groome', + 'groom', + 'married', + 'marry', + 'twink', + 'woman', + 'female', + 'homosexual', + 'bisex', + 'transgender', + 'non binary', + 'pansexuality', + 'intersex', + 'ladies night', + 'girls only', + 'girlfriend', + 'friends', + 'friendship', + 'best friends', + 'bestfriends', + 'ami', + 'amiga', + 'amigo', + 'gender', + 'people', + 'parents', + 'adult', + 'maman', + 'mommy', + 'mama', + 'mother' + ], + modifiable: true), + Emoji( + name: 'women holding hands: dark skin tone, light skin tone', + char: '\u{1F469}\u{1F3FF}\u{200D}\u{1F91D}\u{200D}\u{1F469}\u{1F3FB}', + shortName: 'women_holding_hands_tone5_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.family, + keywords: [ + 'uc12', + 'family', + 'diversity', + 'wedding', + 'lesbian', + 'gay', + 'women', + 'lgbt', + 'girls night', + 'friend', + 'human', + 'porn', + 'parent', + 'wife', + 'mom', + 'families', + 'group', + 'brother', + 'sister', + 'daughter', + 'son', + 'together', + 'sibling', + 'twins', + 'brothers', + 'sisters', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'weddings', + 'marriage', + 'newlywed', + 'bride', + 'groome', + 'groom', + 'married', + 'marry', + 'twink', + 'woman', + 'female', + 'homosexual', + 'bisex', + 'transgender', + 'non binary', + 'pansexuality', + 'intersex', + 'ladies night', + 'girls only', + 'girlfriend', + 'friends', + 'friendship', + 'best friends', + 'bestfriends', + 'ami', + 'amiga', + 'amigo', + 'gender', + 'people', + 'parents', + 'adult', + 'maman', + 'mommy', + 'mama', + 'mother' + ], + modifiable: true), + Emoji( + name: 'women holding hands: dark skin tone, medium-light skin tone', + char: '\u{1F469}\u{1F3FF}\u{200D}\u{1F91D}\u{200D}\u{1F469}\u{1F3FC}', + shortName: 'women_holding_hands_tone5_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.family, + keywords: [ + 'uc12', + 'family', + 'diversity', + 'wedding', + 'lesbian', + 'gay', + 'women', + 'lgbt', + 'girls night', + 'friend', + 'human', + 'porn', + 'parent', + 'wife', + 'mom', + 'families', + 'group', + 'brother', + 'sister', + 'daughter', + 'son', + 'together', + 'sibling', + 'twins', + 'brothers', + 'sisters', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'weddings', + 'marriage', + 'newlywed', + 'bride', + 'groome', + 'groom', + 'married', + 'marry', + 'twink', + 'woman', + 'female', + 'homosexual', + 'bisex', + 'transgender', + 'non binary', + 'pansexuality', + 'intersex', + 'ladies night', + 'girls only', + 'girlfriend', + 'friends', + 'friendship', + 'best friends', + 'bestfriends', + 'ami', + 'amiga', + 'amigo', + 'gender', + 'people', + 'parents', + 'adult', + 'maman', + 'mommy', + 'mama', + 'mother' + ], + modifiable: true), + Emoji( + name: 'women holding hands: dark skin tone, medium skin tone', + char: '\u{1F469}\u{1F3FF}\u{200D}\u{1F91D}\u{200D}\u{1F469}\u{1F3FD}', + shortName: 'women_holding_hands_tone5_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.family, + keywords: [ + 'uc12', + 'family', + 'diversity', + 'wedding', + 'lesbian', + 'gay', + 'women', + 'lgbt', + 'girls night', + 'friend', + 'human', + 'porn', + 'parent', + 'wife', + 'mom', + 'families', + 'group', + 'brother', + 'sister', + 'daughter', + 'son', + 'together', + 'sibling', + 'twins', + 'brothers', + 'sisters', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'weddings', + 'marriage', + 'newlywed', + 'bride', + 'groome', + 'groom', + 'married', + 'marry', + 'twink', + 'woman', + 'female', + 'homosexual', + 'bisex', + 'transgender', + 'non binary', + 'pansexuality', + 'intersex', + 'ladies night', + 'girls only', + 'girlfriend', + 'friends', + 'friendship', + 'best friends', + 'bestfriends', + 'ami', + 'amiga', + 'amigo', + 'gender', + 'people', + 'parents', + 'adult', + 'maman', + 'mommy', + 'mama', + 'mother' + ], + modifiable: true), + Emoji( + name: 'women holding hands: dark skin tone, medium-dark skin tone', + char: '\u{1F469}\u{1F3FF}\u{200D}\u{1F91D}\u{200D}\u{1F469}\u{1F3FE}', + shortName: 'women_holding_hands_tone5_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.family, + keywords: [ + 'uc12', + 'family', + 'diversity', + 'wedding', + 'lesbian', + 'gay', + 'women', + 'lgbt', + 'girls night', + 'friend', + 'human', + 'porn', + 'parent', + 'wife', + 'mom', + 'families', + 'group', + 'brother', + 'sister', + 'daughter', + 'son', + 'together', + 'sibling', + 'twins', + 'brothers', + 'sisters', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'weddings', + 'marriage', + 'newlywed', + 'bride', + 'groome', + 'groom', + 'married', + 'marry', + 'twink', + 'woman', + 'female', + 'homosexual', + 'bisex', + 'transgender', + 'non binary', + 'pansexuality', + 'intersex', + 'ladies night', + 'girls only', + 'girlfriend', + 'friends', + 'friendship', + 'best friends', + 'bestfriends', + 'ami', + 'amiga', + 'amigo', + 'gender', + 'people', + 'parents', + 'adult', + 'maman', + 'mommy', + 'mama', + 'mother' + ], + modifiable: true), + Emoji( + name: 'women holding hands: dark skin tone', + char: '\u{1F46D}\u{1F3FF}', + shortName: 'women_holding_hands_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.family, + keywords: [ + 'uc12', + 'family', + 'diversity', + 'wedding', + 'lesbian', + 'gay', + 'women', + 'lgbt', + 'girls night', + 'friend', + 'human', + 'porn', + 'parent', + 'wife', + 'mom', + 'families', + 'group', + 'brother', + 'sister', + 'daughter', + 'son', + 'together', + 'sibling', + 'twins', + 'brothers', + 'sisters', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'weddings', + 'marriage', + 'newlywed', + 'bride', + 'groome', + 'groom', + 'married', + 'marry', + 'twink', + 'woman', + 'female', + 'homosexual', + 'bisex', + 'transgender', + 'non binary', + 'pansexuality', + 'intersex', + 'ladies night', + 'girls only', + 'girlfriend', + 'friends', + 'friendship', + 'best friends', + 'bestfriends', + 'ami', + 'amiga', + 'amigo', + 'gender', + 'people', + 'parents', + 'adult', + 'maman', + 'mommy', + 'mama', + 'mother' + ], + modifiable: true), + Emoji( + name: 'men holding hands', + char: '\u{1F46C}', + shortName: 'two_men_holding_hands', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.family, + keywords: [ + 'Gemini', + 'couple', + 'hand', + 'hold', + 'man', + 'twins', + 'zodiac', + 'uc6', + 'family', + 'diversity', + 'wedding', + 'gay', + 'men', + 'lgbt', + 'friend', + 'queen', + 'human', + 'daddy', + 'porn', + 'parent', + 'husband', + 'families', + 'group', + 'brother', + 'sister', + 'daughter', + 'son', + 'together', + 'sibling', + 'twins', + 'brothers', + 'sisters', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'weddings', + 'marriage', + 'newlywed', + 'bride', + 'groome', + 'groom', + 'married', + 'marry', + 'twink', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'homosexual', + 'bisex', + 'transgender', + 'non binary', + 'pansexuality', + 'intersex', + 'friends', + 'friendship', + 'best friends', + 'bestfriends', + 'ami', + 'amiga', + 'amigo', + 'king', + 'prince', + 'princess', + 'gender', + 'people', + 'dad', + 'papa', + 'pere', + 'father', + 'parents', + 'adult' + ]), + Emoji( + name: 'men holding hands: light skin tone', + char: '\u{1F46C}\u{1F3FB}', + shortName: 'men_holding_hands_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.family, + keywords: [ + 'uc12', + 'family', + 'diversity', + 'wedding', + 'gay', + 'men', + 'lgbt', + 'friend', + 'queen', + 'human', + 'daddy', + 'porn', + 'parent', + 'husband', + 'families', + 'group', + 'brother', + 'sister', + 'daughter', + 'son', + 'together', + 'sibling', + 'twins', + 'brothers', + 'sisters', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'weddings', + 'marriage', + 'newlywed', + 'bride', + 'groome', + 'groom', + 'married', + 'marry', + 'twink', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'homosexual', + 'bisex', + 'transgender', + 'non binary', + 'pansexuality', + 'intersex', + 'friends', + 'friendship', + 'best friends', + 'bestfriends', + 'ami', + 'amiga', + 'amigo', + 'king', + 'prince', + 'princess', + 'gender', + 'people', + 'dad', + 'papa', + 'pere', + 'father', + 'parents', + 'adult' + ], + modifiable: true), + Emoji( + name: 'men holding hands: light skin tone, medium-light skin tone', + char: '\u{1F468}\u{1F3FB}\u{200D}\u{1F91D}\u{200D}\u{1F468}\u{1F3FC}', + shortName: 'men_holding_hands_tone1_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.family, + keywords: [ + 'uc12', + 'family', + 'diversity', + 'wedding', + 'gay', + 'men', + 'lgbt', + 'friend', + 'queen', + 'human', + 'daddy', + 'porn', + 'parent', + 'husband', + 'families', + 'group', + 'brother', + 'sister', + 'daughter', + 'son', + 'together', + 'sibling', + 'twins', + 'brothers', + 'sisters', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'weddings', + 'marriage', + 'newlywed', + 'bride', + 'groome', + 'groom', + 'married', + 'marry', + 'twink', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'homosexual', + 'bisex', + 'transgender', + 'non binary', + 'pansexuality', + 'intersex', + 'friends', + 'friendship', + 'best friends', + 'bestfriends', + 'ami', + 'amiga', + 'amigo', + 'king', + 'prince', + 'princess', + 'gender', + 'people', + 'dad', + 'papa', + 'pere', + 'father', + 'parents', + 'adult' + ], + modifiable: true), + Emoji( + name: 'men holding hands: light skin tone, medium skin tone', + char: '\u{1F468}\u{1F3FB}\u{200D}\u{1F91D}\u{200D}\u{1F468}\u{1F3FD}', + shortName: 'men_holding_hands_tone1_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.family, + keywords: [ + 'uc12', + 'family', + 'diversity', + 'wedding', + 'gay', + 'men', + 'lgbt', + 'friend', + 'queen', + 'human', + 'daddy', + 'porn', + 'parent', + 'husband', + 'families', + 'group', + 'brother', + 'sister', + 'daughter', + 'son', + 'together', + 'sibling', + 'twins', + 'brothers', + 'sisters', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'weddings', + 'marriage', + 'newlywed', + 'bride', + 'groome', + 'groom', + 'married', + 'marry', + 'twink', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'homosexual', + 'bisex', + 'transgender', + 'non binary', + 'pansexuality', + 'intersex', + 'friends', + 'friendship', + 'best friends', + 'bestfriends', + 'ami', + 'amiga', + 'amigo', + 'king', + 'prince', + 'princess', + 'gender', + 'people', + 'dad', + 'papa', + 'pere', + 'father', + 'parents', + 'adult' + ], + modifiable: true), + Emoji( + name: 'men holding hands: light skin tone, medium-dark skin tone', + char: '\u{1F468}\u{1F3FB}\u{200D}\u{1F91D}\u{200D}\u{1F468}\u{1F3FE}', + shortName: 'men_holding_hands_tone1_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.family, + keywords: [ + 'uc12', + 'family', + 'diversity', + 'wedding', + 'gay', + 'men', + 'lgbt', + 'friend', + 'queen', + 'human', + 'daddy', + 'porn', + 'parent', + 'husband', + 'families', + 'group', + 'brother', + 'sister', + 'daughter', + 'son', + 'together', + 'sibling', + 'twins', + 'brothers', + 'sisters', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'weddings', + 'marriage', + 'newlywed', + 'bride', + 'groome', + 'groom', + 'married', + 'marry', + 'twink', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'homosexual', + 'bisex', + 'transgender', + 'non binary', + 'pansexuality', + 'intersex', + 'friends', + 'friendship', + 'best friends', + 'bestfriends', + 'ami', + 'amiga', + 'amigo', + 'king', + 'prince', + 'princess', + 'gender', + 'people', + 'dad', + 'papa', + 'pere', + 'father', + 'parents', + 'adult' + ], + modifiable: true), + Emoji( + name: 'men holding hands: light skin tone, dark skin tone', + char: '\u{1F468}\u{1F3FB}\u{200D}\u{1F91D}\u{200D}\u{1F468}\u{1F3FF}', + shortName: 'men_holding_hands_tone1_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.family, + keywords: [ + 'uc12', + 'family', + 'diversity', + 'wedding', + 'gay', + 'men', + 'lgbt', + 'friend', + 'queen', + 'human', + 'daddy', + 'porn', + 'parent', + 'husband', + 'families', + 'group', + 'brother', + 'sister', + 'daughter', + 'son', + 'together', + 'sibling', + 'twins', + 'brothers', + 'sisters', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'weddings', + 'marriage', + 'newlywed', + 'bride', + 'groome', + 'groom', + 'married', + 'marry', + 'twink', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'homosexual', + 'bisex', + 'transgender', + 'non binary', + 'pansexuality', + 'intersex', + 'friends', + 'friendship', + 'best friends', + 'bestfriends', + 'ami', + 'amiga', + 'amigo', + 'king', + 'prince', + 'princess', + 'gender', + 'people', + 'dad', + 'papa', + 'pere', + 'father', + 'parents', + 'adult' + ], + modifiable: true), + Emoji( + name: 'men holding hands: medium-light skin tone, light skin tone', + char: '\u{1F468}\u{1F3FC}\u{200D}\u{1F91D}\u{200D}\u{1F468}\u{1F3FB}', + shortName: 'men_holding_hands_tone2_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.family, + keywords: [ + 'uc12', + 'family', + 'diversity', + 'wedding', + 'gay', + 'men', + 'lgbt', + 'friend', + 'queen', + 'human', + 'daddy', + 'porn', + 'parent', + 'husband', + 'families', + 'group', + 'brother', + 'sister', + 'daughter', + 'son', + 'together', + 'sibling', + 'twins', + 'brothers', + 'sisters', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'weddings', + 'marriage', + 'newlywed', + 'bride', + 'groome', + 'groom', + 'married', + 'marry', + 'twink', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'homosexual', + 'bisex', + 'transgender', + 'non binary', + 'pansexuality', + 'intersex', + 'friends', + 'friendship', + 'best friends', + 'bestfriends', + 'ami', + 'amiga', + 'amigo', + 'king', + 'prince', + 'princess', + 'gender', + 'people', + 'dad', + 'papa', + 'pere', + 'father', + 'parents', + 'adult' + ], + modifiable: true), + Emoji( + name: 'men holding hands: medium-light skin tone', + char: '\u{1F46C}\u{1F3FC}', + shortName: 'men_holding_hands_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.family, + keywords: [ + 'uc12', + 'family', + 'diversity', + 'wedding', + 'gay', + 'men', + 'lgbt', + 'friend', + 'queen', + 'human', + 'daddy', + 'porn', + 'parent', + 'husband', + 'families', + 'group', + 'brother', + 'sister', + 'daughter', + 'son', + 'together', + 'sibling', + 'twins', + 'brothers', + 'sisters', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'weddings', + 'marriage', + 'newlywed', + 'bride', + 'groome', + 'groom', + 'married', + 'marry', + 'twink', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'homosexual', + 'bisex', + 'transgender', + 'non binary', + 'pansexuality', + 'intersex', + 'friends', + 'friendship', + 'best friends', + 'bestfriends', + 'ami', + 'amiga', + 'amigo', + 'king', + 'prince', + 'princess', + 'gender', + 'people', + 'dad', + 'papa', + 'pere', + 'father', + 'parents', + 'adult' + ], + modifiable: true), + Emoji( + name: 'men holding hands: medium-light skin tone, medium skin tone', + char: '\u{1F468}\u{1F3FC}\u{200D}\u{1F91D}\u{200D}\u{1F468}\u{1F3FD}', + shortName: 'men_holding_hands_tone2_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.family, + keywords: [ + 'uc12', + 'family', + 'diversity', + 'wedding', + 'gay', + 'men', + 'lgbt', + 'friend', + 'queen', + 'human', + 'daddy', + 'porn', + 'parent', + 'husband', + 'families', + 'group', + 'brother', + 'sister', + 'daughter', + 'son', + 'together', + 'sibling', + 'twins', + 'brothers', + 'sisters', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'weddings', + 'marriage', + 'newlywed', + 'bride', + 'groome', + 'groom', + 'married', + 'marry', + 'twink', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'homosexual', + 'bisex', + 'transgender', + 'non binary', + 'pansexuality', + 'intersex', + 'friends', + 'friendship', + 'best friends', + 'bestfriends', + 'ami', + 'amiga', + 'amigo', + 'king', + 'prince', + 'princess', + 'gender', + 'people', + 'dad', + 'papa', + 'pere', + 'father', + 'parents', + 'adult' + ], + modifiable: true), + Emoji( + name: 'men holding hands: medium-light skin tone, medium-dark skin tone', + char: '\u{1F468}\u{1F3FC}\u{200D}\u{1F91D}\u{200D}\u{1F468}\u{1F3FE}', + shortName: 'men_holding_hands_tone2_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.family, + keywords: [ + 'uc12', + 'family', + 'diversity', + 'wedding', + 'gay', + 'men', + 'lgbt', + 'friend', + 'queen', + 'human', + 'daddy', + 'porn', + 'parent', + 'husband', + 'families', + 'group', + 'brother', + 'sister', + 'daughter', + 'son', + 'together', + 'sibling', + 'twins', + 'brothers', + 'sisters', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'weddings', + 'marriage', + 'newlywed', + 'bride', + 'groome', + 'groom', + 'married', + 'marry', + 'twink', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'homosexual', + 'bisex', + 'transgender', + 'non binary', + 'pansexuality', + 'intersex', + 'friends', + 'friendship', + 'best friends', + 'bestfriends', + 'ami', + 'amiga', + 'amigo', + 'king', + 'prince', + 'princess', + 'gender', + 'people', + 'dad', + 'papa', + 'pere', + 'father', + 'parents', + 'adult' + ], + modifiable: true), + Emoji( + name: 'men holding hands: medium-light skin tone, dark skin tone', + char: '\u{1F468}\u{1F3FC}\u{200D}\u{1F91D}\u{200D}\u{1F468}\u{1F3FF}', + shortName: 'men_holding_hands_tone2_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.family, + keywords: [ + 'uc12', + 'family', + 'diversity', + 'wedding', + 'gay', + 'men', + 'lgbt', + 'friend', + 'queen', + 'human', + 'daddy', + 'porn', + 'parent', + 'husband', + 'families', + 'group', + 'brother', + 'sister', + 'daughter', + 'son', + 'together', + 'sibling', + 'twins', + 'brothers', + 'sisters', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'weddings', + 'marriage', + 'newlywed', + 'bride', + 'groome', + 'groom', + 'married', + 'marry', + 'twink', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'homosexual', + 'bisex', + 'transgender', + 'non binary', + 'pansexuality', + 'intersex', + 'friends', + 'friendship', + 'best friends', + 'bestfriends', + 'ami', + 'amiga', + 'amigo', + 'king', + 'prince', + 'princess', + 'gender', + 'people', + 'dad', + 'papa', + 'pere', + 'father', + 'parents', + 'adult' + ], + modifiable: true), + Emoji( + name: 'men holding hands: medium skin tone, light skin tone', + char: '\u{1F468}\u{1F3FD}\u{200D}\u{1F91D}\u{200D}\u{1F468}\u{1F3FB}', + shortName: 'men_holding_hands_tone3_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.family, + keywords: [ + 'uc12', + 'family', + 'diversity', + 'wedding', + 'gay', + 'men', + 'lgbt', + 'friend', + 'queen', + 'human', + 'daddy', + 'porn', + 'parent', + 'husband', + 'families', + 'group', + 'brother', + 'sister', + 'daughter', + 'son', + 'together', + 'sibling', + 'twins', + 'brothers', + 'sisters', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'weddings', + 'marriage', + 'newlywed', + 'bride', + 'groome', + 'groom', + 'married', + 'marry', + 'twink', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'homosexual', + 'bisex', + 'transgender', + 'non binary', + 'pansexuality', + 'intersex', + 'friends', + 'friendship', + 'best friends', + 'bestfriends', + 'ami', + 'amiga', + 'amigo', + 'king', + 'prince', + 'princess', + 'gender', + 'people', + 'dad', + 'papa', + 'pere', + 'father', + 'parents', + 'adult' + ], + modifiable: true), + Emoji( + name: 'men holding hands: medium skin tone, medium-light skin tone', + char: '\u{1F468}\u{1F3FD}\u{200D}\u{1F91D}\u{200D}\u{1F468}\u{1F3FC}', + shortName: 'men_holding_hands_tone3_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.family, + keywords: [ + 'uc12', + 'family', + 'diversity', + 'wedding', + 'gay', + 'men', + 'lgbt', + 'friend', + 'queen', + 'human', + 'daddy', + 'porn', + 'parent', + 'husband', + 'families', + 'group', + 'brother', + 'sister', + 'daughter', + 'son', + 'together', + 'sibling', + 'twins', + 'brothers', + 'sisters', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'weddings', + 'marriage', + 'newlywed', + 'bride', + 'groome', + 'groom', + 'married', + 'marry', + 'twink', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'homosexual', + 'bisex', + 'transgender', + 'non binary', + 'pansexuality', + 'intersex', + 'friends', + 'friendship', + 'best friends', + 'bestfriends', + 'ami', + 'amiga', + 'amigo', + 'king', + 'prince', + 'princess', + 'gender', + 'people', + 'dad', + 'papa', + 'pere', + 'father', + 'parents', + 'adult' + ], + modifiable: true), + Emoji( + name: 'men holding hands: medium skin tone', + char: '\u{1F46C}\u{1F3FD}', + shortName: 'men_holding_hands_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.family, + keywords: [ + 'uc12', + 'family', + 'diversity', + 'wedding', + 'gay', + 'men', + 'lgbt', + 'friend', + 'queen', + 'human', + 'daddy', + 'porn', + 'parent', + 'husband', + 'families', + 'group', + 'brother', + 'sister', + 'daughter', + 'son', + 'together', + 'sibling', + 'twins', + 'brothers', + 'sisters', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'weddings', + 'marriage', + 'newlywed', + 'bride', + 'groome', + 'groom', + 'married', + 'marry', + 'twink', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'homosexual', + 'bisex', + 'transgender', + 'non binary', + 'pansexuality', + 'intersex', + 'friends', + 'friendship', + 'best friends', + 'bestfriends', + 'ami', + 'amiga', + 'amigo', + 'king', + 'prince', + 'princess', + 'gender', + 'people', + 'dad', + 'papa', + 'pere', + 'father', + 'parents', + 'adult' + ], + modifiable: true), + Emoji( + name: 'men holding hands: medium skin tone, medium-dark skin tone', + char: '\u{1F468}\u{1F3FD}\u{200D}\u{1F91D}\u{200D}\u{1F468}\u{1F3FE}', + shortName: 'men_holding_hands_tone3_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.family, + keywords: [ + 'uc12', + 'family', + 'diversity', + 'wedding', + 'gay', + 'men', + 'lgbt', + 'friend', + 'queen', + 'human', + 'daddy', + 'porn', + 'parent', + 'husband', + 'families', + 'group', + 'brother', + 'sister', + 'daughter', + 'son', + 'together', + 'sibling', + 'twins', + 'brothers', + 'sisters', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'weddings', + 'marriage', + 'newlywed', + 'bride', + 'groome', + 'groom', + 'married', + 'marry', + 'twink', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'homosexual', + 'bisex', + 'transgender', + 'non binary', + 'pansexuality', + 'intersex', + 'friends', + 'friendship', + 'best friends', + 'bestfriends', + 'ami', + 'amiga', + 'amigo', + 'king', + 'prince', + 'princess', + 'gender', + 'people', + 'dad', + 'papa', + 'pere', + 'father', + 'parents', + 'adult' + ], + modifiable: true), + Emoji( + name: 'men holding hands: medium skin tone, dark skin tone', + char: '\u{1F468}\u{1F3FD}\u{200D}\u{1F91D}\u{200D}\u{1F468}\u{1F3FF}', + shortName: 'men_holding_hands_tone3_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.family, + keywords: [ + 'uc12', + 'family', + 'diversity', + 'wedding', + 'gay', + 'men', + 'lgbt', + 'friend', + 'queen', + 'human', + 'daddy', + 'porn', + 'parent', + 'husband', + 'families', + 'group', + 'brother', + 'sister', + 'daughter', + 'son', + 'together', + 'sibling', + 'twins', + 'brothers', + 'sisters', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'weddings', + 'marriage', + 'newlywed', + 'bride', + 'groome', + 'groom', + 'married', + 'marry', + 'twink', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'homosexual', + 'bisex', + 'transgender', + 'non binary', + 'pansexuality', + 'intersex', + 'friends', + 'friendship', + 'best friends', + 'bestfriends', + 'ami', + 'amiga', + 'amigo', + 'king', + 'prince', + 'princess', + 'gender', + 'people', + 'dad', + 'papa', + 'pere', + 'father', + 'parents', + 'adult' + ], + modifiable: true), + Emoji( + name: 'men holding hands: medium-dark skin tone, light skin tone', + char: '\u{1F468}\u{1F3FE}\u{200D}\u{1F91D}\u{200D}\u{1F468}\u{1F3FB}', + shortName: 'men_holding_hands_tone4_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.family, + keywords: [ + 'uc12', + 'family', + 'diversity', + 'wedding', + 'gay', + 'men', + 'lgbt', + 'friend', + 'queen', + 'human', + 'daddy', + 'porn', + 'parent', + 'husband', + 'families', + 'group', + 'brother', + 'sister', + 'daughter', + 'son', + 'together', + 'sibling', + 'twins', + 'brothers', + 'sisters', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'weddings', + 'marriage', + 'newlywed', + 'bride', + 'groome', + 'groom', + 'married', + 'marry', + 'twink', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'homosexual', + 'bisex', + 'transgender', + 'non binary', + 'pansexuality', + 'intersex', + 'friends', + 'friendship', + 'best friends', + 'bestfriends', + 'ami', + 'amiga', + 'amigo', + 'king', + 'prince', + 'princess', + 'gender', + 'people', + 'dad', + 'papa', + 'pere', + 'father', + 'parents', + 'adult' + ], + modifiable: true), + Emoji( + name: 'men holding hands: medium-dark skin tone, medium-light skin tone', + char: '\u{1F468}\u{1F3FE}\u{200D}\u{1F91D}\u{200D}\u{1F468}\u{1F3FC}', + shortName: 'men_holding_hands_tone4_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.family, + keywords: [ + 'uc12', + 'family', + 'diversity', + 'wedding', + 'gay', + 'men', + 'lgbt', + 'friend', + 'queen', + 'human', + 'daddy', + 'porn', + 'parent', + 'husband', + 'families', + 'group', + 'brother', + 'sister', + 'daughter', + 'son', + 'together', + 'sibling', + 'twins', + 'brothers', + 'sisters', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'weddings', + 'marriage', + 'newlywed', + 'bride', + 'groome', + 'groom', + 'married', + 'marry', + 'twink', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'homosexual', + 'bisex', + 'transgender', + 'non binary', + 'pansexuality', + 'intersex', + 'friends', + 'friendship', + 'best friends', + 'bestfriends', + 'ami', + 'amiga', + 'amigo', + 'king', + 'prince', + 'princess', + 'gender', + 'people', + 'dad', + 'papa', + 'pere', + 'father', + 'parents', + 'adult' + ], + modifiable: true), + Emoji( + name: 'men holding hands: medium-dark skin tone, medium skin tone', + char: '\u{1F468}\u{1F3FE}\u{200D}\u{1F91D}\u{200D}\u{1F468}\u{1F3FD}', + shortName: 'men_holding_hands_tone4_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.family, + keywords: [ + 'uc12', + 'family', + 'diversity', + 'wedding', + 'gay', + 'men', + 'lgbt', + 'friend', + 'queen', + 'human', + 'daddy', + 'porn', + 'parent', + 'husband', + 'families', + 'group', + 'brother', + 'sister', + 'daughter', + 'son', + 'together', + 'sibling', + 'twins', + 'brothers', + 'sisters', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'weddings', + 'marriage', + 'newlywed', + 'bride', + 'groome', + 'groom', + 'married', + 'marry', + 'twink', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'homosexual', + 'bisex', + 'transgender', + 'non binary', + 'pansexuality', + 'intersex', + 'friends', + 'friendship', + 'best friends', + 'bestfriends', + 'ami', + 'amiga', + 'amigo', + 'king', + 'prince', + 'princess', + 'gender', + 'people', + 'dad', + 'papa', + 'pere', + 'father', + 'parents', + 'adult' + ], + modifiable: true), + Emoji( + name: 'men holding hands: medium-dark skin tone', + char: '\u{1F46C}\u{1F3FE}', + shortName: 'men_holding_hands_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.family, + keywords: [ + 'uc12', + 'family', + 'diversity', + 'wedding', + 'gay', + 'men', + 'lgbt', + 'friend', + 'queen', + 'human', + 'daddy', + 'porn', + 'parent', + 'husband', + 'families', + 'group', + 'brother', + 'sister', + 'daughter', + 'son', + 'together', + 'sibling', + 'twins', + 'brothers', + 'sisters', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'weddings', + 'marriage', + 'newlywed', + 'bride', + 'groome', + 'groom', + 'married', + 'marry', + 'twink', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'homosexual', + 'bisex', + 'transgender', + 'non binary', + 'pansexuality', + 'intersex', + 'friends', + 'friendship', + 'best friends', + 'bestfriends', + 'ami', + 'amiga', + 'amigo', + 'king', + 'prince', + 'princess', + 'gender', + 'people', + 'dad', + 'papa', + 'pere', + 'father', + 'parents', + 'adult' + ], + modifiable: true), + Emoji( + name: 'men holding hands: medium-dark skin tone, dark skin tone', + char: '\u{1F468}\u{1F3FE}\u{200D}\u{1F91D}\u{200D}\u{1F468}\u{1F3FF}', + shortName: 'men_holding_hands_tone4_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.family, + keywords: [ + 'uc12', + 'family', + 'diversity', + 'wedding', + 'gay', + 'men', + 'lgbt', + 'friend', + 'queen', + 'human', + 'daddy', + 'porn', + 'parent', + 'husband', + 'families', + 'group', + 'brother', + 'sister', + 'daughter', + 'son', + 'together', + 'sibling', + 'twins', + 'brothers', + 'sisters', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'weddings', + 'marriage', + 'newlywed', + 'bride', + 'groome', + 'groom', + 'married', + 'marry', + 'twink', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'homosexual', + 'bisex', + 'transgender', + 'non binary', + 'pansexuality', + 'intersex', + 'friends', + 'friendship', + 'best friends', + 'bestfriends', + 'ami', + 'amiga', + 'amigo', + 'king', + 'prince', + 'princess', + 'gender', + 'people', + 'dad', + 'papa', + 'pere', + 'father', + 'parents', + 'adult' + ], + modifiable: true), + Emoji( + name: 'men holding hands: dark skin tone, light skin tone', + char: '\u{1F468}\u{1F3FF}\u{200D}\u{1F91D}\u{200D}\u{1F468}\u{1F3FB}', + shortName: 'men_holding_hands_tone5_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.family, + keywords: [ + 'uc12', + 'family', + 'diversity', + 'wedding', + 'gay', + 'men', + 'lgbt', + 'friend', + 'queen', + 'human', + 'daddy', + 'porn', + 'parent', + 'husband', + 'families', + 'group', + 'brother', + 'sister', + 'daughter', + 'son', + 'together', + 'sibling', + 'twins', + 'brothers', + 'sisters', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'weddings', + 'marriage', + 'newlywed', + 'bride', + 'groome', + 'groom', + 'married', + 'marry', + 'twink', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'homosexual', + 'bisex', + 'transgender', + 'non binary', + 'pansexuality', + 'intersex', + 'friends', + 'friendship', + 'best friends', + 'bestfriends', + 'ami', + 'amiga', + 'amigo', + 'king', + 'prince', + 'princess', + 'gender', + 'people', + 'dad', + 'papa', + 'pere', + 'father', + 'parents', + 'adult' + ], + modifiable: true), + Emoji( + name: 'men holding hands: dark skin tone, medium-light skin tone', + char: '\u{1F468}\u{1F3FF}\u{200D}\u{1F91D}\u{200D}\u{1F468}\u{1F3FC}', + shortName: 'men_holding_hands_tone5_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.family, + keywords: [ + 'uc12', + 'family', + 'diversity', + 'wedding', + 'gay', + 'men', + 'lgbt', + 'friend', + 'queen', + 'human', + 'daddy', + 'porn', + 'parent', + 'husband', + 'families', + 'group', + 'brother', + 'sister', + 'daughter', + 'son', + 'together', + 'sibling', + 'twins', + 'brothers', + 'sisters', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'weddings', + 'marriage', + 'newlywed', + 'bride', + 'groome', + 'groom', + 'married', + 'marry', + 'twink', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'homosexual', + 'bisex', + 'transgender', + 'non binary', + 'pansexuality', + 'intersex', + 'friends', + 'friendship', + 'best friends', + 'bestfriends', + 'ami', + 'amiga', + 'amigo', + 'king', + 'prince', + 'princess', + 'gender', + 'people', + 'dad', + 'papa', + 'pere', + 'father', + 'parents', + 'adult' + ], + modifiable: true), + Emoji( + name: 'men holding hands: dark skin tone, medium skin tone', + char: '\u{1F468}\u{1F3FF}\u{200D}\u{1F91D}\u{200D}\u{1F468}\u{1F3FD}', + shortName: 'men_holding_hands_tone5_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.family, + keywords: [ + 'uc12', + 'family', + 'diversity', + 'wedding', + 'gay', + 'men', + 'lgbt', + 'friend', + 'queen', + 'human', + 'daddy', + 'porn', + 'parent', + 'husband', + 'families', + 'group', + 'brother', + 'sister', + 'daughter', + 'son', + 'together', + 'sibling', + 'twins', + 'brothers', + 'sisters', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'weddings', + 'marriage', + 'newlywed', + 'bride', + 'groome', + 'groom', + 'married', + 'marry', + 'twink', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'homosexual', + 'bisex', + 'transgender', + 'non binary', + 'pansexuality', + 'intersex', + 'friends', + 'friendship', + 'best friends', + 'bestfriends', + 'ami', + 'amiga', + 'amigo', + 'king', + 'prince', + 'princess', + 'gender', + 'people', + 'dad', + 'papa', + 'pere', + 'father', + 'parents', + 'adult' + ], + modifiable: true), + Emoji( + name: 'men holding hands: dark skin tone, medium-dark skin tone', + char: '\u{1F468}\u{1F3FF}\u{200D}\u{1F91D}\u{200D}\u{1F468}\u{1F3FE}', + shortName: 'men_holding_hands_tone5_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.family, + keywords: [ + 'uc12', + 'family', + 'diversity', + 'wedding', + 'gay', + 'men', + 'lgbt', + 'friend', + 'queen', + 'human', + 'daddy', + 'porn', + 'parent', + 'husband', + 'families', + 'group', + 'brother', + 'sister', + 'daughter', + 'son', + 'together', + 'sibling', + 'twins', + 'brothers', + 'sisters', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'weddings', + 'marriage', + 'newlywed', + 'bride', + 'groome', + 'groom', + 'married', + 'marry', + 'twink', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'homosexual', + 'bisex', + 'transgender', + 'non binary', + 'pansexuality', + 'intersex', + 'friends', + 'friendship', + 'best friends', + 'bestfriends', + 'ami', + 'amiga', + 'amigo', + 'king', + 'prince', + 'princess', + 'gender', + 'people', + 'dad', + 'papa', + 'pere', + 'father', + 'parents', + 'adult' + ], + modifiable: true), + Emoji( + name: 'men holding hands: dark skin tone', + char: '\u{1F46C}\u{1F3FF}', + shortName: 'men_holding_hands_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.family, + keywords: [ + 'uc12', + 'family', + 'diversity', + 'wedding', + 'gay', + 'men', + 'lgbt', + 'friend', + 'queen', + 'human', + 'daddy', + 'porn', + 'parent', + 'husband', + 'families', + 'group', + 'brother', + 'sister', + 'daughter', + 'son', + 'together', + 'sibling', + 'twins', + 'brothers', + 'sisters', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'weddings', + 'marriage', + 'newlywed', + 'bride', + 'groome', + 'groom', + 'married', + 'marry', + 'twink', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'homosexual', + 'bisex', + 'transgender', + 'non binary', + 'pansexuality', + 'intersex', + 'friends', + 'friendship', + 'best friends', + 'bestfriends', + 'ami', + 'amiga', + 'amigo', + 'king', + 'prince', + 'princess', + 'gender', + 'people', + 'dad', + 'papa', + 'pere', + 'father', + 'parents', + 'adult' + ], + modifiable: true), + Emoji( + name: 'couple with heart', + char: '\u{1F491}', + shortName: 'couple_with_heart', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.family, + keywords: [ + 'couple', + 'love', + 'uc6', + 'wedding', + 'lesbian', + 'gay', + 'men', + 'love', + 'sex', + 'lgbt', + 'pink', + 'husband', + 'weddings', + 'marriage', + 'newlywed', + 'bride', + 'groome', + 'groom', + 'married', + 'marry', + 'twink', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'i love you', + 'te amo', + "je t'aime", + 'anniversary', + 'lovin', + 'amour', + 'aimer', + 'amor', + 'valentines day', + 'enamour', + 'lovey', + 'fuck', + 'fucking', + 'horny', + 'humping', + 'homosexual', + 'bisex', + 'transgender', + 'non binary', + 'pansexuality', + 'intersex', + 'rose' + ]), + Emoji( + name: 'couple with heart: woman, man', + char: '\u{1F469}\u{200D}\u{2764}\u{FE0F}\u{200D}\u{1F468}', + shortName: 'couple_with_heart_woman_man', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.family, + keywords: [ + 'couple', + 'love', + 'man', + 'woman', + 'uc6', + 'wedding', + 'love', + 'sex', + 'husband', + 'weddings', + 'marriage', + 'newlywed', + 'bride', + 'groome', + 'groom', + 'married', + 'marry', + 'i love you', + 'te amo', + "je t'aime", + 'anniversary', + 'lovin', + 'amour', + 'aimer', + 'amor', + 'valentines day', + 'enamour', + 'lovey', + 'fuck', + 'fucking', + 'horny', + 'humping' + ]), + Emoji( + name: 'couple with heart: woman, woman', + char: '\u{1F469}\u{200D}\u{2764}\u{FE0F}\u{200D}\u{1F469}', + shortName: 'couple_ww', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.family, + keywords: [ + 'couple', + 'love', + 'woman', + 'uc6', + 'wedding', + 'lesbian', + 'gay', + 'women', + 'love', + 'sex', + 'lgbt', + 'pink', + 'weddings', + 'marriage', + 'newlywed', + 'bride', + 'groome', + 'groom', + 'married', + 'marry', + 'twink', + 'woman', + 'female', + 'i love you', + 'te amo', + "je t'aime", + 'anniversary', + 'lovin', + 'amour', + 'aimer', + 'amor', + 'valentines day', + 'enamour', + 'lovey', + 'fuck', + 'fucking', + 'horny', + 'humping', + 'homosexual', + 'bisex', + 'transgender', + 'non binary', + 'pansexuality', + 'intersex', + 'rose' + ]), + Emoji( + name: 'couple with heart: man, man', + char: '\u{1F468}\u{200D}\u{2764}\u{FE0F}\u{200D}\u{1F468}', + shortName: 'couple_mm', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.family, + keywords: [ + 'couple', + 'love', + 'man', + 'uc6', + 'wedding', + 'gay', + 'men', + 'love', + 'sex', + 'lgbt', + 'pink', + 'husband', + 'weddings', + 'marriage', + 'newlywed', + 'bride', + 'groome', + 'groom', + 'married', + 'marry', + 'twink', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'i love you', + 'te amo', + "je t'aime", + 'anniversary', + 'lovin', + 'amour', + 'aimer', + 'amor', + 'valentines day', + 'enamour', + 'lovey', + 'fuck', + 'fucking', + 'horny', + 'humping', + 'homosexual', + 'bisex', + 'transgender', + 'non binary', + 'pansexuality', + 'intersex', + 'rose' + ]), + Emoji( + name: 'kiss', + char: '\u{1F48F}', + shortName: 'couplekiss', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.family, + keywords: [ + 'couple', + 'uc6', + 'wedding', + 'lesbian', + 'gay', + 'men', + 'love', + 'sex', + 'hug', + 'lgbt', + 'pink', + 'kisses', + 'husband', + 'weddings', + 'marriage', + 'newlywed', + 'bride', + 'groome', + 'groom', + 'married', + 'marry', + 'twink', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'i love you', + 'te amo', + "je t'aime", + 'anniversary', + 'lovin', + 'amour', + 'aimer', + 'amor', + 'valentines day', + 'enamour', + 'lovey', + 'fuck', + 'fucking', + 'horny', + 'humping', + 'embrace', + 'hugs', + 'homosexual', + 'bisex', + 'transgender', + 'non binary', + 'pansexuality', + 'intersex', + 'rose', + 'bisous', + 'beijos', + 'besos', + 'bise', + 'blowing kisses', + 'kissy' + ]), + Emoji( + name: 'kiss: woman, man', + char: + '\u{1F469}\u{200D}\u{2764}\u{FE0F}\u{200D}\u{1F48B}\u{200D}\u{1F468}', + shortName: 'kiss_woman_man', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.family, + keywords: [ + 'couple', + 'man', + 'woman', + 'uc6', + 'wedding', + 'love', + 'sex', + 'hug', + 'kisses', + 'husband', + 'weddings', + 'marriage', + 'newlywed', + 'bride', + 'groome', + 'groom', + 'married', + 'marry', + 'i love you', + 'te amo', + "je t'aime", + 'anniversary', + 'lovin', + 'amour', + 'aimer', + 'amor', + 'valentines day', + 'enamour', + 'lovey', + 'fuck', + 'fucking', + 'horny', + 'humping', + 'embrace', + 'hugs', + 'bisous', + 'beijos', + 'besos', + 'bise', + 'blowing kisses', + 'kissy' + ]), + Emoji( + name: 'kiss: woman, woman', + char: + '\u{1F469}\u{200D}\u{2764}\u{FE0F}\u{200D}\u{1F48B}\u{200D}\u{1F469}', + shortName: 'kiss_ww', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.family, + keywords: [ + 'couple', + 'woman', + 'uc6', + 'wedding', + 'lesbian', + 'gay', + 'women', + 'love', + 'sex', + 'hug', + 'lgbt', + 'pink', + 'kisses', + 'weddings', + 'marriage', + 'newlywed', + 'bride', + 'groome', + 'groom', + 'married', + 'marry', + 'twink', + 'woman', + 'female', + 'i love you', + 'te amo', + "je t'aime", + 'anniversary', + 'lovin', + 'amour', + 'aimer', + 'amor', + 'valentines day', + 'enamour', + 'lovey', + 'fuck', + 'fucking', + 'horny', + 'humping', + 'embrace', + 'hugs', + 'homosexual', + 'bisex', + 'transgender', + 'non binary', + 'pansexuality', + 'intersex', + 'rose', + 'bisous', + 'beijos', + 'besos', + 'bise', + 'blowing kisses', + 'kissy' + ]), + Emoji( + name: 'kiss: man, man', + char: + '\u{1F468}\u{200D}\u{2764}\u{FE0F}\u{200D}\u{1F48B}\u{200D}\u{1F468}', + shortName: 'kiss_mm', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.family, + keywords: [ + 'couple', + 'man', + 'uc6', + 'wedding', + 'gay', + 'men', + 'love', + 'sex', + 'hug', + 'lgbt', + 'pink', + 'kisses', + 'husband', + 'weddings', + 'marriage', + 'newlywed', + 'bride', + 'groome', + 'groom', + 'married', + 'marry', + 'twink', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'i love you', + 'te amo', + "je t'aime", + 'anniversary', + 'lovin', + 'amour', + 'aimer', + 'amor', + 'valentines day', + 'enamour', + 'lovey', + 'fuck', + 'fucking', + 'horny', + 'humping', + 'embrace', + 'hugs', + 'homosexual', + 'bisex', + 'transgender', + 'non binary', + 'pansexuality', + 'intersex', + 'rose', + 'bisous', + 'beijos', + 'besos', + 'bise', + 'blowing kisses', + 'kissy' + ]), + Emoji( + name: 'family', + char: '\u{1F46A}', + shortName: 'family', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.family, + keywords: [ + 'family', + 'uc6', + 'family', + 'lesbian', + 'gay', + 'men', + 'christmas', + 'lgbt', + 'human', + 'daddy', + 'parent', + 'wife', + 'husband', + 'child', + 'mom', + 'families', + 'group', + 'brother', + 'sister', + 'daughter', + 'son', + 'together', + 'sibling', + 'twins', + 'brothers', + 'sisters', + 'twink', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'navidad', + 'xmas', + 'noel', + 'merry christmas', + 'homosexual', + 'bisex', + 'transgender', + 'non binary', + 'pansexuality', + 'intersex', + 'gender', + 'people', + 'dad', + 'papa', + 'pere', + 'father', + 'parents', + 'adult', + 'children', + 'girl', + 'boy', + 'kids', + 'niño', + 'enfant', + 'maman', + 'mommy', + 'mama', + 'mother' + ]), + Emoji( + name: 'family: man, woman, boy', + char: '\u{1F468}\u{200D}\u{1F469}\u{200D}\u{1F466}', + shortName: 'family_man_woman_boy', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.family, + keywords: [ + 'boy', + 'family', + 'man', + 'woman', + 'uc6', + 'family', + 'human', + 'daddy', + 'parent', + 'wife', + 'husband', + 'child', + 'mom', + 'families', + 'group', + 'brother', + 'sister', + 'daughter', + 'son', + 'together', + 'sibling', + 'twins', + 'brothers', + 'sisters', + 'gender', + 'people', + 'dad', + 'papa', + 'pere', + 'father', + 'parents', + 'adult', + 'children', + 'girl', + 'boy', + 'kids', + 'niño', + 'enfant', + 'maman', + 'mommy', + 'mama', + 'mother' + ]), + Emoji( + name: 'family: man, woman, girl', + char: '\u{1F468}\u{200D}\u{1F469}\u{200D}\u{1F467}', + shortName: 'family_mwg', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.family, + keywords: [ + 'family', + 'girl', + 'man', + 'woman', + 'uc6', + 'family', + 'human', + 'daddy', + 'parent', + 'wife', + 'husband', + 'child', + 'mom', + 'families', + 'group', + 'brother', + 'sister', + 'daughter', + 'son', + 'together', + 'sibling', + 'twins', + 'brothers', + 'sisters', + 'gender', + 'people', + 'dad', + 'papa', + 'pere', + 'father', + 'parents', + 'adult', + 'children', + 'girl', + 'boy', + 'kids', + 'niño', + 'enfant', + 'maman', + 'mommy', + 'mama', + 'mother' + ]), + Emoji( + name: 'family: man, woman, girl, boy', + char: '\u{1F468}\u{200D}\u{1F469}\u{200D}\u{1F467}\u{200D}\u{1F466}', + shortName: 'family_mwgb', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.family, + keywords: [ + 'boy', + 'family', + 'girl', + 'man', + 'woman', + 'uc6', + 'family', + 'human', + 'daddy', + 'parent', + 'wife', + 'husband', + 'child', + 'mom', + 'families', + 'group', + 'brother', + 'sister', + 'daughter', + 'son', + 'together', + 'sibling', + 'twins', + 'brothers', + 'sisters', + 'gender', + 'people', + 'dad', + 'papa', + 'pere', + 'father', + 'parents', + 'adult', + 'children', + 'girl', + 'boy', + 'kids', + 'niño', + 'enfant', + 'maman', + 'mommy', + 'mama', + 'mother' + ]), + Emoji( + name: 'family: man, woman, boy, boy', + char: '\u{1F468}\u{200D}\u{1F469}\u{200D}\u{1F466}\u{200D}\u{1F466}', + shortName: 'family_mwbb', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.family, + keywords: [ + 'boy', + 'family', + 'man', + 'woman', + 'uc6', + 'family', + 'human', + 'daddy', + 'parent', + 'wife', + 'husband', + 'child', + 'mom', + 'families', + 'group', + 'brother', + 'sister', + 'daughter', + 'son', + 'together', + 'sibling', + 'twins', + 'brothers', + 'sisters', + 'gender', + 'people', + 'dad', + 'papa', + 'pere', + 'father', + 'parents', + 'adult', + 'children', + 'girl', + 'boy', + 'kids', + 'niño', + 'enfant', + 'maman', + 'mommy', + 'mama', + 'mother' + ]), + Emoji( + name: 'family: man, woman, girl, girl', + char: '\u{1F468}\u{200D}\u{1F469}\u{200D}\u{1F467}\u{200D}\u{1F467}', + shortName: 'family_mwgg', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.family, + keywords: [ + 'family', + 'girl', + 'man', + 'woman', + 'uc6', + 'family', + 'human', + 'daddy', + 'parent', + 'wife', + 'husband', + 'child', + 'mom', + 'families', + 'group', + 'brother', + 'sister', + 'daughter', + 'son', + 'together', + 'sibling', + 'twins', + 'brothers', + 'sisters', + 'gender', + 'people', + 'dad', + 'papa', + 'pere', + 'father', + 'parents', + 'adult', + 'children', + 'girl', + 'boy', + 'kids', + 'niño', + 'enfant', + 'maman', + 'mommy', + 'mama', + 'mother' + ]), + Emoji( + name: 'family: woman, woman, boy', + char: '\u{1F469}\u{200D}\u{1F469}\u{200D}\u{1F466}', + shortName: 'family_wwb', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.family, + keywords: [ + 'boy', + 'family', + 'woman', + 'uc6', + 'family', + 'lesbian', + 'gay', + 'women', + 'lgbt', + 'human', + 'parent', + 'wife', + 'child', + 'mom', + 'families', + 'group', + 'brother', + 'sister', + 'daughter', + 'son', + 'together', + 'sibling', + 'twins', + 'brothers', + 'sisters', + 'twink', + 'woman', + 'female', + 'homosexual', + 'bisex', + 'transgender', + 'non binary', + 'pansexuality', + 'intersex', + 'gender', + 'people', + 'parents', + 'adult', + 'children', + 'girl', + 'boy', + 'kids', + 'niño', + 'enfant', + 'maman', + 'mommy', + 'mama', + 'mother' + ]), + Emoji( + name: 'family: woman, woman, girl', + char: '\u{1F469}\u{200D}\u{1F469}\u{200D}\u{1F467}', + shortName: 'family_wwg', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.family, + keywords: [ + 'family', + 'girl', + 'woman', + 'uc6', + 'family', + 'lesbian', + 'gay', + 'women', + 'lgbt', + 'human', + 'parent', + 'wife', + 'child', + 'mom', + 'families', + 'group', + 'brother', + 'sister', + 'daughter', + 'son', + 'together', + 'sibling', + 'twins', + 'brothers', + 'sisters', + 'twink', + 'woman', + 'female', + 'homosexual', + 'bisex', + 'transgender', + 'non binary', + 'pansexuality', + 'intersex', + 'gender', + 'people', + 'parents', + 'adult', + 'children', + 'girl', + 'boy', + 'kids', + 'niño', + 'enfant', + 'maman', + 'mommy', + 'mama', + 'mother' + ]), + Emoji( + name: 'family: woman, woman, girl, boy', + char: '\u{1F469}\u{200D}\u{1F469}\u{200D}\u{1F467}\u{200D}\u{1F466}', + shortName: 'family_wwgb', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.family, + keywords: [ + 'boy', + 'family', + 'girl', + 'woman', + 'uc6', + 'family', + 'lesbian', + 'gay', + 'women', + 'lgbt', + 'human', + 'parent', + 'wife', + 'child', + 'mom', + 'families', + 'group', + 'brother', + 'sister', + 'daughter', + 'son', + 'together', + 'sibling', + 'twins', + 'brothers', + 'sisters', + 'twink', + 'woman', + 'female', + 'homosexual', + 'bisex', + 'transgender', + 'non binary', + 'pansexuality', + 'intersex', + 'gender', + 'people', + 'parents', + 'adult', + 'children', + 'girl', + 'boy', + 'kids', + 'niño', + 'enfant', + 'maman', + 'mommy', + 'mama', + 'mother' + ]), + Emoji( + name: 'family: woman, woman, boy, boy', + char: '\u{1F469}\u{200D}\u{1F469}\u{200D}\u{1F466}\u{200D}\u{1F466}', + shortName: 'family_wwbb', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.family, + keywords: [ + 'boy', + 'family', + 'woman', + 'uc6', + 'family', + 'lesbian', + 'gay', + 'women', + 'lgbt', + 'human', + 'parent', + 'wife', + 'child', + 'mom', + 'families', + 'group', + 'brother', + 'sister', + 'daughter', + 'son', + 'together', + 'sibling', + 'twins', + 'brothers', + 'sisters', + 'twink', + 'woman', + 'female', + 'homosexual', + 'bisex', + 'transgender', + 'non binary', + 'pansexuality', + 'intersex', + 'gender', + 'people', + 'parents', + 'adult', + 'children', + 'girl', + 'boy', + 'kids', + 'niño', + 'enfant', + 'maman', + 'mommy', + 'mama', + 'mother' + ]), + Emoji( + name: 'family: woman, woman, girl, girl', + char: '\u{1F469}\u{200D}\u{1F469}\u{200D}\u{1F467}\u{200D}\u{1F467}', + shortName: 'family_wwgg', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.family, + keywords: [ + 'family', + 'girl', + 'woman', + 'uc6', + 'family', + 'lesbian', + 'gay', + 'women', + 'lgbt', + 'human', + 'parent', + 'wife', + 'child', + 'mom', + 'families', + 'group', + 'brother', + 'sister', + 'daughter', + 'son', + 'together', + 'sibling', + 'twins', + 'brothers', + 'sisters', + 'twink', + 'woman', + 'female', + 'homosexual', + 'bisex', + 'transgender', + 'non binary', + 'pansexuality', + 'intersex', + 'gender', + 'people', + 'parents', + 'adult', + 'children', + 'girl', + 'boy', + 'kids', + 'niño', + 'enfant', + 'maman', + 'mommy', + 'mama', + 'mother' + ]), + Emoji( + name: 'family: man, man, boy', + char: '\u{1F468}\u{200D}\u{1F468}\u{200D}\u{1F466}', + shortName: 'family_mmb', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.family, + keywords: [ + 'boy', + 'family', + 'man', + 'uc6', + 'family', + 'gay', + 'men', + 'lgbt', + 'human', + 'daddy', + 'parent', + 'husband', + 'child', + 'families', + 'group', + 'brother', + 'sister', + 'daughter', + 'son', + 'together', + 'sibling', + 'twins', + 'brothers', + 'sisters', + 'twink', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'homosexual', + 'bisex', + 'transgender', + 'non binary', + 'pansexuality', + 'intersex', + 'gender', + 'people', + 'dad', + 'papa', + 'pere', + 'father', + 'parents', + 'adult', + 'children', + 'girl', + 'boy', + 'kids', + 'niño', + 'enfant' + ]), + Emoji( + name: 'family: man, man, girl', + char: '\u{1F468}\u{200D}\u{1F468}\u{200D}\u{1F467}', + shortName: 'family_mmg', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.family, + keywords: [ + 'family', + 'girl', + 'man', + 'uc6', + 'family', + 'gay', + 'men', + 'lgbt', + 'human', + 'daddy', + 'parent', + 'husband', + 'child', + 'families', + 'group', + 'brother', + 'sister', + 'daughter', + 'son', + 'together', + 'sibling', + 'twins', + 'brothers', + 'sisters', + 'twink', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'homosexual', + 'bisex', + 'transgender', + 'non binary', + 'pansexuality', + 'intersex', + 'gender', + 'people', + 'dad', + 'papa', + 'pere', + 'father', + 'parents', + 'adult', + 'children', + 'girl', + 'boy', + 'kids', + 'niño', + 'enfant' + ]), + Emoji( + name: 'family: man, man, girl, boy', + char: '\u{1F468}\u{200D}\u{1F468}\u{200D}\u{1F467}\u{200D}\u{1F466}', + shortName: 'family_mmgb', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.family, + keywords: [ + 'boy', + 'family', + 'girl', + 'man', + 'uc6', + 'family', + 'gay', + 'men', + 'lgbt', + 'human', + 'daddy', + 'parent', + 'husband', + 'child', + 'families', + 'group', + 'brother', + 'sister', + 'daughter', + 'son', + 'together', + 'sibling', + 'twins', + 'brothers', + 'sisters', + 'twink', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'homosexual', + 'bisex', + 'transgender', + 'non binary', + 'pansexuality', + 'intersex', + 'gender', + 'people', + 'dad', + 'papa', + 'pere', + 'father', + 'parents', + 'adult', + 'children', + 'girl', + 'boy', + 'kids', + 'niño', + 'enfant' + ]), + Emoji( + name: 'family: man, man, boy, boy', + char: '\u{1F468}\u{200D}\u{1F468}\u{200D}\u{1F466}\u{200D}\u{1F466}', + shortName: 'family_mmbb', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.family, + keywords: [ + 'boy', + 'family', + 'man', + 'uc6', + 'family', + 'gay', + 'men', + 'lgbt', + 'human', + 'daddy', + 'parent', + 'husband', + 'child', + 'families', + 'group', + 'brother', + 'sister', + 'daughter', + 'son', + 'together', + 'sibling', + 'twins', + 'brothers', + 'sisters', + 'twink', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'homosexual', + 'bisex', + 'transgender', + 'non binary', + 'pansexuality', + 'intersex', + 'gender', + 'people', + 'dad', + 'papa', + 'pere', + 'father', + 'parents', + 'adult', + 'children', + 'girl', + 'boy', + 'kids', + 'niño', + 'enfant' + ]), + Emoji( + name: 'family: man, man, girl, girl', + char: '\u{1F468}\u{200D}\u{1F468}\u{200D}\u{1F467}\u{200D}\u{1F467}', + shortName: 'family_mmgg', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.family, + keywords: [ + 'family', + 'girl', + 'man', + 'uc6', + 'family', + 'gay', + 'men', + 'lgbt', + 'human', + 'daddy', + 'parent', + 'husband', + 'child', + 'families', + 'group', + 'brother', + 'sister', + 'daughter', + 'son', + 'together', + 'sibling', + 'twins', + 'brothers', + 'sisters', + 'twink', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'homosexual', + 'bisex', + 'transgender', + 'non binary', + 'pansexuality', + 'intersex', + 'gender', + 'people', + 'dad', + 'papa', + 'pere', + 'father', + 'parents', + 'adult', + 'children', + 'girl', + 'boy', + 'kids', + 'niño', + 'enfant' + ]), + Emoji( + name: 'family: woman, boy', + char: '\u{1F469}\u{200D}\u{1F466}', + shortName: 'family_woman_boy', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.family, + keywords: [ + 'boy', + 'family', + 'woman', + 'uc6', + 'family', + 'women', + 'human', + 'parent', + 'wife', + 'child', + 'mom', + 'families', + 'group', + 'brother', + 'sister', + 'daughter', + 'son', + 'together', + 'sibling', + 'twins', + 'brothers', + 'sisters', + 'woman', + 'female', + 'gender', + 'people', + 'parents', + 'adult', + 'children', + 'girl', + 'boy', + 'kids', + 'niño', + 'enfant', + 'maman', + 'mommy', + 'mama', + 'mother' + ]), + Emoji( + name: 'family: woman, girl', + char: '\u{1F469}\u{200D}\u{1F467}', + shortName: 'family_woman_girl', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.family, + keywords: [ + 'family', + 'girl', + 'woman', + 'uc6', + 'family', + 'women', + 'human', + 'parent', + 'wife', + 'child', + 'mom', + 'families', + 'group', + 'brother', + 'sister', + 'daughter', + 'son', + 'together', + 'sibling', + 'twins', + 'brothers', + 'sisters', + 'woman', + 'female', + 'gender', + 'people', + 'parents', + 'adult', + 'children', + 'girl', + 'boy', + 'kids', + 'niño', + 'enfant', + 'maman', + 'mommy', + 'mama', + 'mother' + ]), + Emoji( + name: 'family: woman, girl, boy', + char: '\u{1F469}\u{200D}\u{1F467}\u{200D}\u{1F466}', + shortName: 'family_woman_girl_boy', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.family, + keywords: [ + 'boy', + 'family', + 'girl', + 'woman', + 'uc6', + 'family', + 'women', + 'human', + 'parent', + 'wife', + 'child', + 'mom', + 'families', + 'group', + 'brother', + 'sister', + 'daughter', + 'son', + 'together', + 'sibling', + 'twins', + 'brothers', + 'sisters', + 'woman', + 'female', + 'gender', + 'people', + 'parents', + 'adult', + 'children', + 'girl', + 'boy', + 'kids', + 'niño', + 'enfant', + 'maman', + 'mommy', + 'mama', + 'mother' + ]), + Emoji( + name: 'family: woman, boy, boy', + char: '\u{1F469}\u{200D}\u{1F466}\u{200D}\u{1F466}', + shortName: 'family_woman_boy_boy', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.family, + keywords: [ + 'boy', + 'family', + 'woman', + 'uc6', + 'family', + 'women', + 'human', + 'parent', + 'wife', + 'child', + 'mom', + 'families', + 'group', + 'brother', + 'sister', + 'daughter', + 'son', + 'together', + 'sibling', + 'twins', + 'brothers', + 'sisters', + 'woman', + 'female', + 'gender', + 'people', + 'parents', + 'adult', + 'children', + 'girl', + 'boy', + 'kids', + 'niño', + 'enfant', + 'maman', + 'mommy', + 'mama', + 'mother' + ]), + Emoji( + name: 'family: woman, girl, girl', + char: '\u{1F469}\u{200D}\u{1F467}\u{200D}\u{1F467}', + shortName: 'family_woman_girl_girl', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.family, + keywords: [ + 'family', + 'girl', + 'woman', + 'uc6', + 'family', + 'women', + 'human', + 'parent', + 'wife', + 'child', + 'mom', + 'families', + 'group', + 'brother', + 'sister', + 'daughter', + 'son', + 'together', + 'sibling', + 'twins', + 'brothers', + 'sisters', + 'woman', + 'female', + 'gender', + 'people', + 'parents', + 'adult', + 'children', + 'girl', + 'boy', + 'kids', + 'niño', + 'enfant', + 'maman', + 'mommy', + 'mama', + 'mother' + ]), + Emoji( + name: 'family: man, boy', + char: '\u{1F468}\u{200D}\u{1F466}', + shortName: 'family_man_boy', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.family, + keywords: [ + 'boy', + 'family', + 'man', + 'uc6', + 'family', + 'men', + 'human', + 'daddy', + 'parent', + 'husband', + 'child', + 'families', + 'group', + 'brother', + 'sister', + 'daughter', + 'son', + 'together', + 'sibling', + 'twins', + 'brothers', + 'sisters', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'gender', + 'people', + 'dad', + 'papa', + 'pere', + 'father', + 'parents', + 'adult', + 'children', + 'girl', + 'boy', + 'kids', + 'niño', + 'enfant' + ]), + Emoji( + name: 'family: man, girl', + char: '\u{1F468}\u{200D}\u{1F467}', + shortName: 'family_man_girl', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.family, + keywords: [ + 'family', + 'girl', + 'man', + 'uc6', + 'family', + 'men', + 'human', + 'daddy', + 'parent', + 'husband', + 'child', + 'families', + 'group', + 'brother', + 'sister', + 'daughter', + 'son', + 'together', + 'sibling', + 'twins', + 'brothers', + 'sisters', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'gender', + 'people', + 'dad', + 'papa', + 'pere', + 'father', + 'parents', + 'adult', + 'children', + 'girl', + 'boy', + 'kids', + 'niño', + 'enfant' + ]), + Emoji( + name: 'family: man, girl, boy', + char: '\u{1F468}\u{200D}\u{1F467}\u{200D}\u{1F466}', + shortName: 'family_man_girl_boy', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.family, + keywords: [ + 'boy', + 'family', + 'girl', + 'man', + 'uc6', + 'family', + 'men', + 'human', + 'daddy', + 'parent', + 'husband', + 'child', + 'families', + 'group', + 'brother', + 'sister', + 'daughter', + 'son', + 'together', + 'sibling', + 'twins', + 'brothers', + 'sisters', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'gender', + 'people', + 'dad', + 'papa', + 'pere', + 'father', + 'parents', + 'adult', + 'children', + 'girl', + 'boy', + 'kids', + 'niño', + 'enfant' + ]), + Emoji( + name: 'family: man, boy, boy', + char: '\u{1F468}\u{200D}\u{1F466}\u{200D}\u{1F466}', + shortName: 'family_man_boy_boy', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.family, + keywords: [ + 'boy', + 'family', + 'man', + 'uc6', + 'family', + 'men', + 'human', + 'daddy', + 'parent', + 'husband', + 'child', + 'families', + 'group', + 'brother', + 'sister', + 'daughter', + 'son', + 'together', + 'sibling', + 'twins', + 'brothers', + 'sisters', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'gender', + 'people', + 'dad', + 'papa', + 'pere', + 'father', + 'parents', + 'adult', + 'children', + 'girl', + 'boy', + 'kids', + 'niño', + 'enfant' + ]), + Emoji( + name: 'family: man, girl, girl', + char: '\u{1F468}\u{200D}\u{1F467}\u{200D}\u{1F467}', + shortName: 'family_man_girl_girl', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.family, + keywords: [ + 'family', + 'girl', + 'man', + 'uc6', + 'family', + 'men', + 'human', + 'daddy', + 'parent', + 'husband', + 'child', + 'families', + 'group', + 'brother', + 'sister', + 'daughter', + 'son', + 'together', + 'sibling', + 'twins', + 'brothers', + 'sisters', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'gender', + 'people', + 'dad', + 'papa', + 'pere', + 'father', + 'parents', + 'adult', + 'children', + 'girl', + 'boy', + 'kids', + 'niño', + 'enfant' + ]), + Emoji( + name: 'yarn', + char: '\u{1F9F6}', + shortName: 'yarn', + emojiGroup: EmojiGroup.activities, + emojiSubgroup: EmojiSubgroup.artsCrafts, + keywords: [ + 'uc11', + 'cat', + 'household', + 'sew', + 'kitty', + 'kitten', + 'cats', + 'kittens', + 'kitties', + 'feline', + 'felines', + 'cat face', + 'gato', + 'meow', + 'knit', + 'embroider', + 'stitch', + 'repair', + 'crochet', + 'alter', + 'seamstress', + 'fix' + ]), + Emoji( + name: 'thread', + char: '\u{1F9F5}', + shortName: 'thread', + emojiGroup: EmojiGroup.activities, + emojiSubgroup: EmojiSubgroup.artsCrafts, + keywords: [ + 'uc11', + 'household', + 'sew', + 'knit', + 'embroider', + 'stitch', + 'repair', + 'crochet', + 'alter', + 'seamstress', + 'fix' + ]), + Emoji( + name: 'coat', + char: '\u{1F9E5}', + shortName: 'coat', + emojiGroup: EmojiGroup.objects, + emojiSubgroup: EmojiSubgroup.clothing, + keywords: [ + 'jacket', + 'uc10', + 'fashion', + 'winter', + 'cold', + 'jacket', + 'clothes', + 'clothing', + 'style', + 'chilly', + 'chilled', + 'brisk', + 'freezing', + 'frostbite', + 'icicles', + 'veste' + ]), + Emoji( + name: 'lab coat', + char: '\u{1F97C}', + shortName: 'lab_coat', + emojiGroup: EmojiGroup.objects, + emojiSubgroup: EmojiSubgroup.clothing, + keywords: ['uc11', 'science', 'jacket', 'medical', 'lab', 'veste']), + Emoji( + name: 'safety vest', + char: '\u{1F9BA}', + shortName: 'safety_vest', + emojiGroup: EmojiGroup.objects, + emojiSubgroup: EmojiSubgroup.clothing, + keywords: [ + 'uc12', + '911', + 'jacket', + 'construction', + 'emergency', + 'injury', + 'veste' + ]), + Emoji( + name: 'woman’s clothes', + char: '\u{1F45A}', + shortName: 'womans_clothes', + emojiGroup: EmojiGroup.objects, + emojiSubgroup: EmojiSubgroup.clothing, + keywords: [ + 'clothing', + 'woman', + 'uc6', + 'fashion', + 'women', + 'pink', + 'mom', + 'clothes', + 'clothing', + 'style', + 'woman', + 'female', + 'rose', + 'maman', + 'mommy', + 'mama', + 'mother' + ]), + Emoji( + name: 't-shirt', + char: '\u{1F455}', + shortName: 'shirt', + emojiGroup: EmojiGroup.objects, + emojiSubgroup: EmojiSubgroup.clothing, + keywords: [ + 'clothing', + 'shirt', + 'tshirt', + 'uc6', + 'fashion', + 'men', + 'clothes', + 'clothing', + 'style', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male' + ]), + Emoji( + name: 'jeans', + char: '\u{1F456}', + shortName: 'jeans', + emojiGroup: EmojiGroup.objects, + emojiSubgroup: EmojiSubgroup.clothing, + keywords: [ + 'clothing', + 'pants', + 'trousers', + 'uc6', + 'fashion', + 'men', + 'pants', + 'clothes', + 'clothing', + 'style', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'pant', + 'levis', + 'slacks' + ]), + Emoji( + name: 'briefs', + char: '\u{1FA72}', + shortName: 'briefs', + emojiGroup: EmojiGroup.objects, + emojiSubgroup: EmojiSubgroup.clothing, + keywords: [ + 'uc12', + 'fashion', + 'men', + 'underwear', + 'clothes', + 'clothing', + 'style', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'undergarments', + 'boxers', + 'panties', + 'boy shorts', + 'panty', + 'biancheria intima', + 'sous-vêtements', + 'ropa interior', + 'speedos' + ]), + Emoji( + name: 'shorts', + char: '\u{1FA73}', + shortName: 'shorts', + emojiGroup: EmojiGroup.objects, + emojiSubgroup: EmojiSubgroup.clothing, + keywords: [ + 'uc12', + 'fashion', + 'vacation', + 'swim', + 'beach', + 'scuba', + 'pantalones cortos', + 'clothes', + 'clothing', + 'style', + 'swimming', + 'swimmer', + 'snorkel', + 'kurze Hose', + 'pantaloncini' + ]), + Emoji( + name: 'necktie', + char: '\u{1F454}', + shortName: 'necktie', + emojiGroup: EmojiGroup.objects, + emojiSubgroup: EmojiSubgroup.clothing, + keywords: [ + 'clothing', + 'uc6', + 'fashion', + 'men', + 'accessories', + 'business', + 'clothes', + 'clothing', + 'style', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male' + ]), + Emoji( + name: 'dress', + char: '\u{1F457}', + shortName: 'dress', + emojiGroup: EmojiGroup.objects, + emojiSubgroup: EmojiSubgroup.clothing, + keywords: [ + 'clothing', + 'uc6', + 'fashion', + 'women', + 'sexy', + 'beautiful', + 'girls night', + 'pink', + 'vintage', + 'florida', + 'mom', + 'dress', + 'clothes', + 'clothing', + 'style', + 'woman', + 'female', + 'cute', + 'pretty', + 'adorable', + 'adore', + 'beauty', + 'cutie', + 'babe', + 'lovely', + 'ladies night', + 'girls only', + 'girlfriend', + 'rose', + 'maman', + 'mommy', + 'mama', + 'mother' + ]), + Emoji( + name: 'bikini', + char: '\u{1F459}', + shortName: 'bikini', + emojiGroup: EmojiGroup.objects, + emojiSubgroup: EmojiSubgroup.clothing, + keywords: [ + 'clothing', + 'swim', + 'uc6', + 'fashion', + 'women', + 'sexy', + 'tropical', + 'vacation', + 'swim', + 'beach', + 'hawaii', + 'california', + 'florida', + 'las vegas', + 'summer', + 'binoculars', + 'clothes', + 'clothing', + 'style', + 'woman', + 'female', + 'swimming', + 'swimmer', + 'aloha', + 'kawaii', + 'maui', + 'moana', + 'vegas', + 'weekend' + ]), + Emoji( + name: 'one-piece swimsuit', + char: '\u{1FA71}', + shortName: 'one_piece_swimsuit', + emojiGroup: EmojiGroup.objects, + emojiSubgroup: EmojiSubgroup.clothing, + keywords: [ + 'uc12', + 'fashion', + 'tropical', + 'vacation', + 'swim', + 'beach', + 'hawaii', + 'california', + 'florida', + 'scuba', + 'summer', + 'clothes', + 'clothing', + 'style', + 'swimming', + 'swimmer', + 'aloha', + 'kawaii', + 'maui', + 'moana', + 'snorkel', + 'weekend' + ]), + Emoji( + name: 'kimono', + char: '\u{1F458}', + shortName: 'kimono', + emojiGroup: EmojiGroup.objects, + emojiSubgroup: EmojiSubgroup.clothing, + keywords: [ + 'clothing', + 'uc6', + 'fashion', + 'japan', + 'pink', + 'dress', + 'clothes', + 'clothing', + 'style', + 'japanese', + 'ninja', + 'rose' + ]), + Emoji( + name: 'sari', + char: '\u{1F97B}', + shortName: 'sari', + emojiGroup: EmojiGroup.objects, + emojiSubgroup: EmojiSubgroup.clothing, + keywords: [ + 'uc12', + 'fashion', + 'saree', + 'dress', + 'clothes', + 'clothing', + 'style', + 'shari', + 'nivi', + 'choli', + 'ravike', + 'cholo', + 'parkar', + 'ul-pavadai' + ]), + Emoji( + name: 'flat shoe', + char: '\u{1F97F}', + shortName: 'womans_flat_shoe', + emojiGroup: EmojiGroup.objects, + emojiSubgroup: EmojiSubgroup.clothing, + keywords: [ + 'uc11', + 'fashion', + 'women', + 'shoe', + 'accessories', + 'pink', + 'mom', + 'clothes', + 'clothing', + 'style', + 'woman', + 'female', + 'shoes', + 'baskets', + 'loafers', + 'sandals', + 'pumps', + 'boots', + 'heels', + 'rose', + 'maman', + 'mommy', + 'mama', + 'mother' + ]), + Emoji( + name: 'high-heeled shoe', + char: '\u{1F460}', + shortName: 'high_heel', + emojiGroup: EmojiGroup.objects, + emojiSubgroup: EmojiSubgroup.clothing, + keywords: [ + 'clothing', + 'heel', + 'shoe', + 'woman', + 'uc6', + 'fashion', + 'women', + 'shoe', + 'sexy', + 'accessories', + 'girls night', + 'california', + 'las vegas', + 'rich', + 'mom', + 'clothes', + 'clothing', + 'style', + 'woman', + 'female', + 'shoes', + 'baskets', + 'loafers', + 'sandals', + 'pumps', + 'boots', + 'heels', + 'ladies night', + 'girls only', + 'girlfriend', + 'vegas', + 'grand', + 'expensive', + 'fancy', + 'maman', + 'mommy', + 'mama', + 'mother' + ]), + Emoji( + name: 'woman’s sandal', + char: '\u{1F461}', + shortName: 'sandal', + emojiGroup: EmojiGroup.objects, + emojiSubgroup: EmojiSubgroup.clothing, + keywords: [ + 'clothing', + 'sandal', + 'shoe', + 'woman', + 'uc6', + 'fashion', + 'women', + 'shoe', + 'accessories', + 'pink', + 'summer', + 'mom', + 'clothes', + 'clothing', + 'style', + 'woman', + 'female', + 'shoes', + 'baskets', + 'loafers', + 'sandals', + 'pumps', + 'boots', + 'heels', + 'rose', + 'weekend', + 'maman', + 'mommy', + 'mama', + 'mother' + ]), + Emoji( + name: 'woman’s boot', + char: '\u{1F462}', + shortName: 'boot', + emojiGroup: EmojiGroup.objects, + emojiSubgroup: EmojiSubgroup.clothing, + keywords: [ + 'boot', + 'clothing', + 'shoe', + 'woman', + 'uc6', + 'fashion', + 'women', + 'shoe', + 'sexy', + 'accessories', + 'vintage', + 'rich', + 'clothes', + 'clothing', + 'style', + 'woman', + 'female', + 'shoes', + 'baskets', + 'loafers', + 'sandals', + 'pumps', + 'boots', + 'heels', + 'grand', + 'expensive', + 'fancy' + ]), + Emoji( + name: 'man’s shoe', + char: '\u{1F45E}', + shortName: 'mans_shoe', + emojiGroup: EmojiGroup.objects, + emojiSubgroup: EmojiSubgroup.clothing, + keywords: [ + 'clothing', + 'man', + 'shoe', + 'uc6', + 'fashion', + 'shoe', + 'men', + 'accessories', + 'vintage', + 'clothes', + 'clothing', + 'style', + 'shoes', + 'baskets', + 'loafers', + 'sandals', + 'pumps', + 'boots', + 'heels', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male' + ]), + Emoji( + name: 'running shoe', + char: '\u{1F45F}', + shortName: 'athletic_shoe', + emojiGroup: EmojiGroup.objects, + emojiSubgroup: EmojiSubgroup.clothing, + keywords: [ + 'athletic', + 'clothing', + 'shoe', + 'sneaker', + 'uc6', + 'sport', + 'fashion', + 'shoe', + 'accessories', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'clothes', + 'clothing', + 'style', + 'shoes', + 'baskets', + 'loafers', + 'sandals', + 'pumps', + 'boots', + 'heels' + ]), + Emoji( + name: 'hiking boot', + char: '\u{1F97E}', + shortName: 'hiking_boot', + emojiGroup: EmojiGroup.objects, + emojiSubgroup: EmojiSubgroup.clothing, + keywords: [ + 'uc11', + 'shoe', + 'accessories', + 'mountain', + 'activity', + 'rock climbing', + 'shoes', + 'baskets', + 'loafers', + 'sandals', + 'pumps', + 'boots', + 'heels', + 'climber' + ]), + Emoji( + name: 'thong sandal', + char: '\u{1FA74}', + shortName: 'thong_sandal', + emojiGroup: EmojiGroup.objects, + emojiSubgroup: EmojiSubgroup.clothing, + keywords: [ + 'uc13', + 'tropical', + 'beach', + 'hawaii', + 'flip flop', + 'california', + 'florida', + 'summer', + 'aloha', + 'kawaii', + 'maui', + 'moana', + 'weekend' + ]), + Emoji( + name: 'socks', + char: '\u{1F9E6}', + shortName: 'socks', + emojiGroup: EmojiGroup.objects, + emojiSubgroup: EmojiSubgroup.clothing, + keywords: [ + 'stocking', + 'uc10', + 'fashion', + 'winter', + 'cold', + 'accessories', + 'clothes', + 'clothing', + 'style', + 'chilly', + 'chilled', + 'brisk', + 'freezing', + 'frostbite', + 'icicles' + ]), + Emoji( + name: 'gloves', + char: '\u{1F9E4}', + shortName: 'gloves', + emojiGroup: EmojiGroup.objects, + emojiSubgroup: EmojiSubgroup.clothing, + keywords: [ + 'hand', + 'uc10', + 'fashion', + 'winter', + 'cold', + 'accessories', + 'gloves', + 'mittins', + 'clothes', + 'clothing', + 'style', + 'chilly', + 'chilled', + 'brisk', + 'freezing', + 'frostbite', + 'icicles', + 'muff' + ]), + Emoji( + name: 'scarf', + char: '\u{1F9E3}', + shortName: 'scarf', + emojiGroup: EmojiGroup.objects, + emojiSubgroup: EmojiSubgroup.clothing, + keywords: [ + 'neck', + 'uc10', + 'fashion', + 'winter', + 'cold', + 'accessories', + 'clothes', + 'clothing', + 'style', + 'chilly', + 'chilled', + 'brisk', + 'freezing', + 'frostbite', + 'icicles' + ]), + Emoji( + name: 'top hat', + char: '\u{1F3A9}', + shortName: 'tophat', + emojiGroup: EmojiGroup.objects, + emojiSubgroup: EmojiSubgroup.clothing, + keywords: [ + 'clothing', + 'hat', + 'top', + 'tophat', + 'uc6', + 'fashion', + 'wedding', + 'hat', + 'men', + 'accessories', + 'magic', + 'vintage', + 'rich', + 'clothes', + 'clothing', + 'style', + 'weddings', + 'marriage', + 'newlywed', + 'bride', + 'groome', + 'groom', + 'married', + 'marry', + 'hats', + 'cap', + 'caps', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'spell', + 'genie', + 'magical', + 'grand', + 'expensive', + 'fancy' + ]), + Emoji( + name: 'billed cap', + char: '\u{1F9E2}', + shortName: 'billed_cap', + emojiGroup: EmojiGroup.objects, + emojiSubgroup: EmojiSubgroup.clothing, + keywords: [ + 'baseball cap', + 'uc10', + 'fashion', + 'hat', + 'accessories', + 'clothes', + 'clothing', + 'style', + 'hats', + 'cap', + 'caps' + ]), + Emoji( + name: 'woman’s hat', + char: '\u{1F452}', + shortName: 'womans_hat', + emojiGroup: EmojiGroup.objects, + emojiSubgroup: EmojiSubgroup.clothing, + keywords: [ + 'clothing', + 'hat', + 'woman', + 'uc6', + 'fashion', + 'hat', + 'women', + 'accessories', + 'pink', + 'vintage', + 'easter', + 'rich', + 'clothes', + 'clothing', + 'style', + 'hats', + 'cap', + 'caps', + 'woman', + 'female', + 'rose', + 'grand', + 'expensive', + 'fancy' + ]), + Emoji( + name: 'graduation cap', + char: '\u{1F393}', + shortName: 'mortar_board', + emojiGroup: EmojiGroup.objects, + emojiSubgroup: EmojiSubgroup.clothing, + keywords: [ + 'cap', + 'celebration', + 'clothing', + 'graduation', + 'hat', + 'uc6', + 'hat', + 'classroom', + 'accessories', + 'graduate', + 'hats', + 'cap', + 'caps', + 'school', + 'teach', + 'learn', + 'study', + 'college', + 'degree', + 'education', + 'homework', + 'student', + 'teacher', + 'university', + 'test', + 'learning' + ]), + Emoji( + name: 'rescue worker’s helmet', + char: '\u{26D1}\u{FE0F}', + shortName: 'helmet_with_cross', + emojiGroup: EmojiGroup.objects, + emojiSubgroup: EmojiSubgroup.clothing, + keywords: [ + 'aid', + 'cross', + 'face', + 'hat', + 'helmet', + 'uc5', + 'hat', + 'accessories', + 'job', + '911', + 'help', + 'helmet', + 'hats', + 'cap', + 'caps', + 'profession', + 'boss', + 'career', + 'emergency', + 'injury' + ]), + Emoji( + name: 'military helmet', + char: '\u{1FA96}', + shortName: 'military_helmet', + emojiGroup: EmojiGroup.objects, + emojiSubgroup: EmojiSubgroup.clothing, + keywords: [ + 'uc13', + 'hat', + 'soldier', + 'helmet', + 'army', + 'hats', + 'cap', + 'caps' + ]), + Emoji( + name: 'crown', + char: '\u{1F451}', + shortName: 'crown', + emojiGroup: EmojiGroup.objects, + emojiSubgroup: EmojiSubgroup.clothing, + keywords: [ + 'clothing', + 'king', + 'queen', + 'uc6', + 'accessories', + 'power', + 'queen', + 'england', + 'bling', + 'fame', + 'crown', + 'rich', + 'king', + 'prince', + 'princess', + 'united kingdom', + 'london', + 'uk', + 'jewels', + 'gems', + 'jewel', + 'jewelry', + 'treasure', + 'famous', + 'celebrity', + 'tiara', + 'grand', + 'expensive', + 'fancy' + ]), + Emoji( + name: 'ring', + char: '\u{1F48D}', + shortName: 'ring', + emojiGroup: EmojiGroup.objects, + emojiSubgroup: EmojiSubgroup.clothing, + keywords: [ + 'diamond', + 'uc6', + 'wedding', + 'accessories', + 'girls night', + 'trap', + 'vintage', + 'bling', + 'diamond', + 'rich', + 'weddings', + 'marriage', + 'newlywed', + 'bride', + 'groome', + 'groom', + 'married', + 'marry', + 'ladies night', + 'girls only', + 'girlfriend', + 'jewels', + 'gems', + 'jewel', + 'jewelry', + 'treasure', + 'grand', + 'expensive', + 'fancy' + ]), + Emoji( + name: 'clutch bag', + char: '\u{1F45D}', + shortName: 'pouch', + emojiGroup: EmojiGroup.objects, + emojiSubgroup: EmojiSubgroup.clothing, + keywords: [ + 'bag', + 'clothing', + 'pouch', + 'uc6', + 'fashion', + 'women', + 'bag', + 'accessories', + 'mom', + 'clothes', + 'clothing', + 'style', + 'woman', + 'female', + 'swag', + 'maman', + 'mommy', + 'mama', + 'mother' + ]), + Emoji( + name: 'purse', + char: '\u{1F45B}', + shortName: 'purse', + emojiGroup: EmojiGroup.objects, + emojiSubgroup: EmojiSubgroup.clothing, + keywords: [ + 'clothing', + 'coin', + 'uc6', + 'fashion', + 'women', + 'bag', + 'money', + 'accessories', + 'pink', + 'vintage', + 'mom', + 'clothes', + 'clothing', + 'style', + 'woman', + 'female', + 'swag', + 'cash', + 'dollars', + 'dollar', + 'bucks', + 'currency', + 'funds', + 'payment', + 'money face', + 'reward', + 'thief', + 'bank', + 'benjamins', + 'argent', + 'dinero', + 'i soldi', + 'Geld', + 'rose', + 'maman', + 'mommy', + 'mama', + 'mother' + ]), + Emoji( + name: 'handbag', + char: '\u{1F45C}', + shortName: 'handbag', + emojiGroup: EmojiGroup.objects, + emojiSubgroup: EmojiSubgroup.clothing, + keywords: [ + 'bag', + 'clothing', + 'purse', + 'uc6', + 'fashion', + 'women', + 'bag', + 'vacation', + 'accessories', + 'rich', + 'mom', + 'work', + 'clothes', + 'clothing', + 'style', + 'woman', + 'female', + 'swag', + 'grand', + 'expensive', + 'fancy', + 'maman', + 'mommy', + 'mama', + 'mother', + 'office' + ]), + Emoji( + name: 'briefcase', + char: '\u{1F4BC}', + shortName: 'briefcase', + emojiGroup: EmojiGroup.objects, + emojiSubgroup: EmojiSubgroup.office, + keywords: [ + 'briefcase', + 'uc6', + 'fashion', + 'bag', + 'men', + 'classroom', + 'accessories', + 'nutcase', + 'job', + 'business', + 'rich', + 'work', + 'clothes', + 'clothing', + 'style', + 'swag', + 'man', + 'guy', + 'guys', + 'gentleman', + 'male', + 'school', + 'teach', + 'learn', + 'study', + 'college', + 'degree', + 'education', + 'homework', + 'student', + 'teacher', + 'university', + 'test', + 'learning', + 'profession', + 'boss', + 'career', + 'grand', + 'expensive', + 'fancy', + 'office' + ]), + Emoji( + name: 'backpack', + char: '\u{1F392}', + shortName: 'school_satchel', + emojiGroup: EmojiGroup.objects, + emojiSubgroup: EmojiSubgroup.clothing, + keywords: [ + 'bag', + 'satchel', + 'school', + 'uc6', + 'fashion', + 'bag', + 'classroom', + 'vacation', + 'accessories', + 'backpack', + 'suitcase', + 'clothes', + 'clothing', + 'style', + 'swag', + 'school', + 'teach', + 'learn', + 'study', + 'college', + 'degree', + 'education', + 'homework', + 'student', + 'teacher', + 'university', + 'test', + 'learning', + 'carry-on' + ]), + Emoji( + name: 'luggage', + char: '\u{1F9F3}', + shortName: 'luggage', + emojiGroup: EmojiGroup.travelPlaces, + emojiSubgroup: EmojiSubgroup.hotel, + keywords: [ + 'uc11', + 'bag', + 'travel', + 'vacation', + 'suitcase', + 'household', + 'swag', + 'carry-on' + ]), + Emoji( + name: 'glasses', + char: '\u{1F453}', + shortName: 'eyeglasses', + emojiGroup: EmojiGroup.objects, + emojiSubgroup: EmojiSubgroup.clothing, + keywords: [ + 'clothing', + 'eye', + 'eyeglasses', + 'eyewear', + 'uc6', + 'fashion', + 'glasses', + 'accessories', + 'harry potter', + 'detective', + 'clothes', + 'clothing', + 'style', + 'eyeglasses', + 'eye glasses' + ]), + Emoji( + name: 'sunglasses', + char: '\u{1F576}\u{FE0F}', + shortName: 'dark_sunglasses', + emojiGroup: EmojiGroup.objects, + emojiSubgroup: EmojiSubgroup.clothing, + keywords: [ + 'dark', + 'eye', + 'eyewear', + 'glasses', + 'uc7', + 'fashion', + 'glasses', + 'accessories', + 'awesome', + 'beautiful', + 'sunglasses', + 'hawaii', + 'california', + 'florida', + 'las vegas', + 'summer', + 'clothes', + 'clothing', + 'style', + 'eyeglasses', + 'eye glasses', + 'okay', + 'got it', + 'cool', + 'ok', + 'will do', + 'like', + 'bien', + 'yep', + 'yup', + 'cute', + 'pretty', + 'adorable', + 'adore', + 'beauty', + 'cutie', + 'babe', + 'lovely', + 'shades', + 'lunettes de soleil', + 'sun glasses', + 'aloha', + 'kawaii', + 'maui', + 'moana', + 'vegas', + 'weekend' + ]), + Emoji( + name: 'goggles', + char: '\u{1F97D}', + shortName: 'goggles', + emojiGroup: EmojiGroup.objects, + emojiSubgroup: EmojiSubgroup.clothing, + keywords: [ + 'uc11', + 'glasses', + 'science', + 'accessories', + 'medical', + 'eyeglasses', + 'eye glasses', + 'lab' + ]), + Emoji( + name: 'closed umbrella', + char: '\u{1F302}', + shortName: 'closed_umbrella', + emojiGroup: EmojiGroup.travelPlaces, + emojiSubgroup: EmojiSubgroup.skyWeather, + keywords: [ + 'clothing', + 'rain', + 'umbrella', + 'uc6', + 'sky', + 'rain', + 'accessories', + 'umbrella', + 'england', + 'cane', + 'united kingdom', + 'london', + 'uk' + ]), + Emoji( + name: 'curly hair', + char: '\u{1F9B1}', + shortName: 'curly_haired', + emojiGroup: EmojiGroup.component, + emojiSubgroup: EmojiSubgroup.hairStyle, + keywords: [ + 'uc11', + 'diversity', + 'body', + 'hair', + 'afro', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'body part', + 'anatomy', + "'fro", + 'curls', + 'frizzy', + 'perm' + ]), + Emoji( + name: 'red hair', + char: '\u{1F9B0}', + shortName: 'red_haired', + emojiGroup: EmojiGroup.component, + emojiSubgroup: EmojiSubgroup.hairStyle, + keywords: [ + 'uc11', + 'diversity', + 'body', + 'hair', + 'ginger', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'body part', + 'anatomy' + ]), + Emoji( + name: 'white hair', + char: '\u{1F9B3}', + shortName: 'white_haired', + emojiGroup: EmojiGroup.component, + emojiSubgroup: EmojiSubgroup.hairStyle, + keywords: [ + 'uc11', + 'old people', + 'diversity', + 'body', + 'hair', + 'grey hair', + 'grandparents', + 'elderly', + 'grandma', + 'grandpa', + 'grandmother', + 'grandfather', + 'mamie', + 'papy', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'body part', + 'anatomy', + 'silver hair' + ]), + Emoji( + name: 'bald', + char: '\u{1F9B2}', + shortName: 'bald', + emojiGroup: EmojiGroup.component, + emojiSubgroup: EmojiSubgroup.hairStyle, + keywords: [ + 'uc11', + 'old people', + 'diversity', + 'body', + 'shaved head', + 'hairless', + 'grandparents', + 'elderly', + 'grandma', + 'grandpa', + 'grandmother', + 'grandfather', + 'mamie', + 'papy', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'body part', + 'anatomy', + 'balding' + ]), + Emoji( + name: 'dog face', + char: '\u{1F436}', + shortName: 'dog', + emojiGroup: EmojiGroup.animalsNature, + emojiSubgroup: EmojiSubgroup.animalMammal, + keywords: [ + 'dog', + 'face', + 'pet', + 'uc6', + 'animal', + 'dog', + 'pug', + 'harry potter', + 'bingo', + 'bitch', + 'pets', + 'pokemon', + 'minecraft', + 'animals', + 'animal kingdom', + 'puppy', + 'doggy', + 'memes', + 'dogs', + 'perro', + 'puppies', + 'chien', + 'pugs', + 'puta', + 'pute' + ]), + Emoji( + name: 'cat face', + char: '\u{1F431}', + shortName: 'cat', + emojiGroup: EmojiGroup.animalsNature, + emojiSubgroup: EmojiSubgroup.animalMammal, + keywords: [ + 'cat', + 'face', + 'pet', + 'uc6', + 'animal', + 'halloween', + 'cat', + 'vagina', + 'pussy', + 'glitter', + 'pets', + 'pokemon', + 'porn', + 'animals', + 'animal kingdom', + 'samhain', + 'kitty', + 'kitten', + 'cats', + 'kittens', + 'kitties', + 'feline', + 'felines', + 'cat face', + 'gato', + 'meow', + 'condom' + ]), + Emoji( + name: 'mouse face', + char: '\u{1F42D}', + shortName: 'mouse', + emojiGroup: EmojiGroup.animalsNature, + emojiSubgroup: EmojiSubgroup.animalMammal, + keywords: [ + 'face', + 'mouse', + 'uc6', + 'animal', + 'mickey', + 'disney', + 'pokemon', + 'rodent', + 'animals', + 'animal kingdom', + 'cartoon' + ]), + Emoji( + name: 'hamster', + char: '\u{1F439}', + shortName: 'hamster', + emojiGroup: EmojiGroup.animalsNature, + emojiSubgroup: EmojiSubgroup.animalMammal, + keywords: [ + 'face', + 'hamster', + 'pet', + 'uc6', + 'animal', + 'pets', + 'rodent', + 'animals', + 'animal kingdom' + ]), + Emoji( + name: 'rabbit face', + char: '\u{1F430}', + shortName: 'rabbit', + emojiGroup: EmojiGroup.animalsNature, + emojiSubgroup: EmojiSubgroup.animalMammal, + keywords: [ + 'bunny', + 'face', + 'pet', + 'rabbit', + 'uc6', + 'animal', + 'wildlife', + 'magic', + 'easter', + 'pets', + 'pokemon', + 'playboy', + 'animals', + 'animal kingdom', + 'spell', + 'genie', + 'magical', + 'play boy' + ]), + Emoji( + name: 'fox', + char: '\u{1F98A}', + shortName: 'fox', + emojiGroup: EmojiGroup.animalsNature, + emojiSubgroup: EmojiSubgroup.animalMammal, + keywords: [ + 'face', + 'fox', + 'uc9', + 'animal', + 'wildlife', + 'forest', + 'animals', + 'animal kingdom', + 'rainforest' + ]), + Emoji( + name: 'bear', + char: '\u{1F43B}', + shortName: 'bear', + emojiGroup: EmojiGroup.animalsNature, + emojiSubgroup: EmojiSubgroup.animalMammal, + keywords: [ + 'bear', + 'face', + 'uc6', + 'animal', + 'wildlife', + 'roar', + 'gummy', + 'california', + 'forest', + 'animals', + 'animal kingdom', + 'rawr', + 'rainforest' + ]), + Emoji( + name: 'panda', + char: '\u{1F43C}', + shortName: 'panda_face', + emojiGroup: EmojiGroup.animalsNature, + emojiSubgroup: EmojiSubgroup.animalMammal, + keywords: [ + 'face', + 'panda', + 'uc6', + 'animal', + 'wildlife', + 'roar', + 'chinese', + 'forest', + 'animals', + 'animal kingdom', + 'rawr', + 'chinois', + 'asian', + 'chine', + 'rainforest' + ]), + Emoji( + name: 'polar bear', + char: '\u{1F43B}\u{200D}\u{2744}\u{FE0F}', + shortName: 'polar_bear', + emojiGroup: EmojiGroup.animalsNature, + emojiSubgroup: EmojiSubgroup.animalMammal, + keywords: ['uc13', 'animal', 'polar bear', 'animals', 'animal kingdom']), + Emoji( + name: 'koala', + char: '\u{1F428}', + shortName: 'koala', + emojiGroup: EmojiGroup.animalsNature, + emojiSubgroup: EmojiSubgroup.animalMammal, + keywords: [ + 'bear', + 'uc6', + 'animal', + 'wildlife', + 'australia', + 'forest', + 'marsupial', + 'animals', + 'animal kingdom', + 'rainforest', + 'marsupials' + ]), + Emoji( + name: 'tiger face', + char: '\u{1F42F}', + shortName: 'tiger', + emojiGroup: EmojiGroup.animalsNature, + emojiSubgroup: EmojiSubgroup.animalMammal, + keywords: [ + 'face', + 'tiger', + 'uc6', + 'animal', + 'wildlife', + 'roar', + 'cat', + 'forest', + 'animals', + 'animal kingdom', + 'rawr', + 'kitty', + 'kitten', + 'cats', + 'kittens', + 'kitties', + 'feline', + 'felines', + 'cat face', + 'gato', + 'meow', + 'rainforest' + ]), + Emoji( + name: 'lion', + char: '\u{1F981}', + shortName: 'lion_face', + emojiGroup: EmojiGroup.animalsNature, + emojiSubgroup: EmojiSubgroup.animalMammal, + keywords: [ + 'Leo', + 'face', + 'lion', + 'zodiac', + 'uc8', + 'animal', + 'wildlife', + 'roar', + 'cat', + 'england', + 'forest', + 'animals', + 'animal kingdom', + 'rawr', + 'kitty', + 'kitten', + 'cats', + 'kittens', + 'kitties', + 'feline', + 'felines', + 'cat face', + 'gato', + 'meow', + 'united kingdom', + 'london', + 'uk', + 'rainforest' + ]), + Emoji( + name: 'cow face', + char: '\u{1F42E}', + shortName: 'cow', + emojiGroup: EmojiGroup.animalsNature, + emojiSubgroup: EmojiSubgroup.animalMammal, + keywords: [ + 'cow', + 'face', + 'uc6', + 'animal', + 'farm', + 'texas', + 'minecraft', + 'animals', + 'animal kingdom' + ]), + Emoji( + name: 'pig face', + char: '\u{1F437}', + shortName: 'pig', + emojiGroup: EmojiGroup.animalsNature, + emojiSubgroup: EmojiSubgroup.animalMammal, + keywords: [ + 'face', + 'pig', + 'uc6', + 'animal', + 'pig', + 'farm', + 'guinea pig', + 'pets', + 'minecraft', + 'animals', + 'animal kingdom', + 'pork' + ]), + Emoji( + name: 'pig nose', + char: '\u{1F43D}', + shortName: 'pig_nose', + emojiGroup: EmojiGroup.animalsNature, + emojiSubgroup: EmojiSubgroup.animalMammal, + keywords: [ + 'face', + 'nose', + 'pig', + 'uc6', + 'animal', + 'pig', + 'guinea pig', + 'pets', + 'animals', + 'animal kingdom', + 'pork' + ]), + Emoji( + name: 'frog', + char: '\u{1F438}', + shortName: 'frog', + emojiGroup: EmojiGroup.animalsNature, + emojiSubgroup: EmojiSubgroup.animalAmphibian, + keywords: [ + 'face', + 'frog', + 'uc6', + 'animal', + 'wildlife', + 'forest', + 'pets', + 'pokemon', + 'river', + 'animals', + 'animal kingdom', + 'rainforest' + ]), + Emoji( + name: 'monkey face', + char: '\u{1F435}', + shortName: 'monkey_face', + emojiGroup: EmojiGroup.animalsNature, + emojiSubgroup: EmojiSubgroup.animalMammal, + keywords: [ + 'face', + 'monkey', + 'uc6', + 'animal', + 'monkey', + 'animals', + 'animal kingdom', + 'progi', + 'ape', + 'primate' + ]), + Emoji( + name: 'see-no-evil monkey', + char: '\u{1F648}', + shortName: 'see_no_evil', + emojiGroup: EmojiGroup.smileysEmotion, + emojiSubgroup: EmojiSubgroup.monkeyFace, + keywords: [ + 'evil', + 'face', + 'forbidden', + 'gesture', + 'monkey', + 'no', + 'not', + 'prohibited', + 'see', + 'uc6', + 'animal', + 'monkey', + 'porn', + 'shame', + 'animals', + 'animal kingdom', + 'progi', + 'ape', + 'primate' + ]), + Emoji( + name: 'hear-no-evil monkey', + char: '\u{1F649}', + shortName: 'hear_no_evil', + emojiGroup: EmojiGroup.smileysEmotion, + emojiSubgroup: EmojiSubgroup.monkeyFace, + keywords: [ + 'evil', + 'face', + 'forbidden', + 'gesture', + 'hear', + 'monkey', + 'no', + 'not', + 'prohibited', + 'uc6', + 'animal', + 'monkey', + 'animals', + 'animal kingdom', + 'progi', + 'ape', + 'primate' + ]), + Emoji( + name: 'speak-no-evil monkey', + char: '\u{1F64A}', + shortName: 'speak_no_evil', + emojiGroup: EmojiGroup.smileysEmotion, + emojiSubgroup: EmojiSubgroup.monkeyFace, + keywords: [ + 'evil', + 'face', + 'forbidden', + 'gesture', + 'monkey', + 'no', + 'not', + 'prohibited', + 'speak', + 'uc6', + 'animal', + 'monkey', + 'facebook', + 'quiet', + 'animals', + 'animal kingdom', + 'progi', + 'ape', + 'primate', + 'shut up', + 'hushed', + 'silence', + 'silent', + 'shush', + 'shh' + ]), + Emoji( + name: 'monkey', + char: '\u{1F412}', + shortName: 'monkey', + emojiGroup: EmojiGroup.animalsNature, + emojiSubgroup: EmojiSubgroup.animalMammal, + keywords: [ + 'monkey', + 'uc6', + 'animal', + 'wildlife', + 'monkey', + 'forest', + 'pokemon', + 'animals', + 'animal kingdom', + 'progi', + 'ape', + 'primate', + 'rainforest' + ]), + Emoji( + name: 'chicken', + char: '\u{1F414}', + shortName: 'chicken', + emojiGroup: EmojiGroup.animalsNature, + emojiSubgroup: EmojiSubgroup.animalBird, + keywords: [ + 'bird', + 'chicken', + 'uc6', + 'animal', + 'birds', + 'farm', + 'jewish', + 'pets', + 'minecraft', + 'animals', + 'animal kingdom', + 'goose', + 'hannukah', + 'hanukkah', + 'israel' + ]), + Emoji( + name: 'penguin', + char: '\u{1F427}', + shortName: 'penguin', + emojiGroup: EmojiGroup.animalsNature, + emojiSubgroup: EmojiSubgroup.animalBird, + keywords: [ + 'bird', + 'penguin', + 'uc6', + 'animal', + 'wildlife', + 'birds', + 'ocean', + 'animals', + 'animal kingdom', + 'goose', + 'sea' + ]), + Emoji( + name: 'bird', + char: '\u{1F426}', + shortName: 'bird', + emojiGroup: EmojiGroup.animalsNature, + emojiSubgroup: EmojiSubgroup.animalBird, + keywords: [ + 'bird', + 'uc6', + 'animal', + 'wildlife', + 'twitter', + 'birds', + 'forest', + 'pets', + 'pokemon', + 'animals', + 'animal kingdom', + 'goose', + 'rainforest' + ]), + Emoji( + name: 'baby chick', + char: '\u{1F424}', + shortName: 'baby_chick', + emojiGroup: EmojiGroup.animalsNature, + emojiSubgroup: EmojiSubgroup.animalBird, + keywords: [ + 'baby', + 'bird', + 'chick', + 'uc6', + 'animal', + 'easter', + 'birds', + 'pets', + 'animals', + 'animal kingdom', + 'goose' + ]), + Emoji( + name: 'hatching chick', + char: '\u{1F423}', + shortName: 'hatching_chick', + emojiGroup: EmojiGroup.animalsNature, + emojiSubgroup: EmojiSubgroup.animalBird, + keywords: [ + 'baby', + 'bird', + 'chick', + 'hatching', + 'uc6', + 'animal', + 'easter', + 'birds', + 'eggs', + 'pets', + 'animals', + 'animal kingdom', + 'goose' + ]), + Emoji( + name: 'front-facing baby chick', + char: '\u{1F425}', + shortName: 'hatched_chick', + emojiGroup: EmojiGroup.animalsNature, + emojiSubgroup: EmojiSubgroup.animalBird, + keywords: [ + 'baby', + 'bird', + 'chick', + 'uc6', + 'animal', + 'easter', + 'birds', + 'pets', + 'pokemon', + 'minecraft', + 'animals', + 'animal kingdom', + 'goose' + ]), + Emoji( + name: 'duck', + char: '\u{1F986}', + shortName: 'duck', + emojiGroup: EmojiGroup.animalsNature, + emojiSubgroup: EmojiSubgroup.animalBird, + keywords: [ + 'bird', + 'duck', + 'uc9', + 'animal', + 'wildlife', + 'quack', + 'birds', + 'hunt', + 'animals', + 'animal kingdom', + 'goose' + ]), + Emoji( + name: 'dodo', + char: '\u{1F9A4}', + shortName: 'dodo', + emojiGroup: EmojiGroup.animalsNature, + emojiSubgroup: EmojiSubgroup.animalBird, + keywords: [ + 'uc13', + 'animal', + 'birds', + 'animals', + 'animal kingdom', + 'goose' + ]), + Emoji( + name: 'eagle', + char: '\u{1F985}', + shortName: 'eagle', + emojiGroup: EmojiGroup.animalsNature, + emojiSubgroup: EmojiSubgroup.animalBird, + keywords: [ + 'bird', + 'eagle', + 'uc9', + 'animal', + 'wildlife', + 'america', + 'birds', + 'forest', + 'pokemon', + 'independence day', + 'animals', + 'animal kingdom', + 'usa', + 'united states', + 'united states of america', + 'american', + 'goose', + 'rainforest', + '4th of july' + ]), + Emoji( + name: 'owl', + char: '\u{1F989}', + shortName: 'owl', + emojiGroup: EmojiGroup.animalsNature, + emojiSubgroup: EmojiSubgroup.animalBird, + keywords: [ + 'bird', + 'owl', + 'wise', + 'uc9', + 'animal', + 'wildlife', + 'halloween', + 'birds', + 'forest', + 'animals', + 'animal kingdom', + 'samhain', + 'goose', + 'rainforest' + ]), + Emoji( + name: 'bat', + char: '\u{1F987}', + shortName: 'bat', + emojiGroup: EmojiGroup.animalsNature, + emojiSubgroup: EmojiSubgroup.animalMammal, + keywords: [ + 'bat', + 'vampire', + 'uc9', + 'animal', + 'wildlife', + 'halloween', + 'harry potter', + 'forest', + 'pokemon', + 'super hero', + 'vampire', + 'animals', + 'animal kingdom', + 'samhain', + 'rainforest', + 'superhero', + 'superman', + 'batman', + 'dracula' + ]), + Emoji( + name: 'wolf', + char: '\u{1F43A}', + shortName: 'wolf', + emojiGroup: EmojiGroup.animalsNature, + emojiSubgroup: EmojiSubgroup.animalMammal, + keywords: [ + 'face', + 'wolf', + 'uc6', + 'animal', + 'wildlife', + 'roar', + 'forest', + 'pokemon', + 'minecraft', + 'animals', + 'animal kingdom', + 'rawr', + 'rainforest' + ]), + Emoji( + name: 'boar', + char: '\u{1F417}', + shortName: 'boar', + emojiGroup: EmojiGroup.animalsNature, + emojiSubgroup: EmojiSubgroup.animalMammal, + keywords: [ + 'pig', + 'uc6', + 'animal', + 'wildlife', + 'pig', + 'farm', + 'forest', + 'guinea pig', + 'hunt', + 'animals', + 'animal kingdom', + 'pork', + 'rainforest' + ]), + Emoji( + name: 'horse face', + char: '\u{1F434}', + shortName: 'horse', + emojiGroup: EmojiGroup.animalsNature, + emojiSubgroup: EmojiSubgroup.animalMammal, + keywords: [ + 'face', + 'horse', + 'uc6', + 'animal', + 'wildlife', + 'horse racing', + 'donkey', + 'farm', + 'pets', + 'pokemon', + 'texas', + 'minecraft', + 'horse', + 'animals', + 'animal kingdom', + 'horseback riding', + 'horse and rider', + 'horses', + 'horseshoe', + 'pony', + 'poney' + ]), + Emoji( + name: 'unicorn', + char: '\u{1F984}', + shortName: 'unicorn', + emojiGroup: EmojiGroup.animalsNature, + emojiSubgroup: EmojiSubgroup.animalMammal, + keywords: [ + 'face', + 'unicorn', + 'uc8', + 'animal', + 'halloween', + 'emojione', + 'hug', + 'lgbt', + 'unicorn', + 'pink', + 'facebook', + 'dream', + 'pokemon', + 'fantasy', + 'animals', + 'animal kingdom', + 'samhain', + 'emoji one', + 'embrace', + 'hugs', + 'homosexual', + 'bisex', + 'transgender', + 'non binary', + 'pansexuality', + 'intersex', + 'unicorns', + 'onesie', + 'rose', + 'dreams' + ]), + Emoji( + name: 'honeybee', + char: '\u{1F41D}', + shortName: 'bee', + emojiGroup: EmojiGroup.animalsNature, + emojiSubgroup: EmojiSubgroup.animalBug, + keywords: [ + 'bee', + 'insect', + 'uc6', + 'animal', + 'wildlife', + 'insects', + 'animals', + 'animal kingdom', + 'insect', + 'bugs', + 'bug' + ]), + Emoji( + name: 'bug', + char: '\u{1F41B}', + shortName: 'bug', + emojiGroup: EmojiGroup.animalsNature, + emojiSubgroup: EmojiSubgroup.animalBug, + keywords: [ + 'insect', + 'uc6', + 'animal', + 'wildlife', + 'insects', + 'gummy', + 'forest', + 'pokemon', + 'worm', + 'animals', + 'animal kingdom', + 'insect', + 'bugs', + 'bug', + 'rainforest', + 'caterpillar' + ]), + Emoji( + name: 'butterfly', + char: '\u{1F98B}', + shortName: 'butterfly', + emojiGroup: EmojiGroup.animalsNature, + emojiSubgroup: EmojiSubgroup.animalBug, + keywords: [ + 'butterfly', + 'insect', + 'pretty', + 'uc9', + 'animal', + 'wildlife', + 'insects', + 'forest', + 'pokemon', + 'animals', + 'animal kingdom', + 'insect', + 'bugs', + 'bug', + 'rainforest' + ]), + Emoji( + name: 'snail', + char: '\u{1F40C}', + shortName: 'snail', + emojiGroup: EmojiGroup.animalsNature, + emojiSubgroup: EmojiSubgroup.animalBug, + keywords: [ + 'snail', + 'uc6', + 'animal', + 'wildlife', + 'insects', + 'emojione', + 'animals', + 'animal kingdom', + 'insect', + 'bugs', + 'bug', + 'emoji one' + ]), + Emoji( + name: 'worm', + char: '\u{1FAB1}', + shortName: 'worm', + emojiGroup: EmojiGroup.animalsNature, + emojiSubgroup: EmojiSubgroup.animalBug, + keywords: ['uc13', 'animal', 'wildlife', 'animals', 'animal kingdom']), + Emoji( + name: 'lady beetle', + char: '\u{1F41E}', + shortName: 'lady_beetle', + emojiGroup: EmojiGroup.animalsNature, + emojiSubgroup: EmojiSubgroup.animalBug, + keywords: [ + 'beetle', + 'insect', + 'ladybird', + 'ladybug', + 'uc6', + 'animal', + 'wildlife', + 'insects', + 'animals', + 'animal kingdom', + 'insect', + 'bugs', + 'bug' + ]), + Emoji( + name: 'ant', + char: '\u{1F41C}', + shortName: 'ant', + emojiGroup: EmojiGroup.animalsNature, + emojiSubgroup: EmojiSubgroup.animalBug, + keywords: [ + 'insect', + 'uc6', + 'animal', + 'wildlife', + 'insects', + 'pokemon', + 'animals', + 'animal kingdom', + 'insect', + 'bugs', + 'bug' + ]), + Emoji( + name: 'fly', + char: '\u{1FAB0}', + shortName: 'fly', + emojiGroup: EmojiGroup.animalsNature, + emojiSubgroup: EmojiSubgroup.animalBug, + keywords: [ + 'uc13', + 'animal', + 'wildlife', + 'insects', + 'shit', + 'animals', + 'animal kingdom', + 'insect', + 'bugs', + 'bug', + 'poop', + 'turd', + 'feces', + 'pile', + 'merde', + 'butthole', + 'caca', + 'crap', + 'dirty', + 'pooo', + 'mess', + 'brown', + 'poopoo' + ]), + Emoji( + name: 'mosquito', + char: '\u{1F99F}', + shortName: 'mosquito', + emojiGroup: EmojiGroup.animalsNature, + emojiSubgroup: EmojiSubgroup.animalBug, + keywords: [ + 'uc11', + 'animal', + 'wildlife', + 'insects', + 'bite', + 'animals', + 'animal kingdom', + 'insect', + 'bugs', + 'bug' + ]), + Emoji( + name: 'cockroach', + char: '\u{1FAB3}', + shortName: 'cockroach', + emojiGroup: EmojiGroup.animalsNature, + emojiSubgroup: EmojiSubgroup.animalBug, + keywords: [ + 'uc13', + 'animal', + 'wildlife', + 'insects', + 'animals', + 'animal kingdom', + 'insect', + 'bugs', + 'bug' + ]), + Emoji( + name: 'beetle', + char: '\u{1FAB2}', + shortName: 'beetle', + emojiGroup: EmojiGroup.animalsNature, + emojiSubgroup: EmojiSubgroup.animalBug, + keywords: [ + 'uc13', + 'animal', + 'wildlife', + 'insects', + 'animals', + 'animal kingdom', + 'insect', + 'bugs', + 'bug' + ]), + Emoji( + name: 'cricket', + char: '\u{1F997}', + shortName: 'cricket', + emojiGroup: EmojiGroup.animalsNature, + emojiSubgroup: EmojiSubgroup.animalBug, + keywords: [ + 'uc10', + 'animal', + 'wildlife', + 'insects', + 'animals', + 'animal kingdom', + 'insect', + 'bugs', + 'bug' + ]), + Emoji( + name: 'spider', + char: '\u{1F577}\u{FE0F}', + shortName: 'spider', + emojiGroup: EmojiGroup.animalsNature, + emojiSubgroup: EmojiSubgroup.animalBug, + keywords: [ + 'insect', + 'uc7', + 'animal', + 'wildlife', + 'insects', + 'halloween', + 'australia', + 'harry potter', + 'forest', + 'pets', + 'animals', + 'animal kingdom', + 'insect', + 'bugs', + 'bug', + 'samhain', + 'rainforest' + ]), + Emoji( + name: 'spider web', + char: '\u{1F578}\u{FE0F}', + shortName: 'spider_web', + emojiGroup: EmojiGroup.animalsNature, + emojiSubgroup: EmojiSubgroup.animalBug, + keywords: [ + 'spider', + 'web', + 'uc7', + 'halloween', + 'forest', + 'samhain', + 'rainforest' + ]), + Emoji( + name: 'scorpion', + char: '\u{1F982}', + shortName: 'scorpion', + emojiGroup: EmojiGroup.animalsNature, + emojiSubgroup: EmojiSubgroup.animalBug, + keywords: [ + 'Scorpius', + 'scorpio', + 'zodiac', + 'uc8', + 'animal', + 'wildlife', + 'insects', + 'reptile', + 'animals', + 'animal kingdom', + 'insect', + 'bugs', + 'bug', + 'reptiles' + ]), + Emoji( + name: 'turtle', + char: '\u{1F422}', + shortName: 'turtle', + emojiGroup: EmojiGroup.animalsNature, + emojiSubgroup: EmojiSubgroup.animalReptile, + keywords: [ + 'terrapin', + 'tortoise', + 'turtle', + 'uc6', + 'animal', + 'wildlife', + 'reptile', + 'pets', + 'pokemon', + 'river', + 'ocean', + 'animals', + 'animal kingdom', + 'reptiles', + 'sea' + ]), + Emoji( + name: 'snake', + char: '\u{1F40D}', + shortName: 'snake', + emojiGroup: EmojiGroup.animalsNature, + emojiSubgroup: EmojiSubgroup.animalReptile, + keywords: [ + 'Ophiuchus', + 'bearer', + 'serpent', + 'zodiac', + 'uc6', + 'animal', + 'wildlife', + 'reptile', + 'creationism', + 'australia', + 'harry potter', + 'forest', + 'pets', + 'pokemon', + 'ocean', + 'animals', + 'animal kingdom', + 'reptiles', + 'adam & eve', + 'adam and eve', + 'rainforest', + 'sea' + ]), + Emoji( + name: 'lizard', + char: '\u{1F98E}', + shortName: 'lizard', + emojiGroup: EmojiGroup.animalsNature, + emojiSubgroup: EmojiSubgroup.animalReptile, + keywords: [ + 'lizard', + 'reptile', + 'uc9', + 'animal', + 'wildlife', + 'reptile', + 'forest', + 'pets', + 'pokemon', + 'animals', + 'animal kingdom', + 'reptiles', + 'rainforest' + ]), + Emoji( + name: 'T-Rex', + char: '\u{1F996}', + shortName: 't_rex', + emojiGroup: EmojiGroup.animalsNature, + emojiSubgroup: EmojiSubgroup.animalReptile, + keywords: [ + 'Tyrannosaurus Rex', + 'uc10', + 'animal', + 'dinosaur', + 'Tyrannosaurus Rex', + 'animals', + 'animal kingdom', + 'trex', + 't rex' + ]), + Emoji( + name: 'sauropod', + char: '\u{1F995}', + shortName: 'sauropod', + emojiGroup: EmojiGroup.animalsNature, + emojiSubgroup: EmojiSubgroup.animalReptile, + keywords: [ + 'brachiosaurus', + 'brontosaurus', + 'diplodocus', + 'uc10', + 'animal', + 'dinosaur', + 'Brontosaurus', + 'animals', + 'animal kingdom', + 'Diplodocus', + 'Brachiosaurus' + ]), + Emoji( + name: 'octopus', + char: '\u{1F419}', + shortName: 'octopus', + emojiGroup: EmojiGroup.animalsNature, + emojiSubgroup: EmojiSubgroup.animalMarine, + keywords: [ + 'octopus', + 'uc6', + 'animal', + 'wildlife', + 'pussy', + 'pokemon', + 'porn', + 'scuba', + 'seafood', + 'ocean', + 'animals', + 'animal kingdom', + 'condom', + 'snorkel', + 'sea' + ]), + Emoji( + name: 'squid', + char: '\u{1F991}', + shortName: 'squid', + emojiGroup: EmojiGroup.foodDrink, + emojiSubgroup: EmojiSubgroup.foodMarine, + keywords: [ + 'food', + 'molusc', + 'squid', + 'uc9', + 'animal', + 'wildlife', + 'scuba', + 'seafood', + 'ocean', + 'animals', + 'animal kingdom', + 'snorkel', + 'sea' + ]), + Emoji( + name: 'shrimp', + char: '\u{1F990}', + shortName: 'shrimp', + emojiGroup: EmojiGroup.foodDrink, + emojiSubgroup: EmojiSubgroup.foodMarine, + keywords: [ + 'food', + 'shellfish', + 'shrimp', + 'small', + 'uc9', + 'animal', + 'wildlife', + 'prawn', + 'scuba', + 'seafood', + 'ocean', + 'crustacean', + 'animals', + 'animal kingdom', + 'snorkel', + 'sea' + ]), + Emoji( + name: 'lobster', + char: '\u{1F99E}', + shortName: 'lobster', + emojiGroup: EmojiGroup.foodDrink, + emojiSubgroup: EmojiSubgroup.foodMarine, + keywords: [ + 'uc11', + 'animal', + 'food', + 'wildlife', + 'seafood', + 'ocean', + 'crustacean', + 'animals', + 'animal kingdom', + 'foods', + 'eat', + 'meal', + 'comida', + 'nourriture', + 'eats', + 'groceries', + 'grocery', + 'hungry', + 'tasty', + 'mmm', + 'yummy', + 'feed', + 'hunger', + 'sea' + ]), + Emoji( + name: 'crab', + char: '\u{1F980}', + shortName: 'crab', + emojiGroup: EmojiGroup.foodDrink, + emojiSubgroup: EmojiSubgroup.foodMarine, + keywords: [ + 'Cancer', + 'zodiac', + 'uc8', + 'animal', + 'wildlife', + 'tropical', + 'pokemon', + 'scuba', + 'seafood', + 'ocean', + 'crustacean', + 'animals', + 'animal kingdom', + 'snorkel', + 'sea' + ]), + Emoji( + name: 'blowfish', + char: '\u{1F421}', + shortName: 'blowfish', + emojiGroup: EmojiGroup.animalsNature, + emojiSubgroup: EmojiSubgroup.animalMarine, + keywords: [ + 'fish', + 'uc6', + 'animal', + 'wildlife', + 'japan', + 'scuba', + 'ocean', + 'animals', + 'animal kingdom', + 'japanese', + 'ninja', + 'snorkel', + 'sea' + ]), + Emoji( + name: 'tropical fish', + char: '\u{1F420}', + shortName: 'tropical_fish', + emojiGroup: EmojiGroup.animalsNature, + emojiSubgroup: EmojiSubgroup.animalMarine, + keywords: [ + 'fish', + 'tropical', + 'uc6', + 'animal', + 'wildlife', + 'tropical', + 'pets', + 'scuba', + 'ocean', + 'animals', + 'animal kingdom', + 'snorkel', + 'sea' + ]), + Emoji( + name: 'fish', + char: '\u{1F41F}', + shortName: 'fish', + emojiGroup: EmojiGroup.animalsNature, + emojiSubgroup: EmojiSubgroup.animalMarine, + keywords: [ + 'Pisces', + 'zodiac', + 'uc6', + 'animal', + 'wildlife', + 'tropical', + 'pets', + 'scuba', + 'seafood', + 'river', + 'ocean', + 'animals', + 'animal kingdom', + 'snorkel', + 'sea' + ]), + Emoji( + name: 'seal', + char: '\u{1F9AD}', + shortName: 'seal', + emojiGroup: EmojiGroup.animalsNature, + emojiSubgroup: EmojiSubgroup.animalMarine, + keywords: [ + 'uc13', + 'animal', + 'wildlife', + 'ocean', + 'animals', + 'animal kingdom', + 'sea' + ]), + Emoji( + name: 'dolphin', + char: '\u{1F42C}', + shortName: 'dolphin', + emojiGroup: EmojiGroup.animalsNature, + emojiSubgroup: EmojiSubgroup.animalMarine, + keywords: [ + 'flipper', + 'uc6', + 'animal', + 'wildlife', + 'tropical', + 'florida', + 'scuba', + 'ocean', + 'animals', + 'animal kingdom', + 'snorkel', + 'sea' + ]), + Emoji( + name: 'spouting whale', + char: '\u{1F433}', + shortName: 'whale', + emojiGroup: EmojiGroup.animalsNature, + emojiSubgroup: EmojiSubgroup.animalMarine, + keywords: [ + 'face', + 'spouting', + 'whale', + 'uc6', + 'animal', + 'wildlife', + 'tropical', + 'whales', + 'scuba', + 'ocean', + 'animals', + 'animal kingdom', + 'whale', + 'moby', + 'snorkel', + 'sea' + ]), + Emoji( + name: 'whale', + char: '\u{1F40B}', + shortName: 'whale2', + emojiGroup: EmojiGroup.animalsNature, + emojiSubgroup: EmojiSubgroup.animalMarine, + keywords: [ + 'whale', + 'uc6', + 'animal', + 'wildlife', + 'tropical', + 'whales', + 'scuba', + 'ocean', + 'animals', + 'animal kingdom', + 'whale', + 'moby', + 'snorkel', + 'sea' + ]), + Emoji( + name: 'shark', + char: '\u{1F988}', + shortName: 'shark', + emojiGroup: EmojiGroup.animalsNature, + emojiSubgroup: EmojiSubgroup.animalMarine, + keywords: [ + 'fish', + 'shark', + 'uc9', + 'animal', + 'wildlife', + 'florida', + 'scuba', + 'ocean', + 'animals', + 'animal kingdom', + 'snorkel', + 'sea' + ]), + Emoji( + name: 'crocodile', + char: '\u{1F40A}', + shortName: 'crocodile', + emojiGroup: EmojiGroup.animalsNature, + emojiSubgroup: EmojiSubgroup.animalReptile, + keywords: [ + 'crocodile', + 'uc6', + 'animal', + 'wildlife', + 'reptile', + 'florida', + 'river', + 'alligator', + 'animals', + 'animal kingdom', + 'reptiles' + ]), + Emoji( + name: 'tiger', + char: '\u{1F405}', + shortName: 'tiger2', + emojiGroup: EmojiGroup.animalsNature, + emojiSubgroup: EmojiSubgroup.animalMammal, + keywords: [ + 'tiger', + 'uc6', + 'animal', + 'wildlife', + 'roar', + 'cat', + 'circus', + 'forest', + 'pokemon', + 'animals', + 'animal kingdom', + 'rawr', + 'kitty', + 'kitten', + 'cats', + 'kittens', + 'kitties', + 'feline', + 'felines', + 'cat face', + 'gato', + 'meow', + 'circus tent', + 'clown', + 'clowns', + 'rainforest' + ]), + Emoji( + name: 'leopard', + char: '\u{1F406}', + shortName: 'leopard', + emojiGroup: EmojiGroup.animalsNature, + emojiSubgroup: EmojiSubgroup.animalMammal, + keywords: [ + 'leopard', + 'uc6', + 'animal', + 'wildlife', + 'roar', + 'cat', + 'forest', + 'animals', + 'animal kingdom', + 'rawr', + 'kitty', + 'kitten', + 'cats', + 'kittens', + 'kitties', + 'feline', + 'felines', + 'cat face', + 'gato', + 'meow', + 'rainforest' + ]), + Emoji( + name: 'zebra', + char: '\u{1F993}', + shortName: 'zebra', + emojiGroup: EmojiGroup.animalsNature, + emojiSubgroup: EmojiSubgroup.animalMammal, + keywords: [ + 'stripe', + 'uc10', + 'animal', + 'wildlife', + 'horse', + 'animals', + 'animal kingdom' + ]), + Emoji( + name: 'gorilla', + char: '\u{1F98D}', + shortName: 'gorilla', + emojiGroup: EmojiGroup.animalsNature, + emojiSubgroup: EmojiSubgroup.animalMammal, + keywords: [ + 'gorilla', + 'uc9', + 'animal', + 'wildlife', + 'forest', + 'pokemon', + 'animals', + 'animal kingdom', + 'rainforest' + ]), + Emoji( + name: 'orangutan', + char: '\u{1F9A7}', + shortName: 'orangutan', + emojiGroup: EmojiGroup.animalsNature, + emojiSubgroup: EmojiSubgroup.animalMammal, + keywords: [ + 'uc12', + 'animal', + 'wildlife', + 'monkey', + 'animals', + 'animal kingdom', + 'progi', + 'ape', + 'primate' + ]), + Emoji( + name: 'elephant', + char: '\u{1F418}', + shortName: 'elephant', + emojiGroup: EmojiGroup.animalsNature, + emojiSubgroup: EmojiSubgroup.animalMammal, + keywords: [ + 'elephant', + 'uc6', + 'animal', + 'wildlife', + 'circus', + 'elephant', + 'animals', + 'animal kingdom', + 'circus tent', + 'clown', + 'clowns' + ]), + Emoji( + name: 'mammoth', + char: '\u{1F9A3}', + shortName: 'mammoth', + emojiGroup: EmojiGroup.animalsNature, + emojiSubgroup: EmojiSubgroup.animalMammal, + keywords: [ + 'uc13', + 'animal', + 'wildlife', + 'mammuthus', + 'elephant', + 'animals', + 'animal kingdom' + ]), + Emoji( + name: 'bison', + char: '\u{1F9AC}', + shortName: 'bison', + emojiGroup: EmojiGroup.animalsNature, + emojiSubgroup: EmojiSubgroup.animalMammal, + keywords: ['uc13', 'animal', 'wildlife', 'animals', 'animal kingdom']), + Emoji( + name: 'hippopotamus', + char: '\u{1F99B}', + shortName: 'hippopotamus', + emojiGroup: EmojiGroup.animalsNature, + emojiSubgroup: EmojiSubgroup.animalMammal, + keywords: ['uc11', 'animal', 'wildlife', 'animals', 'animal kingdom']), + Emoji( + name: 'rhinoceros', + char: '\u{1F98F}', + shortName: 'rhino', + emojiGroup: EmojiGroup.animalsNature, + emojiSubgroup: EmojiSubgroup.animalMammal, + keywords: [ + 'rhinoceros', + 'uc9', + 'animal', + 'wildlife', + 'forest', + 'pokemon', + 'animals', + 'animal kingdom', + 'rainforest' + ]), + Emoji( + name: 'camel', + char: '\u{1F42A}', + shortName: 'dromedary_camel', + emojiGroup: EmojiGroup.animalsNature, + emojiSubgroup: EmojiSubgroup.animalMammal, + keywords: [ + 'dromedary', + 'hump', + 'uc6', + 'animal', + 'wildlife', + 'animals', + 'animal kingdom' + ]), + Emoji( + name: 'two-hump camel', + char: '\u{1F42B}', + shortName: 'camel', + emojiGroup: EmojiGroup.animalsNature, + emojiSubgroup: EmojiSubgroup.animalMammal, + keywords: [ + 'bactrian', + 'camel', + 'hump', + 'uc6', + 'animal', + 'wildlife', + 'hump day', + 'animals', + 'animal kingdom' + ]), + Emoji( + name: 'giraffe', + char: '\u{1F992}', + shortName: 'giraffe', + emojiGroup: EmojiGroup.animalsNature, + emojiSubgroup: EmojiSubgroup.animalMammal, + keywords: [ + 'spots', + 'uc10', + 'animal', + 'wildlife', + 'animals', + 'animal kingdom' + ]), + Emoji( + name: 'kangaroo', + char: '\u{1F998}', + shortName: 'kangaroo', + emojiGroup: EmojiGroup.animalsNature, + emojiSubgroup: EmojiSubgroup.animalMammal, + keywords: [ + 'uc11', + 'animal', + 'wildlife', + 'australia', + 'marsupial', + 'animals', + 'animal kingdom', + 'marsupials' + ]), + Emoji( + name: 'water buffalo', + char: '\u{1F403}', + shortName: 'water_buffalo', + emojiGroup: EmojiGroup.animalsNature, + emojiSubgroup: EmojiSubgroup.animalMammal, + keywords: [ + 'buffalo', + 'water', + 'uc6', + 'animal', + 'wildlife', + 'scotland', + 'animals', + 'animal kingdom', + 'scottish' + ]), + Emoji( + name: 'ox', + char: '\u{1F402}', + shortName: 'ox', + emojiGroup: EmojiGroup.animalsNature, + emojiSubgroup: EmojiSubgroup.animalMammal, + keywords: [ + 'Taurus', + 'bull', + 'zodiac', + 'uc6', + 'animal', + 'farm', + 'texas', + 'animals', + 'animal kingdom' + ]), + Emoji( + name: 'cow', + char: '\u{1F404}', + shortName: 'cow2', + emojiGroup: EmojiGroup.animalsNature, + emojiSubgroup: EmojiSubgroup.animalMammal, + keywords: [ + 'cow', + 'uc6', + 'animal', + 'farm', + 'texas', + 'animals', + 'animal kingdom' + ]), + Emoji( + name: 'horse', + char: '\u{1F40E}', + shortName: 'racehorse', + emojiGroup: EmojiGroup.animalsNature, + emojiSubgroup: EmojiSubgroup.animalMammal, + keywords: [ + 'equestrian', + 'racehorse', + 'racing', + 'uc6', + 'animal', + 'wildlife', + 'horse racing', + 'donkey', + 'farm', + 'pets', + 'pokemon', + 'texas', + 'viking', + 'horse', + 'animals', + 'animal kingdom', + 'horseback riding', + 'horse and rider', + 'horses', + 'horseshoe', + 'pony', + 'poney', + 'knight' + ]), + Emoji( + name: 'pig', + char: '\u{1F416}', + shortName: 'pig2', + emojiGroup: EmojiGroup.animalsNature, + emojiSubgroup: EmojiSubgroup.animalMammal, + keywords: [ + 'sow', + 'uc6', + 'animal', + 'pink', + 'pig', + 'farm', + 'guinea pig', + 'pets', + 'minecraft', + 'animals', + 'animal kingdom', + 'rose', + 'pork' + ]), + Emoji( + name: 'ram', + char: '\u{1F40F}', + shortName: 'ram', + emojiGroup: EmojiGroup.animalsNature, + emojiSubgroup: EmojiSubgroup.animalMammal, + keywords: [ + 'Aries', + 'male', + 'sheep', + 'zodiac', + 'uc6', + 'animal', + 'wildlife', + 'farm', + 'sheep', + 'animals', + 'animal kingdom' + ]), + Emoji( + name: 'ewe', + char: '\u{1F411}', + shortName: 'sheep', + emojiGroup: EmojiGroup.animalsNature, + emojiSubgroup: EmojiSubgroup.animalMammal, + keywords: [ + 'female', + 'sheep', + 'uc6', + 'animal', + 'farm', + 'lamb', + 'sheep', + 'animals', + 'animal kingdom' + ]), + Emoji( + name: 'llama', + char: '\u{1F999}', + shortName: 'llama', + emojiGroup: EmojiGroup.animalsNature, + emojiSubgroup: EmojiSubgroup.animalMammal, + keywords: ['uc11', 'animal', 'farm', 'animals', 'animal kingdom']), + Emoji( + name: 'goat', + char: '\u{1F410}', + shortName: 'goat', + emojiGroup: EmojiGroup.animalsNature, + emojiSubgroup: EmojiSubgroup.animalMammal, + keywords: [ + 'Capricorn', + 'zodiac', + 'uc6', + 'animal', + 'farm', + 'animals', + 'animal kingdom' + ]), + Emoji( + name: 'deer', + char: '\u{1F98C}', + shortName: 'deer', + emojiGroup: EmojiGroup.animalsNature, + emojiSubgroup: EmojiSubgroup.animalMammal, + keywords: [ + 'deer', + 'uc9', + 'animal', + 'wildlife', + 'christmas', + 'raindeer', + 'forest', + 'hunt', + 'animals', + 'animal kingdom', + 'navidad', + 'xmas', + 'noel', + 'merry christmas', + 'moose', + 'rudolph', + 'rainforest' + ]), + Emoji( + name: 'dog', + char: '\u{1F415}', + shortName: 'dog2', + emojiGroup: EmojiGroup.animalsNature, + emojiSubgroup: EmojiSubgroup.animalMammal, + keywords: [ + 'pet', + 'uc6', + 'animal', + 'dog', + 'japan', + 'pug', + 'bingo', + 'bitch', + 'farm', + 'pets', + 'pokemon', + 'minecraft', + 'hunt', + 'animals', + 'animal kingdom', + 'puppy', + 'doggy', + 'memes', + 'dogs', + 'perro', + 'puppies', + 'chien', + 'japanese', + 'ninja', + 'pugs', + 'puta', + 'pute' + ]), + Emoji( + name: 'poodle', + char: '\u{1F429}', + shortName: 'poodle', + emojiGroup: EmojiGroup.animalsNature, + emojiSubgroup: EmojiSubgroup.animalMammal, + keywords: [ + 'dog', + 'uc6', + 'animal', + 'dog', + 'pink', + 'paris', + 'bitch', + 'pets', + 'animals', + 'animal kingdom', + 'puppy', + 'doggy', + 'memes', + 'dogs', + 'perro', + 'puppies', + 'chien', + 'rose', + 'french', + 'france', + 'puta', + 'pute' + ]), + Emoji( + name: 'guide dog', + char: '\u{1F9AE}', + shortName: 'guide_dog', + emojiGroup: EmojiGroup.animalsNature, + emojiSubgroup: EmojiSubgroup.animalMammal, + keywords: [ + 'uc12', + 'animal', + 'dog', + 'handicap', + 'pets', + 'blind', + 'animals', + 'animal kingdom', + 'puppy', + 'doggy', + 'memes', + 'dogs', + 'perro', + 'puppies', + 'chien', + 'disabled', + 'disability' + ]), + Emoji( + name: 'service dog', + char: '\u{1F415}\u{200D}\u{1F9BA}', + shortName: 'service_dog', + emojiGroup: EmojiGroup.animalsNature, + emojiSubgroup: EmojiSubgroup.animalMammal, + keywords: [ + 'uc12', + 'animal', + 'dog', + 'handicap', + 'pets', + 'blind', + 'animals', + 'animal kingdom', + 'puppy', + 'doggy', + 'memes', + 'dogs', + 'perro', + 'puppies', + 'chien', + 'disabled', + 'disability' + ]), + Emoji( + name: 'cat', + char: '\u{1F408}', + shortName: 'cat2', + emojiGroup: EmojiGroup.animalsNature, + emojiSubgroup: EmojiSubgroup.animalMammal, + keywords: [ + 'pet', + 'uc6', + 'animal', + 'cat', + 'pussy', + 'grass', + 'farm', + 'pets', + 'pokemon', + 'porn', + 'animals', + 'animal kingdom', + 'kitty', + 'kitten', + 'cats', + 'kittens', + 'kitties', + 'feline', + 'felines', + 'cat face', + 'gato', + 'meow', + 'condom' + ]), + Emoji( + name: 'black cat', + char: '\u{1F408}\u{200D}\u{2B1B}', + shortName: 'black_cat', + emojiGroup: EmojiGroup.animalsNature, + emojiSubgroup: EmojiSubgroup.animalMammal, + keywords: [ + 'uc13', + 'animal', + 'halloween', + 'cat', + 'luck', + 'sol', + 'farm', + 'animals', + 'animal kingdom', + 'samhain', + 'kitty', + 'kitten', + 'cats', + 'kittens', + 'kitties', + 'feline', + 'felines', + 'cat face', + 'gato', + 'meow', + 'good luck', + 'lucky', + 'shit outta luck', + 'shit out of luck', + 'bad luck' + ]), + Emoji( + name: 'rooster', + char: '\u{1F413}', + shortName: 'rooster', + emojiGroup: EmojiGroup.animalsNature, + emojiSubgroup: EmojiSubgroup.animalBird, + keywords: [ + 'bird', + 'rooster', + 'uc6', + 'animal', + 'wildlife', + 'birds', + 'farm', + 'animals', + 'animal kingdom', + 'goose' + ]), + Emoji( + name: 'turkey', + char: '\u{1F983}', + shortName: 'turkey', + emojiGroup: EmojiGroup.animalsNature, + emojiSubgroup: EmojiSubgroup.animalBird, + keywords: [ + 'bird', + 'turkey', + 'uc8', + 'animal', + 'wildlife', + 'birds', + 'farm', + 'thanksgiving', + 'animals', + 'animal kingdom', + 'goose' + ]), + Emoji( + name: 'peacock', + char: '\u{1F99A}', + shortName: 'peacock', + emojiGroup: EmojiGroup.animalsNature, + emojiSubgroup: EmojiSubgroup.animalBird, + keywords: [ + 'uc11', + 'animal', + 'wildlife', + 'birds', + 'farm', + 'animals', + 'animal kingdom', + 'goose' + ]), + Emoji( + name: 'parrot', + char: '\u{1F99C}', + shortName: 'parrot', + emojiGroup: EmojiGroup.animalsNature, + emojiSubgroup: EmojiSubgroup.animalBird, + keywords: [ + 'uc11', + 'animal', + 'wildlife', + 'tropical', + 'pirate', + 'birds', + 'animals', + 'animal kingdom', + 'goose' + ]), + Emoji( + name: 'swan', + char: '\u{1F9A2}', + shortName: 'swan', + emojiGroup: EmojiGroup.animalsNature, + emojiSubgroup: EmojiSubgroup.animalBird, + keywords: [ + 'uc11', + 'animal', + 'wildlife', + 'birds', + 'animals', + 'animal kingdom', + 'goose' + ]), + Emoji( + name: 'flamingo', + char: '\u{1F9A9}', + shortName: 'flamingo', + emojiGroup: EmojiGroup.animalsNature, + emojiSubgroup: EmojiSubgroup.animalBird, + keywords: [ + 'uc12', + 'animal', + 'wildlife', + 'pink', + 'birds', + 'animals', + 'animal kingdom', + 'rose', + 'goose' + ]), + Emoji( + name: 'dove', + char: '\u{1F54A}\u{FE0F}', + shortName: 'dove', + emojiGroup: EmojiGroup.animalsNature, + emojiSubgroup: EmojiSubgroup.animalBird, + keywords: [ + 'bird', + 'fly', + 'peace', + 'uc7', + 'animal', + 'wildlife', + 'wedding', + 'religion', + 'peace', + 'pray', + 'birds', + 'animals', + 'animal kingdom', + 'weddings', + 'marriage', + 'newlywed', + 'bride', + 'groome', + 'groom', + 'married', + 'marry', + 'peace out', + 'peace sign', + 'prayer', + 'praying', + 'prayers', + 'grateful', + 'sorry', + 'heaven', + 'bless', + 'faith', + 'holy', + 'spirit', + 'hopeful', + 'blessed', + 'preach', + 'offering', + 'goose' + ]), + Emoji( + name: 'rabbit', + char: '\u{1F407}', + shortName: 'rabbit2', + emojiGroup: EmojiGroup.animalsNature, + emojiSubgroup: EmojiSubgroup.animalMammal, + keywords: [ + 'bunny', + 'pet', + 'uc6', + 'animal', + 'wildlife', + 'magic', + 'easter', + 'pets', + 'pokemon', + 'hunt', + 'animals', + 'animal kingdom', + 'spell', + 'genie', + 'magical' + ]), + Emoji( + name: 'raccoon', + char: '\u{1F99D}', + shortName: 'raccoon', + emojiGroup: EmojiGroup.animalsNature, + emojiSubgroup: EmojiSubgroup.animalMammal, + keywords: [ + 'uc11', + 'animal', + 'wildlife', + 'forest', + 'animals', + 'animal kingdom', + 'rainforest' + ]), + Emoji( + name: 'skunk', + char: '\u{1F9A8}', + shortName: 'skunk', + emojiGroup: EmojiGroup.animalsNature, + emojiSubgroup: EmojiSubgroup.animalMammal, + keywords: [ + 'uc12', + 'animal', + 'wildlife', + 'stinky', + 'forest', + 'animals', + 'animal kingdom', + 'smell', + 'stink', + 'odor', + 'rainforest' + ]), + Emoji( + name: 'badger', + char: '\u{1F9A1}', + shortName: 'badger', + emojiGroup: EmojiGroup.animalsNature, + emojiSubgroup: EmojiSubgroup.animalMammal, + keywords: [ + 'uc11', + 'animal', + 'wildlife', + 'forest', + 'animals', + 'animal kingdom', + 'rainforest' + ]), + Emoji( + name: 'beaver', + char: '\u{1F9AB}', + shortName: 'beaver', + emojiGroup: EmojiGroup.animalsNature, + emojiSubgroup: EmojiSubgroup.animalMammal, + keywords: [ + 'uc13', + 'animal', + 'wildlife', + 'forest', + 'rodent', + 'beaver', + 'animals', + 'animal kingdom', + 'rainforest' + ]), + Emoji( + name: 'otter', + char: '\u{1F9A6}', + shortName: 'otter', + emojiGroup: EmojiGroup.animalsNature, + emojiSubgroup: EmojiSubgroup.animalMammal, + keywords: [ + 'uc12', + 'animal', + 'wildlife', + 'forest', + 'ocean', + 'otor', + 'animals', + 'animal kingdom', + 'rainforest', + 'sea', + 'oter', + 'wódr̥' + ]), + Emoji( + name: 'sloth', + char: '\u{1F9A5}', + shortName: 'sloth', + emojiGroup: EmojiGroup.animalsNature, + emojiSubgroup: EmojiSubgroup.animalMammal, + keywords: [ + 'uc12', + 'animal', + 'wildlife', + 'forest', + 'lazy', + 'animals', + 'animal kingdom', + 'rainforest' + ]), + Emoji( + name: 'mouse', + char: '\u{1F401}', + shortName: 'mouse2', + emojiGroup: EmojiGroup.animalsNature, + emojiSubgroup: EmojiSubgroup.animalMammal, + keywords: [ + 'mouse', + 'uc6', + 'animal', + 'wildlife', + 'mickey', + 'disney', + 'pokemon', + 'rodent', + 'animals', + 'animal kingdom', + 'cartoon' + ]), + Emoji( + name: 'rat', + char: '\u{1F400}', + shortName: 'rat', + emojiGroup: EmojiGroup.animalsNature, + emojiSubgroup: EmojiSubgroup.animalMammal, + keywords: [ + 'rat', + 'uc6', + 'animal', + 'wildlife', + 'harry potter', + 'pokemon', + 'rodent', + 'animals', + 'animal kingdom' + ]), + Emoji( + name: 'chipmunk', + char: '\u{1F43F}\u{FE0F}', + shortName: 'chipmunk', + emojiGroup: EmojiGroup.animalsNature, + emojiSubgroup: EmojiSubgroup.animalMammal, + keywords: [ + 'chipmunk', + 'uc7', + 'animal', + 'wildlife', + 'squirrel', + 'forest', + 'pokemon', + 'rodent', + 'animals', + 'animal kingdom', + 'rainforest' + ]), + Emoji( + name: 'hedgehog', + char: '\u{1F994}', + shortName: 'hedgehog', + emojiGroup: EmojiGroup.animalsNature, + emojiSubgroup: EmojiSubgroup.animalMammal, + keywords: [ + 'spiny', + 'uc10', + 'animal', + 'wildlife', + 'erinaceinae', + 'animals', + 'animal kingdom' + ]), + Emoji( + name: 'paw prints', + char: '\u{1F43E}', + shortName: 'feet', + emojiGroup: EmojiGroup.animalsNature, + emojiSubgroup: EmojiSubgroup.animalMammal, + keywords: [ + 'feet', + 'paw', + 'print', + 'uc6', + 'animal', + 'paws', + 'animals', + 'animal kingdom' + ]), + Emoji( + name: 'dragon', + char: '\u{1F409}', + shortName: 'dragon', + emojiGroup: EmojiGroup.animalsNature, + emojiSubgroup: EmojiSubgroup.animalReptile, + keywords: [ + 'fairy tale', + 'uc6', + 'animal', + 'roar', + 'reptile', + 'harry potter', + 'pokemon', + 'minecraft', + 'fantasy', + 'animals', + 'animal kingdom', + 'rawr', + 'reptiles' + ]), + Emoji( + name: 'dragon face', + char: '\u{1F432}', + shortName: 'dragon_face', + emojiGroup: EmojiGroup.animalsNature, + emojiSubgroup: EmojiSubgroup.animalReptile, + keywords: [ + 'dragon', + 'face', + 'fairy tale', + 'uc6', + 'animal', + 'roar', + 'monster', + 'reptile', + 'pokemon', + 'minecraft', + 'fantasy', + 'animals', + 'animal kingdom', + 'rawr', + 'monsters', + 'beast', + 'reptiles' + ]), + Emoji( + name: 'cactus', + char: '\u{1F335}', + shortName: 'cactus', + emojiGroup: EmojiGroup.animalsNature, + emojiSubgroup: EmojiSubgroup.plantOther, + keywords: [ + 'plant', + 'uc6', + 'nature', + 'plant', + 'trees', + 'plants', + 'tree', + 'branch', + 'wood' + ]), + Emoji( + name: 'Christmas tree', + char: '\u{1F384}', + shortName: 'christmas_tree', + emojiGroup: EmojiGroup.activities, + emojiSubgroup: EmojiSubgroup.event, + keywords: [ + 'Christmas', + 'celebration', + 'tree', + 'uc6', + 'holidays', + 'plant', + 'christmas', + 'santa', + 'trees', + 'advent', + 'holiday', + 'plants', + 'navidad', + 'xmas', + 'noel', + 'merry christmas', + 'santa clause', + 'santa claus', + 'tree', + 'branch', + 'wood' + ]), + Emoji( + name: 'evergreen tree', + char: '\u{1F332}', + shortName: 'evergreen_tree', + emojiGroup: EmojiGroup.animalsNature, + emojiSubgroup: EmojiSubgroup.plantOther, + keywords: [ + 'tree', + 'uc6', + 'nature', + 'plant', + 'camp', + 'trees', + 'forest', + 'parks', + 'plants', + 'camping', + 'tent', + 'camper', + 'outdoor', + 'outside', + 'tree', + 'branch', + 'wood', + 'rainforest', + 'regional park', + 'nature park', + 'natural park' + ]), + Emoji( + name: 'deciduous tree', + char: '\u{1F333}', + shortName: 'deciduous_tree', + emojiGroup: EmojiGroup.animalsNature, + emojiSubgroup: EmojiSubgroup.plantOther, + keywords: [ + 'deciduous', + 'shedding', + 'tree', + 'uc6', + 'nature', + 'plant', + 'camp', + 'trees', + 'farm', + 'forest', + 'parks', + 'plants', + 'camping', + 'tent', + 'camper', + 'outdoor', + 'outside', + 'tree', + 'branch', + 'wood', + 'rainforest', + 'regional park', + 'nature park', + 'natural park' + ]), + Emoji( + name: 'palm tree', + char: '\u{1F334}', + shortName: 'palm_tree', + emojiGroup: EmojiGroup.animalsNature, + emojiSubgroup: EmojiSubgroup.plantOther, + keywords: [ + 'palm', + 'tree', + 'uc6', + 'nature', + 'plant', + 'tropical', + 'trees', + 'california', + 'coconut', + 'florida', + 'palma', + 'plants', + 'tree', + 'branch', + 'wood', + 'palmas' + ]), + Emoji( + name: 'seedling', + char: '\u{1F331}', + shortName: 'seedling', + emojiGroup: EmojiGroup.animalsNature, + emojiSubgroup: EmojiSubgroup.plantOther, + keywords: [ + 'young', + 'uc6', + 'nature', + 'plant', + 'trees', + 'leaf', + 'grass', + 'weed', + 'farm', + 'irish', + 'pokemon', + 'plants', + 'tree', + 'branch', + 'wood', + 'leaves', + 'saint patricks day', + 'st patricks day', + 'leprechaun' + ]), + Emoji( + name: 'herb', + char: '\u{1F33F}', + shortName: 'herb', + emojiGroup: EmojiGroup.animalsNature, + emojiSubgroup: EmojiSubgroup.plantOther, + keywords: [ + 'leaf', + 'uc6', + 'nature', + 'plant', + 'leaf', + 'grass', + 'weed', + 'farm', + 'plants', + 'leaves' + ]), + Emoji( + name: 'shamrock', + char: '\u{2618}\u{FE0F}', + shortName: 'shamrock', + emojiGroup: EmojiGroup.animalsNature, + emojiSubgroup: EmojiSubgroup.plantOther, + keywords: [ + 'plant', + 'uc4', + 'nature', + 'plant', + 'luck', + 'leaf', + 'grass', + 'irish', + 'plants', + 'good luck', + 'lucky', + 'leaves', + 'saint patricks day', + 'st patricks day', + 'leprechaun' + ]), + Emoji( + name: 'four leaf clover', + char: '\u{1F340}', + shortName: 'four_leaf_clover', + emojiGroup: EmojiGroup.animalsNature, + emojiSubgroup: EmojiSubgroup.plantOther, + keywords: [ + '4', + 'clover', + 'four', + 'leaf', + 'uc6', + 'nature', + 'plant', + 'luck', + 'leaf', + 'sol', + 'grass', + 'bingo', + 'irish', + 'plants', + 'good luck', + 'lucky', + 'leaves', + 'shit outta luck', + 'shit out of luck', + 'bad luck', + 'saint patricks day', + 'st patricks day', + 'leprechaun' + ]), + Emoji( + name: 'pine decoration', + char: '\u{1F38D}', + shortName: 'bamboo', + emojiGroup: EmojiGroup.activities, + emojiSubgroup: EmojiSubgroup.event, + keywords: [ + 'Japanese', + 'bamboo', + 'celebration', + 'pine', + 'uc6', + 'nature', + 'plant', + 'japan', + 'plants', + 'japanese', + 'ninja' + ]), + Emoji( + name: 'tanabata tree', + char: '\u{1F38B}', + shortName: 'tanabata_tree', + emojiGroup: EmojiGroup.activities, + emojiSubgroup: EmojiSubgroup.event, + keywords: [ + 'Japanese', + 'banner', + 'celebration', + 'tree', + 'uc6', + 'nature', + 'plant', + 'japan', + 'trees', + 'plants', + 'japanese', + 'ninja', + 'tree', + 'branch', + 'wood' + ]), + Emoji( + name: 'leaf fluttering in wind', + char: '\u{1F343}', + shortName: 'leaves', + emojiGroup: EmojiGroup.animalsNature, + emojiSubgroup: EmojiSubgroup.plantOther, + keywords: [ + 'blow', + 'flutter', + 'leaf', + 'wind', + 'uc6', + 'weather', + 'nature', + 'plant', + 'trees', + 'leaf', + 'storm', + 'autumn', + 'plants', + 'tree', + 'branch', + 'wood', + 'leaves', + 'fall' + ]), + Emoji( + name: 'fallen leaf', + char: '\u{1F342}', + shortName: 'fallen_leaf', + emojiGroup: EmojiGroup.animalsNature, + emojiSubgroup: EmojiSubgroup.plantOther, + keywords: [ + 'falling', + 'leaf', + 'uc6', + 'nature', + 'plant', + 'trees', + 'leaf', + 'autumn', + 'plants', + 'tree', + 'branch', + 'wood', + 'leaves', + 'fall' + ]), + Emoji( + name: 'maple leaf', + char: '\u{1F341}', + shortName: 'maple_leaf', + emojiGroup: EmojiGroup.animalsNature, + emojiSubgroup: EmojiSubgroup.plantOther, + keywords: [ + 'falling', + 'leaf', + 'maple', + 'uc6', + 'nature', + 'plant', + 'trees', + 'leaf', + 'autumn', + 'marijuana', + 'plants', + 'tree', + 'branch', + 'wood', + 'leaves', + 'fall' + ]), + Emoji( + name: 'feather', + char: '\u{1FAB6}', + shortName: 'feather', + emojiGroup: EmojiGroup.animalsNature, + emojiSubgroup: EmojiSubgroup.animalBird, + keywords: [ + 'uc13', + 'animal', + 'nature', + 'lgbt', + 'birds', + 'plume', + 'animals', + 'animal kingdom', + 'homosexual', + 'bisex', + 'transgender', + 'non binary', + 'pansexuality', + 'intersex', + 'goose', + 'quill', + 'plumage', + 'feathering', + 'pluma', + 'piuma', + 'feder' + ]), + Emoji( + name: 'mushroom', + char: '\u{1F344}', + shortName: 'mushroom', + emojiGroup: EmojiGroup.foodDrink, + emojiSubgroup: EmojiSubgroup.foodVegetable, + keywords: [ + 'toadstool', + 'uc6', + 'food', + 'nature', + 'vegetables', + 'drugs', + 'plant', + 'disney', + 'poison', + 'pokemon', + 'mushroom', + 'foods', + 'eat', + 'meal', + 'comida', + 'nourriture', + 'eats', + 'groceries', + 'grocery', + 'hungry', + 'tasty', + 'mmm', + 'yummy', + 'feed', + 'hunger', + 'vegetable', + 'veggie', + 'legume', + 'drug', + 'narcotics', + 'plants', + 'cartoon', + 'toxic', + 'toxins' + ]), + Emoji( + name: 'spiral shell', + char: '\u{1F41A}', + shortName: 'shell', + emojiGroup: EmojiGroup.animalsNature, + emojiSubgroup: EmojiSubgroup.animalMarine, + keywords: [ + 'shell', + 'spiral', + 'uc6', + 'nature', + 'tropical', + 'scuba', + 'ocean', + 'snorkel', + 'sea' + ]), + Emoji( + name: 'rock', + char: '\u{1FAA8}', + shortName: 'rock', + emojiGroup: EmojiGroup.travelPlaces, + emojiSubgroup: EmojiSubgroup.placeBuilding, + keywords: [ + 'uc13', + 'nature', + 'parks', + 'climb', + 'boulder', + 'regional park', + 'nature park', + 'natural park', + 'pebble' + ]), + Emoji( + name: 'wood', + char: '\u{1FAB5}', + shortName: 'wood', + emojiGroup: EmojiGroup.travelPlaces, + emojiSubgroup: EmojiSubgroup.placeBuilding, + keywords: [ + 'uc13', + 'nature', + 'beaver', + 'parks', + 'regional park', + 'nature park', + 'natural park' + ]), + Emoji( + name: 'sheaf of rice', + char: '\u{1F33E}', + shortName: 'ear_of_rice', + emojiGroup: EmojiGroup.animalsNature, + emojiSubgroup: EmojiSubgroup.plantOther, + keywords: [ + 'ear', + 'grain', + 'rice', + 'uc6', + 'nature', + 'plant', + 'leaf', + 'grass', + 'farm', + 'plants', + 'leaves' + ]), + Emoji( + name: 'potted plant', + char: '\u{1FAB4}', + shortName: 'potted_plant', + emojiGroup: EmojiGroup.animalsNature, + emojiSubgroup: EmojiSubgroup.plantOther, + keywords: ['uc13', 'nature', 'plant', 'leaf', 'plants', 'leaves']), + Emoji( + name: 'bouquet', + char: '\u{1F490}', + shortName: 'bouquet', + emojiGroup: EmojiGroup.animalsNature, + emojiSubgroup: EmojiSubgroup.plantFlower, + keywords: [ + 'flower', + 'uc6', + 'nature', + 'wedding', + 'flower', + 'plant', + 'love', + 'rip', + 'condolence', + 'beautiful', + 'roses', + 'weddings', + 'marriage', + 'newlywed', + 'bride', + 'groome', + 'groom', + 'married', + 'marry', + 'flowers', + 'plants', + 'i love you', + 'te amo', + "je t'aime", + 'anniversary', + 'lovin', + 'amour', + 'aimer', + 'amor', + 'valentines day', + 'enamour', + 'lovey', + 'rest in peace', + 'compassion', + 'cute', + 'pretty', + 'adorable', + 'adore', + 'beauty', + 'cutie', + 'babe', + 'lovely' + ]), + Emoji( + name: 'tulip', + char: '\u{1F337}', + shortName: 'tulip', + emojiGroup: EmojiGroup.animalsNature, + emojiSubgroup: EmojiSubgroup.plantFlower, + keywords: [ + 'flower', + 'uc6', + 'nature', + 'flower', + 'plant', + 'vagina', + 'beautiful', + 'girls night', + 'pink', + 'easter', + 'flowers', + 'plants', + 'cute', + 'pretty', + 'adorable', + 'adore', + 'beauty', + 'cutie', + 'babe', + 'lovely', + 'ladies night', + 'girls only', + 'girlfriend', + 'rose' + ]), + Emoji( + name: 'rose', + char: '\u{1F339}', + shortName: 'rose', + emojiGroup: EmojiGroup.animalsNature, + emojiSubgroup: EmojiSubgroup.plantFlower, + keywords: [ + 'flower', + 'uc6', + 'nature', + 'flower', + 'plant', + 'love', + 'rip', + 'condolence', + 'beautiful', + 'flowers', + 'plants', + 'i love you', + 'te amo', + "je t'aime", + 'anniversary', + 'lovin', + 'amour', + 'aimer', + 'amor', + 'valentines day', + 'enamour', + 'lovey', + 'rest in peace', + 'compassion', + 'cute', + 'pretty', + 'adorable', + 'adore', + 'beauty', + 'cutie', + 'babe', + 'lovely' + ]), + Emoji( + name: 'wilted flower', + char: '\u{1F940}', + shortName: 'wilted_rose', + emojiGroup: EmojiGroup.animalsNature, + emojiSubgroup: EmojiSubgroup.plantFlower, + keywords: [ + 'flower', + 'wilted', + 'uc9', + 'flower', + 'halloween', + 'plant', + 'dead', + 'flowers', + 'samhain', + 'plants', + 'death', + 'die', + 'dying', + 'fart', + 'goth', + 'grave', + 'headstone', + 'horror', + 'hurt', + 'kill', + 'murder', + 'tomb', + 'toot', + 'died' + ]), + Emoji( + name: 'hibiscus', + char: '\u{1F33A}', + shortName: 'hibiscus', + emojiGroup: EmojiGroup.animalsNature, + emojiSubgroup: EmojiSubgroup.plantFlower, + keywords: [ + 'flower', + 'uc6', + 'nature', + 'flower', + 'plant', + 'tropical', + 'beautiful', + 'pink', + 'flowers', + 'plants', + 'cute', + 'pretty', + 'adorable', + 'adore', + 'beauty', + 'cutie', + 'babe', + 'lovely', + 'rose' + ]), + Emoji( + name: 'cherry blossom', + char: '\u{1F338}', + shortName: 'cherry_blossom', + emojiGroup: EmojiGroup.animalsNature, + emojiSubgroup: EmojiSubgroup.plantFlower, + keywords: [ + 'blossom', + 'cherry', + 'flower', + 'uc6', + 'nature', + 'flower', + 'plant', + 'japan', + 'tropical', + 'beautiful', + 'hawaii', + 'pink', + 'sakura', + 'flowers', + 'plants', + 'japanese', + 'ninja', + 'cute', + 'pretty', + 'adorable', + 'adore', + 'beauty', + 'cutie', + 'babe', + 'lovely', + 'aloha', + 'kawaii', + 'maui', + 'moana', + 'rose' + ]), + Emoji( + name: 'blossom', + char: '\u{1F33C}', + shortName: 'blossom', + emojiGroup: EmojiGroup.animalsNature, + emojiSubgroup: EmojiSubgroup.plantFlower, + keywords: [ + 'flower', + 'uc6', + 'nature', + 'flower', + 'plant', + 'vagina', + 'beautiful', + 'flowers', + 'plants', + 'cute', + 'pretty', + 'adorable', + 'adore', + 'beauty', + 'cutie', + 'babe', + 'lovely' + ]), + Emoji( + name: 'sunflower', + char: '\u{1F33B}', + shortName: 'sunflower', + emojiGroup: EmojiGroup.animalsNature, + emojiSubgroup: EmojiSubgroup.plantFlower, + keywords: [ + 'flower', + 'sun', + 'uc6', + 'nature', + 'flower', + 'plant', + 'beautiful', + 'farm', + 'flowers', + 'plants', + 'cute', + 'pretty', + 'adorable', + 'adore', + 'beauty', + 'cutie', + 'babe', + 'lovely' + ]), + Emoji( + name: 'sun with face', + char: '\u{1F31E}', + shortName: 'sun_with_face', + emojiGroup: EmojiGroup.travelPlaces, + emojiSubgroup: EmojiSubgroup.skyWeather, + keywords: [ + 'bright', + 'face', + 'sun', + 'uc6', + 'sun', + 'sky', + 'day', + 'hump day', + 'morning', + 'sunglasses', + 'california', + 'pokemon', + 'las vegas', + 'summer', + 'sunshine', + 'sunny', + 'eclipse', + 'solar', + 'solareclipse', + 'shiny', + 'good morning', + 'shades', + 'lunettes de soleil', + 'sun glasses', + 'vegas', + 'weekend' + ]), + Emoji( + name: 'full moon face', + char: '\u{1F31D}', + shortName: 'full_moon_with_face', + emojiGroup: EmojiGroup.travelPlaces, + emojiSubgroup: EmojiSubgroup.skyWeather, + keywords: [ + 'bright', + 'face', + 'full', + 'moon', + 'uc6', + 'halloween', + 'space', + 'sky', + 'moon', + 'goodnight', + 'samhain', + 'outer space', + 'galaxy', + 'universe', + 'nasa', + 'spaceship', + 'night', + 'moons', + 'lunar', + 'lunareclipse', + 'lunar eclipse' + ]), + Emoji( + name: 'first quarter moon face', + char: '\u{1F31B}', + shortName: 'first_quarter_moon_with_face', + emojiGroup: EmojiGroup.travelPlaces, + emojiSubgroup: EmojiSubgroup.skyWeather, + keywords: [ + 'face', + 'moon', + 'quarter', + 'uc6', + 'space', + 'sky', + 'moon', + 'goodnight', + 'outer space', + 'galaxy', + 'universe', + 'nasa', + 'spaceship', + 'night', + 'moons', + 'lunar', + 'lunareclipse', + 'lunar eclipse' + ]), + Emoji( + name: 'last quarter moon face', + char: '\u{1F31C}', + shortName: 'last_quarter_moon_with_face', + emojiGroup: EmojiGroup.travelPlaces, + emojiSubgroup: EmojiSubgroup.skyWeather, + keywords: [ + 'face', + 'moon', + 'quarter', + 'uc6', + 'space', + 'sky', + 'moon', + 'goodnight', + 'outer space', + 'galaxy', + 'universe', + 'nasa', + 'spaceship', + 'night', + 'moons', + 'lunar', + 'lunareclipse', + 'lunar eclipse' + ]), + Emoji( + name: 'new moon face', + char: '\u{1F31A}', + shortName: 'new_moon_with_face', + emojiGroup: EmojiGroup.travelPlaces, + emojiSubgroup: EmojiSubgroup.skyWeather, + keywords: [ + 'face', + 'moon', + 'uc6', + 'space', + 'sky', + 'moon', + 'goodnight', + 'pokemon', + 'outer space', + 'galaxy', + 'universe', + 'nasa', + 'spaceship', + 'night', + 'moons', + 'lunar', + 'lunareclipse', + 'lunar eclipse' + ]), + Emoji( + name: 'full moon', + char: '\u{1F315}', + shortName: 'full_moon', + emojiGroup: EmojiGroup.travelPlaces, + emojiSubgroup: EmojiSubgroup.skyWeather, + keywords: [ + 'full', + 'moon', + 'uc6', + 'halloween', + 'space', + 'sky', + 'moon', + 'goodnight', + 'samhain', + 'outer space', + 'galaxy', + 'universe', + 'nasa', + 'spaceship', + 'night', + 'moons', + 'lunar', + 'lunareclipse', + 'lunar eclipse' + ]), + Emoji( + name: 'waning gibbous moon', + char: '\u{1F316}', + shortName: 'waning_gibbous_moon', + emojiGroup: EmojiGroup.travelPlaces, + emojiSubgroup: EmojiSubgroup.skyWeather, + keywords: [ + 'gibbous', + 'moon', + 'waning', + 'uc6', + 'space', + 'sky', + 'moon', + 'goodnight', + 'outer space', + 'galaxy', + 'universe', + 'nasa', + 'spaceship', + 'night', + 'moons', + 'lunar', + 'lunareclipse', + 'lunar eclipse' + ]), + Emoji( + name: 'last quarter moon', + char: '\u{1F317}', + shortName: 'last_quarter_moon', + emojiGroup: EmojiGroup.travelPlaces, + emojiSubgroup: EmojiSubgroup.skyWeather, + keywords: [ + 'moon', + 'quarter', + 'uc6', + 'space', + 'sky', + 'moon', + 'goodnight', + 'outer space', + 'galaxy', + 'universe', + 'nasa', + 'spaceship', + 'night', + 'moons', + 'lunar', + 'lunareclipse', + 'lunar eclipse' + ]), + Emoji( + name: 'waning crescent moon', + char: '\u{1F318}', + shortName: 'waning_crescent_moon', + emojiGroup: EmojiGroup.travelPlaces, + emojiSubgroup: EmojiSubgroup.skyWeather, + keywords: [ + 'crescent', + 'moon', + 'waning', + 'uc6', + 'space', + 'sky', + 'moon', + 'goodnight', + 'outer space', + 'galaxy', + 'universe', + 'nasa', + 'spaceship', + 'night', + 'moons', + 'lunar', + 'lunareclipse', + 'lunar eclipse' + ]), + Emoji( + name: 'new moon', + char: '\u{1F311}', + shortName: 'new_moon', + emojiGroup: EmojiGroup.travelPlaces, + emojiSubgroup: EmojiSubgroup.skyWeather, + keywords: [ + 'dark', + 'moon', + 'uc6', + 'halloween', + 'space', + 'sky', + 'moon', + 'goodnight', + 'samhain', + 'outer space', + 'galaxy', + 'universe', + 'nasa', + 'spaceship', + 'night', + 'moons', + 'lunar', + 'lunareclipse', + 'lunar eclipse' + ]), + Emoji( + name: 'waxing crescent moon', + char: '\u{1F312}', + shortName: 'waxing_crescent_moon', + emojiGroup: EmojiGroup.travelPlaces, + emojiSubgroup: EmojiSubgroup.skyWeather, + keywords: [ + 'crescent', + 'moon', + 'waxing', + 'uc6', + 'space', + 'sky', + 'moon', + 'goodnight', + 'outer space', + 'galaxy', + 'universe', + 'nasa', + 'spaceship', + 'night', + 'moons', + 'lunar', + 'lunareclipse', + 'lunar eclipse' + ]), + Emoji( + name: 'first quarter moon', + char: '\u{1F313}', + shortName: 'first_quarter_moon', + emojiGroup: EmojiGroup.travelPlaces, + emojiSubgroup: EmojiSubgroup.skyWeather, + keywords: [ + 'moon', + 'quarter', + 'uc6', + 'space', + 'sky', + 'moon', + 'goodnight', + 'outer space', + 'galaxy', + 'universe', + 'nasa', + 'spaceship', + 'night', + 'moons', + 'lunar', + 'lunareclipse', + 'lunar eclipse' + ]), + Emoji( + name: 'waxing gibbous moon', + char: '\u{1F314}', + shortName: 'waxing_gibbous_moon', + emojiGroup: EmojiGroup.travelPlaces, + emojiSubgroup: EmojiSubgroup.skyWeather, + keywords: [ + 'gibbous', + 'moon', + 'waxing', + 'uc6', + 'space', + 'sky', + 'moon', + 'goodnight', + 'outer space', + 'galaxy', + 'universe', + 'nasa', + 'spaceship', + 'night', + 'moons', + 'lunar', + 'lunareclipse', + 'lunar eclipse' + ]), + Emoji( + name: 'crescent moon', + char: '\u{1F319}', + shortName: 'crescent_moon', + emojiGroup: EmojiGroup.travelPlaces, + emojiSubgroup: EmojiSubgroup.skyWeather, + keywords: [ + 'crescent', + 'moon', + 'uc6', + 'space', + 'sky', + 'moon', + 'goodnight', + 'outer space', + 'galaxy', + 'universe', + 'nasa', + 'spaceship', + 'night', + 'moons', + 'lunar', + 'lunareclipse', + 'lunar eclipse' + ]), + Emoji( + name: 'globe showing Americas', + char: '\u{1F30E}', + shortName: 'earth_americas', + emojiGroup: EmojiGroup.travelPlaces, + emojiSubgroup: EmojiSubgroup.placeMap, + keywords: [ + 'Americas', + 'earth', + 'globe', + 'world', + 'uc6', + 'weather', + 'america', + 'space', + 'map', + 'vacation', + 'globe', + 'history', + 'world', + 'usa', + 'united states', + 'united states of america', + 'american', + 'outer space', + 'galaxy', + 'universe', + 'nasa', + 'spaceship', + 'maps', + 'location', + 'locate', + 'local', + 'lost', + 'globes', + 'planet', + 'earth', + 'earthquake', + 'ancient', + 'old' + ]), + Emoji( + name: 'globe showing Europe-Africa', + char: '\u{1F30D}', + shortName: 'earth_africa', + emojiGroup: EmojiGroup.travelPlaces, + emojiSubgroup: EmojiSubgroup.placeMap, + keywords: [ + 'Africa', + 'Europe', + 'earth', + 'globe', + 'world', + 'uc6', + 'space', + 'map', + 'vacation', + 'globe', + 'history', + 'world', + 'outer space', + 'galaxy', + 'universe', + 'nasa', + 'spaceship', + 'maps', + 'location', + 'locate', + 'local', + 'lost', + 'globes', + 'planet', + 'earth', + 'earthquake', + 'ancient', + 'old' + ]), + Emoji( + name: 'globe showing Asia-Australia', + char: '\u{1F30F}', + shortName: 'earth_asia', + emojiGroup: EmojiGroup.travelPlaces, + emojiSubgroup: EmojiSubgroup.placeMap, + keywords: [ + 'Asia', + 'Australia', + 'earth', + 'globe', + 'world', + 'uc6', + 'space', + 'map', + 'vacation', + 'globe', + 'history', + 'world', + 'outer space', + 'galaxy', + 'universe', + 'nasa', + 'spaceship', + 'maps', + 'location', + 'locate', + 'local', + 'lost', + 'globes', + 'planet', + 'earth', + 'earthquake', + 'ancient', + 'old' + ]), + Emoji( + name: 'ringed planet', + char: '\u{1FA90}', + shortName: 'ringed_planet', + emojiGroup: EmojiGroup.travelPlaces, + emojiSubgroup: EmojiSubgroup.skyWeather, + keywords: [ + 'uc12', + 'space', + 'saturn', + 'world', + 'outer space', + 'galaxy', + 'universe', + 'nasa', + 'spaceship', + 'saturnine' + ]), + Emoji( + name: 'dizzy', + char: '\u{1F4AB}', + shortName: 'dizzy', + emojiGroup: EmojiGroup.smileysEmotion, + emojiSubgroup: EmojiSubgroup.emotion, + keywords: [ + 'comic', + 'star', + 'uc6', + 'star', + 'star wars', + 'drunk', + 'hit', + 'anime', + 'stars', + 'flustered', + 'dizzy', + 'punch', + 'pow', + 'bam', + 'manga' + ]), + Emoji( + name: 'star', + char: '\u{2B50}', + shortName: 'star', + emojiGroup: EmojiGroup.travelPlaces, + emojiSubgroup: EmojiSubgroup.skyWeather, + keywords: [ + 'star', + 'uc5', + 'space', + 'sky', + 'star', + 'star wars', + 'fame', + 'texas', + 'outer space', + 'galaxy', + 'universe', + 'nasa', + 'spaceship', + 'stars', + 'famous', + 'celebrity' + ]), + Emoji( + name: 'glowing star', + char: '\u{1F31F}', + shortName: 'star2', + emojiGroup: EmojiGroup.travelPlaces, + emojiSubgroup: EmojiSubgroup.skyWeather, + keywords: [ + 'glittery', + 'glow', + 'shining', + 'sparkle', + 'star', + 'uc6', + 'space', + 'sky', + 'star', + 'christmas', + 'star wars', + 'fame', + 'sparkle', + 'outer space', + 'galaxy', + 'universe', + 'nasa', + 'spaceship', + 'stars', + 'navidad', + 'xmas', + 'noel', + 'merry christmas', + 'famous', + 'celebrity', + 'bright', + 'shine', + 'twinkle' + ]), + Emoji( + name: 'sparkles', + char: '\u{2728}', + shortName: 'sparkles', + emojiGroup: EmojiGroup.activities, + emojiSubgroup: EmojiSubgroup.event, + keywords: [ + 'sparkle', + 'star', + 'uc6', + 'star', + 'birthday', + 'girls night', + 'magic', + 'glitter', + 'bling', + 'fame', + 'sparkle', + 'stars', + 'birth', + 'cumpleaños', + 'anniversaire', + 'bday', + 'ladies night', + 'girls only', + 'girlfriend', + 'spell', + 'genie', + 'magical', + 'jewels', + 'gems', + 'jewel', + 'jewelry', + 'treasure', + 'famous', + 'celebrity', + 'bright', + 'shine', + 'twinkle' + ]), + Emoji( + name: 'high voltage', + char: '\u{26A1}', + shortName: 'zap', + emojiGroup: EmojiGroup.travelPlaces, + emojiSubgroup: EmojiSubgroup.skyWeather, + keywords: [ + 'danger', + 'electric', + 'electricity', + 'lightning', + 'voltage', + 'zap', + 'uc4', + 'weather', + 'halloween', + 'sky', + 'diarrhea', + 'lightning', + 'electric', + 'harry potter', + 'magic', + 'power', + 'storm', + 'bling', + 'pokemon', + 'samhain', + 'shits', + 'the shits', + 'spell', + 'genie', + 'magical', + 'jewels', + 'gems', + 'jewel', + 'jewelry', + 'treasure' + ]), + Emoji( + name: 'comet', + char: '\u{2604}\u{FE0F}', + shortName: 'comet', + emojiGroup: EmojiGroup.travelPlaces, + emojiSubgroup: EmojiSubgroup.skyWeather, + keywords: [ + 'space', + 'uc1', + 'space', + 'sky', + 'outer space', + 'galaxy', + 'universe', + 'nasa', + 'spaceship' + ]), + Emoji( + name: 'collision', + char: '\u{1F4A5}', + shortName: 'boom', + emojiGroup: EmojiGroup.smileysEmotion, + emojiSubgroup: EmojiSubgroup.emotion, + keywords: [ + 'boom', + 'comic', + 'uc6', + 'blast', + 'explosion', + 'power', + 'flame', + 'anime', + 'sparkle', + 'boom', + 'explode', + 'burn', + 'match', + 'flames', + 'manga', + 'bright', + 'shine', + 'twinkle' + ]), + Emoji( + name: 'fire', + char: '\u{1F525}', + shortName: 'fire', + emojiGroup: EmojiGroup.travelPlaces, + emojiSubgroup: EmojiSubgroup.skyWeather, + keywords: [ + 'flame', + 'tool', + 'uc6', + 'love', + 'christmas', + 'wth', + 'hot', + 'harry potter', + 'flame', + 'jewish', + 'porn', + 'independence day', + 'fires', + 'i love you', + 'te amo', + "je t'aime", + 'anniversary', + 'lovin', + 'amour', + 'aimer', + 'amor', + 'valentines day', + 'enamour', + 'lovey', + 'navidad', + 'xmas', + 'noel', + 'merry christmas', + 'what the hell', + 'heat', + 'warm', + 'caliente', + 'chaud', + 'heiß', + 'burn', + 'match', + 'flames', + 'hannukah', + 'hanukkah', + 'israel', + '4th of july' + ]), + Emoji( + name: 'tornado', + char: '\u{1F32A}\u{FE0F}', + shortName: 'cloud_tornado', + emojiGroup: EmojiGroup.travelPlaces, + emojiSubgroup: EmojiSubgroup.skyWeather, + keywords: [ + 'cloud', + 'whirlwind', + 'uc7', + 'weather', + 'sky', + 'power', + 'storm', + 'clean', + 'texas' + ]), + Emoji( + name: 'rainbow', + char: '\u{1F308}', + shortName: 'rainbow', + emojiGroup: EmojiGroup.travelPlaces, + emojiSubgroup: EmojiSubgroup.skyWeather, + keywords: [ + 'rain', + 'uc6', + 'weather', + 'gay', + 'sky', + 'rain', + 'rainbow', + 'gay pride', + 'hawaii', + 'color', + 'glitter', + 'easter', + 'irish', + 'mirror', + 'twink', + 'aloha', + 'kawaii', + 'maui', + 'moana', + 'colour', + 'coloring', + 'colouring', + 'drawing', + 'marker', + 'sketch', + 'saint patricks day', + 'st patricks day', + 'leprechaun' + ]), + Emoji( + name: 'sun', + char: '\u{2600}\u{FE0F}', + shortName: 'sunny', + emojiGroup: EmojiGroup.travelPlaces, + emojiSubgroup: EmojiSubgroup.skyWeather, + keywords: [ + 'bright', + 'rays', + 'sunny', + 'uc1', + 'weather', + 'sun', + 'space', + 'sky', + 'day', + 'hot', + 'morning', + 'sunglasses', + 'power', + 'california', + 'las vegas', + 'summer', + 'independence day', + 'sunshine', + 'sunny', + 'eclipse', + 'solar', + 'solareclipse', + 'shiny', + 'outer space', + 'galaxy', + 'universe', + 'nasa', + 'spaceship', + 'heat', + 'warm', + 'caliente', + 'chaud', + 'heiß', + 'good morning', + 'shades', + 'lunettes de soleil', + 'sun glasses', + 'vegas', + 'weekend', + '4th of july' + ]), + Emoji( + name: 'sun behind small cloud', + char: '\u{1F324}\u{FE0F}', + shortName: 'white_sun_small_cloud', + emojiGroup: EmojiGroup.travelPlaces, + emojiSubgroup: EmojiSubgroup.skyWeather, + keywords: [ + 'cloud', + 'sun', + 'uc7', + 'weather', + 'sun', + 'sky', + 'cloud', + 'sunshine', + 'sunny', + 'eclipse', + 'solar', + 'solareclipse', + 'shiny', + 'clouds', + 'nuage' + ]), + Emoji( + name: 'sun behind cloud', + char: '\u{26C5}', + shortName: 'partly_sunny', + emojiGroup: EmojiGroup.travelPlaces, + emojiSubgroup: EmojiSubgroup.skyWeather, + keywords: [ + 'cloud', + 'sun', + 'uc5', + 'weather', + 'sun', + 'sky', + 'cloud', + 'sunshine', + 'sunny', + 'eclipse', + 'solar', + 'solareclipse', + 'shiny', + 'clouds', + 'nuage' + ]), + Emoji( + name: 'sun behind large cloud', + char: '\u{1F325}\u{FE0F}', + shortName: 'white_sun_cloud', + emojiGroup: EmojiGroup.travelPlaces, + emojiSubgroup: EmojiSubgroup.skyWeather, + keywords: [ + 'cloud', + 'sun', + 'uc7', + 'weather', + 'sun', + 'sky', + 'cloud', + 'cold', + 'sunshine', + 'sunny', + 'eclipse', + 'solar', + 'solareclipse', + 'shiny', + 'clouds', + 'nuage', + 'chilly', + 'chilled', + 'brisk', + 'freezing', + 'frostbite', + 'icicles' + ]), + Emoji( + name: 'cloud', + char: '\u{2601}\u{FE0F}', + shortName: 'cloud', + emojiGroup: EmojiGroup.travelPlaces, + emojiSubgroup: EmojiSubgroup.skyWeather, + keywords: [ + 'weather', + 'uc1', + 'weather', + 'sky', + 'cloud', + 'cold', + 'dream', + 'clouds', + 'nuage', + 'chilly', + 'chilled', + 'brisk', + 'freezing', + 'frostbite', + 'icicles', + 'dreams' + ]), + Emoji( + name: 'sun behind rain cloud', + char: '\u{1F326}\u{FE0F}', + shortName: 'white_sun_rain_cloud', + emojiGroup: EmojiGroup.travelPlaces, + emojiSubgroup: EmojiSubgroup.skyWeather, + keywords: [ + 'cloud', + 'rain', + 'sun', + 'uc7', + 'weather', + 'sun', + 'sky', + 'cloud', + 'rain', + 'cold', + 'sunshine', + 'sunny', + 'eclipse', + 'solar', + 'solareclipse', + 'shiny', + 'clouds', + 'nuage', + 'chilly', + 'chilled', + 'brisk', + 'freezing', + 'frostbite', + 'icicles' + ]), + Emoji( + name: 'cloud with rain', + char: '\u{1F327}\u{FE0F}', + shortName: 'cloud_rain', + emojiGroup: EmojiGroup.travelPlaces, + emojiSubgroup: EmojiSubgroup.skyWeather, + keywords: [ + 'cloud', + 'rain', + 'uc7', + 'weather', + 'winter', + 'sky', + 'cloud', + 'rain', + 'cold', + 'clouds', + 'nuage', + 'chilly', + 'chilled', + 'brisk', + 'freezing', + 'frostbite', + 'icicles' + ]), + Emoji( + name: 'cloud with lightning and rain', + char: '\u{26C8}\u{FE0F}', + shortName: 'thunder_cloud_rain', + emojiGroup: EmojiGroup.travelPlaces, + emojiSubgroup: EmojiSubgroup.skyWeather, + keywords: [ + 'cloud', + 'rain', + 'thunder', + 'uc5', + 'weather', + 'sky', + 'cloud', + 'rain', + 'cold', + 'lightning', + 'storm', + 'clouds', + 'nuage', + 'chilly', + 'chilled', + 'brisk', + 'freezing', + 'frostbite', + 'icicles' + ]), + Emoji( + name: 'cloud with lightning', + char: '\u{1F329}\u{FE0F}', + shortName: 'cloud_lightning', + emojiGroup: EmojiGroup.travelPlaces, + emojiSubgroup: EmojiSubgroup.skyWeather, + keywords: [ + 'cloud', + 'lightning', + 'uc7', + 'weather', + 'halloween', + 'sky', + 'cloud', + 'rain', + 'cold', + 'lightning', + 'storm', + 'samhain', + 'clouds', + 'nuage', + 'chilly', + 'chilled', + 'brisk', + 'freezing', + 'frostbite', + 'icicles' + ]), + Emoji( + name: 'cloud with snow', + char: '\u{1F328}\u{FE0F}', + shortName: 'cloud_snow', + emojiGroup: EmojiGroup.travelPlaces, + emojiSubgroup: EmojiSubgroup.skyWeather, + keywords: [ + 'cloud', + 'cold', + 'snow', + 'uc7', + 'weather', + 'winter', + 'sky', + 'cloud', + 'snow', + 'cold', + 'clouds', + 'nuage', + 'freeze', + 'frozen', + 'frost', + 'ice cube', + 'chilly', + 'chilled', + 'brisk', + 'freezing', + 'frostbite', + 'icicles' + ]), + Emoji( + name: 'snowflake', + char: '\u{2744}\u{FE0F}', + shortName: 'snowflake', + emojiGroup: EmojiGroup.travelPlaces, + emojiSubgroup: EmojiSubgroup.skyWeather, + keywords: [ + 'cold', + 'snow', + 'uc1', + 'weather', + 'winter', + 'sky', + 'snow', + 'christmas', + 'cold', + 'freeze', + 'frozen', + 'frost', + 'ice cube', + 'navidad', + 'xmas', + 'noel', + 'merry christmas', + 'chilly', + 'chilled', + 'brisk', + 'freezing', + 'frostbite', + 'icicles' + ]), + Emoji( + name: 'snowman', + char: '\u{2603}\u{FE0F}', + shortName: 'snowman2', + emojiGroup: EmojiGroup.travelPlaces, + emojiSubgroup: EmojiSubgroup.skyWeather, + keywords: [ + 'cold', + 'snow', + 'uc1', + 'weather', + 'holidays', + 'winter', + 'snow', + 'christmas', + 'cold', + 'holiday', + 'freeze', + 'frozen', + 'frost', + 'ice cube', + 'navidad', + 'xmas', + 'noel', + 'merry christmas', + 'chilly', + 'chilled', + 'brisk', + 'freezing', + 'frostbite', + 'icicles' + ]), + Emoji( + name: 'snowman without snow', + char: '\u{26C4}', + shortName: 'snowman', + emojiGroup: EmojiGroup.travelPlaces, + emojiSubgroup: EmojiSubgroup.skyWeather, + keywords: [ + 'cold', + 'snow', + 'snowman', + 'uc5', + 'weather', + 'winter', + 'snow', + 'christmas', + 'cold', + 'freeze', + 'frozen', + 'frost', + 'ice cube', + 'navidad', + 'xmas', + 'noel', + 'merry christmas', + 'chilly', + 'chilled', + 'brisk', + 'freezing', + 'frostbite', + 'icicles' + ]), + Emoji( + name: 'wind face', + char: '\u{1F32C}\u{FE0F}', + shortName: 'wind_blowing_face', + emojiGroup: EmojiGroup.travelPlaces, + emojiSubgroup: EmojiSubgroup.skyWeather, + keywords: [ + 'blow', + 'cloud', + 'face', + 'wind', + 'uc7', + 'weather', + 'winter', + 'smoking', + 'cold', + 'power', + 'dream', + 'autumn', + 'breathe', + 'smoke', + 'cigarette', + 'puff', + 'chilly', + 'chilled', + 'brisk', + 'freezing', + 'frostbite', + 'icicles', + 'dreams', + 'fall', + 'sigh', + 'inhale' + ]), + Emoji( + name: 'dashing away', + char: '\u{1F4A8}', + shortName: 'dash', + emojiGroup: EmojiGroup.smileysEmotion, + emojiSubgroup: EmojiSubgroup.emotion, + keywords: [ + 'comic', + 'dash', + 'running', + 'uc6', + 'cloud', + 'smoking', + 'cold', + 'clouds', + 'nuage', + 'smoke', + 'cigarette', + 'puff', + 'chilly', + 'chilled', + 'brisk', + 'freezing', + 'frostbite', + 'icicles' + ]), + Emoji( + name: 'droplet', + char: '\u{1F4A7}', + shortName: 'droplet', + emojiGroup: EmojiGroup.travelPlaces, + emojiSubgroup: EmojiSubgroup.skyWeather, + keywords: [ + 'cold', + 'comic', + 'drop', + 'sweat', + 'uc6', + 'weather', + 'sky', + 'rain', + 'sweat', + 'drip', + 'anime', + 'water', + 'manga', + 'water drop' + ]), + Emoji( + name: 'sweat droplets', + char: '\u{1F4A6}', + shortName: 'sweat_drops', + emojiGroup: EmojiGroup.smileysEmotion, + emojiSubgroup: EmojiSubgroup.emotion, + keywords: [ + 'comic', + 'splashing', + 'sweat', + 'uc6', + 'rain', + 'stressed', + 'sweat', + 'clean', + 'drip', + 'porn', + 'anime', + 'water', + 'manga', + 'water drop' + ]), + Emoji( + name: 'umbrella with rain drops', + char: '\u{2614}', + shortName: 'umbrella', + emojiGroup: EmojiGroup.travelPlaces, + emojiSubgroup: EmojiSubgroup.skyWeather, + keywords: [ + 'clothing', + 'drop', + 'rain', + 'umbrella', + 'uc4', + 'weather', + 'sky', + 'rain', + 'cold', + 'umbrella', + 'england', + 'chilly', + 'chilled', + 'brisk', + 'freezing', + 'frostbite', + 'icicles', + 'united kingdom', + 'london', + 'uk' + ]), + Emoji( + name: 'umbrella', + char: '\u{2602}\u{FE0F}', + shortName: 'umbrella2', + emojiGroup: EmojiGroup.travelPlaces, + emojiSubgroup: EmojiSubgroup.skyWeather, + keywords: [ + 'clothing', + 'rain', + 'uc1', + 'weather', + 'sky', + 'umbrella', + 'summer', + 'weekend' + ]), + Emoji( + name: 'water wave', + char: '\u{1F30A}', + shortName: 'ocean', + emojiGroup: EmojiGroup.travelPlaces, + emojiSubgroup: EmojiSubgroup.skyWeather, + keywords: [ + 'ocean', + 'water', + 'wave', + 'uc6', + 'weather', + 'boat', + 'tropical', + 'swim', + 'hawaii', + 'storm', + 'mermaid', + 'california', + 'florida', + 'scuba', + 'waves', + 'ocean', + 'boats', + 'boating', + 'swimming', + 'swimmer', + 'aloha', + 'kawaii', + 'maui', + 'moana', + 'merboy', + 'mergirl', + 'merman', + 'merperson', + 'selkie', + 'undine', + 'atargatis', + 'siren', + 'snorkel', + 'sea' + ]), + Emoji( + name: 'fog', + char: '\u{1F32B}\u{FE0F}', + shortName: 'fog', + emojiGroup: EmojiGroup.travelPlaces, + emojiSubgroup: EmojiSubgroup.skyWeather, + keywords: [ + 'cloud', + 'uc7', + 'weather', + 'sky', + 'cold', + 'steam', + 'chilly', + 'chilled', + 'brisk', + 'freezing', + 'frostbite', + 'icicles', + 'steaming', + 'piping' + ]), + Emoji( + name: 'green apple', + char: '\u{1F34F}', + shortName: 'green_apple', + emojiGroup: EmojiGroup.foodDrink, + emojiSubgroup: EmojiSubgroup.foodFruit, + keywords: [ + 'apple', + 'fruit', + 'green', + 'uc6', + 'food', + 'fruit', + 'classroom', + 'apples', + 'diet', + 'snacks', + 'picnic', + 'vegetarian', + 'foods', + 'eat', + 'meal', + 'comida', + 'nourriture', + 'eats', + 'groceries', + 'grocery', + 'hungry', + 'tasty', + 'mmm', + 'yummy', + 'feed', + 'hunger', + 'school', + 'teach', + 'learn', + 'study', + 'college', + 'degree', + 'education', + 'homework', + 'student', + 'teacher', + 'university', + 'test', + 'learning', + 'snack' + ]), + Emoji( + name: 'red apple', + char: '\u{1F34E}', + shortName: 'apple', + emojiGroup: EmojiGroup.foodDrink, + emojiSubgroup: EmojiSubgroup.foodFruit, + keywords: [ + 'apple', + 'fruit', + 'red', + 'uc6', + 'food', + 'fruit', + 'classroom', + 'creationism', + 'new york', + 'apples', + 'diet', + 'snacks', + 'picnic', + 'vegetarian', + 'snow white', + 'foods', + 'eat', + 'meal', + 'comida', + 'nourriture', + 'eats', + 'groceries', + 'grocery', + 'hungry', + 'tasty', + 'mmm', + 'yummy', + 'feed', + 'hunger', + 'school', + 'teach', + 'learn', + 'study', + 'college', + 'degree', + 'education', + 'homework', + 'student', + 'teacher', + 'university', + 'test', + 'learning', + 'adam & eve', + 'adam and eve', + 'snack' + ]), + Emoji( + name: 'pear', + char: '\u{1F350}', + shortName: 'pear', + emojiGroup: EmojiGroup.foodDrink, + emojiSubgroup: EmojiSubgroup.foodFruit, + keywords: [ + 'fruit', + 'uc6', + 'food', + 'fruit', + 'diet', + 'picnic', + 'vegetarian', + 'foods', + 'eat', + 'meal', + 'comida', + 'nourriture', + 'eats', + 'groceries', + 'grocery', + 'hungry', + 'tasty', + 'mmm', + 'yummy', + 'feed', + 'hunger' + ]), + Emoji( + name: 'tangerine', + char: '\u{1F34A}', + shortName: 'tangerine', + emojiGroup: EmojiGroup.foodDrink, + emojiSubgroup: EmojiSubgroup.foodFruit, + keywords: [ + 'fruit', + 'orange', + 'uc6', + 'food', + 'fruit', + 'breakfast', + 'diet', + 'donald trump', + 'florida', + 'citrus', + 'picnic', + 'orange', + 'vegetarian', + 'foods', + 'eat', + 'meal', + 'comida', + 'nourriture', + 'eats', + 'groceries', + 'grocery', + 'hungry', + 'tasty', + 'mmm', + 'yummy', + 'feed', + 'hunger', + 'petit dejeuner', + 'trump', + 'juice', + 'lime' + ]), + Emoji( + name: 'lemon', + char: '\u{1F34B}', + shortName: 'lemon', + emojiGroup: EmojiGroup.foodDrink, + emojiSubgroup: EmojiSubgroup.foodFruit, + keywords: [ + 'citrus', + 'fruit', + 'uc6', + 'food', + 'fruit', + 'diet', + 'citrus', + 'vegetarian', + 'foods', + 'eat', + 'meal', + 'comida', + 'nourriture', + 'eats', + 'groceries', + 'grocery', + 'hungry', + 'tasty', + 'mmm', + 'yummy', + 'feed', + 'hunger', + 'juice', + 'lime' + ]), + Emoji( + name: 'banana', + char: '\u{1F34C}', + shortName: 'banana', + emojiGroup: EmojiGroup.foodDrink, + emojiSubgroup: EmojiSubgroup.foodFruit, + keywords: [ + 'fruit', + 'uc6', + 'food', + 'fruit', + 'penis', + 'breakfast', + 'monkey', + 'diet', + 'snacks', + 'picnic', + 'vegetarian', + 'foods', + 'eat', + 'meal', + 'comida', + 'nourriture', + 'eats', + 'groceries', + 'grocery', + 'hungry', + 'tasty', + 'mmm', + 'yummy', + 'feed', + 'hunger', + 'dick', + 'petit dejeuner', + 'progi', + 'ape', + 'primate', + 'snack' + ]), + Emoji( + name: 'watermelon', + char: '\u{1F349}', + shortName: 'watermelon', + emojiGroup: EmojiGroup.foodDrink, + emojiSubgroup: EmojiSubgroup.foodFruit, + keywords: [ + 'fruit', + 'uc6', + 'food', + 'fruit', + 'diet', + 'summer', + 'picnic', + 'vegetarian', + 'foods', + 'eat', + 'meal', + 'comida', + 'nourriture', + 'eats', + 'groceries', + 'grocery', + 'hungry', + 'tasty', + 'mmm', + 'yummy', + 'feed', + 'hunger', + 'weekend' + ]), + Emoji( + name: 'grapes', + char: '\u{1F347}', + shortName: 'grapes', + emojiGroup: EmojiGroup.foodDrink, + emojiSubgroup: EmojiSubgroup.foodFruit, + keywords: [ + 'fruit', + 'grape', + 'uc6', + 'food', + 'fruit', + 'paris', + 'diet', + 'snacks', + 'picnic', + 'vegetarian', + 'foods', + 'eat', + 'meal', + 'comida', + 'nourriture', + 'eats', + 'groceries', + 'grocery', + 'hungry', + 'tasty', + 'mmm', + 'yummy', + 'feed', + 'hunger', + 'french', + 'france', + 'snack' + ]), + Emoji( + name: 'blueberries', + char: '\u{1FAD0}', + shortName: 'blueberries', + emojiGroup: EmojiGroup.foodDrink, + emojiSubgroup: EmojiSubgroup.foodFruit, + keywords: [ + 'uc13', + 'food', + 'fruit', + 'breakfast', + 'diet', + 'vegetarian', + 'foods', + 'eat', + 'meal', + 'comida', + 'nourriture', + 'eats', + 'groceries', + 'grocery', + 'hungry', + 'tasty', + 'mmm', + 'yummy', + 'feed', + 'hunger', + 'petit dejeuner' + ]), + Emoji( + name: 'strawberry', + char: '\u{1F353}', + shortName: 'strawberry', + emojiGroup: EmojiGroup.foodDrink, + emojiSubgroup: EmojiSubgroup.foodFruit, + keywords: [ + 'berry', + 'fruit', + 'uc6', + 'food', + 'fruit', + 'diet', + 'picnic', + 'vegetarian', + 'foods', + 'eat', + 'meal', + 'comida', + 'nourriture', + 'eats', + 'groceries', + 'grocery', + 'hungry', + 'tasty', + 'mmm', + 'yummy', + 'feed', + 'hunger' + ]), + Emoji( + name: 'melon', + char: '\u{1F348}', + shortName: 'melon', + emojiGroup: EmojiGroup.foodDrink, + emojiSubgroup: EmojiSubgroup.foodFruit, + keywords: [ + 'fruit', + 'uc6', + 'food', + 'fruit', + 'boobs', + 'diet', + 'porn', + 'vegetarian', + 'foods', + 'eat', + 'meal', + 'comida', + 'nourriture', + 'eats', + 'groceries', + 'grocery', + 'hungry', + 'tasty', + 'mmm', + 'yummy', + 'feed', + 'hunger', + 'boob', + 'tits', + 'tit', + 'breast' + ]), + Emoji( + name: 'cherries', + char: '\u{1F352}', + shortName: 'cherries', + emojiGroup: EmojiGroup.foodDrink, + emojiSubgroup: EmojiSubgroup.foodFruit, + keywords: [ + 'cherry', + 'fruit', + 'uc6', + 'food', + 'fruit', + 'sex', + 'vagina', + 'pussy', + 'diet', + 'porn', + 'picnic', + 'vegetarian', + 'foods', + 'eat', + 'meal', + 'comida', + 'nourriture', + 'eats', + 'groceries', + 'grocery', + 'hungry', + 'tasty', + 'mmm', + 'yummy', + 'feed', + 'hunger', + 'fuck', + 'fucking', + 'horny', + 'humping', + 'condom' + ]), + Emoji( + name: 'peach', + char: '\u{1F351}', + shortName: 'peach', + emojiGroup: EmojiGroup.foodDrink, + emojiSubgroup: EmojiSubgroup.foodFruit, + keywords: [ + 'fruit', + 'uc6', + 'food', + 'fruit', + 'butt', + 'sex', + 'vagina', + 'pussy', + 'diet', + 'porn', + 'picnic', + 'vegetarian', + 'foods', + 'eat', + 'meal', + 'comida', + 'nourriture', + 'eats', + 'groceries', + 'grocery', + 'hungry', + 'tasty', + 'mmm', + 'yummy', + 'feed', + 'hunger', + 'ass', + 'booty', + 'fuck', + 'fucking', + 'horny', + 'humping', + 'condom' + ]), + Emoji( + name: 'mango', + char: '\u{1F96D}', + shortName: 'mango', + emojiGroup: EmojiGroup.foodDrink, + emojiSubgroup: EmojiSubgroup.foodFruit, + keywords: [ + 'uc11', + 'food', + 'fruit', + 'tropical', + 'thai', + 'diet', + 'vegetarian', + 'foods', + 'eat', + 'meal', + 'comida', + 'nourriture', + 'eats', + 'groceries', + 'grocery', + 'hungry', + 'tasty', + 'mmm', + 'yummy', + 'feed', + 'hunger', + 'pattaya' + ]), + Emoji( + name: 'pineapple', + char: '\u{1F34D}', + shortName: 'pineapple', + emojiGroup: EmojiGroup.foodDrink, + emojiSubgroup: EmojiSubgroup.foodFruit, + keywords: [ + 'fruit', + 'uc6', + 'food', + 'fruit', + 'tropical', + 'hawaii', + 'diet', + 'pineapple', + 'picnic', + 'vegetarian', + 'foods', + 'eat', + 'meal', + 'comida', + 'nourriture', + 'eats', + 'groceries', + 'grocery', + 'hungry', + 'tasty', + 'mmm', + 'yummy', + 'feed', + 'hunger', + 'aloha', + 'kawaii', + 'maui', + 'moana', + 'pinapple' + ]), + Emoji( + name: 'coconut', + char: '\u{1F965}', + shortName: 'coconut', + emojiGroup: EmojiGroup.foodDrink, + emojiSubgroup: EmojiSubgroup.foodFruit, + keywords: [ + 'palm', + 'piña colada', + 'uc10', + 'food', + 'fruit', + 'thai', + 'coconut', + 'diet', + 'vegetarian', + 'keto', + 'foods', + 'eat', + 'meal', + 'comida', + 'nourriture', + 'eats', + 'groceries', + 'grocery', + 'hungry', + 'tasty', + 'mmm', + 'yummy', + 'feed', + 'hunger', + 'pattaya' + ]), + Emoji( + name: 'kiwi fruit', + char: '\u{1F95D}', + shortName: 'kiwi', + emojiGroup: EmojiGroup.foodDrink, + emojiSubgroup: EmojiSubgroup.foodFruit, + keywords: [ + 'food', + 'fruit', + 'kiwi', + 'uc9', + 'food', + 'fruit', + 'breakfast', + 'diet', + 'vegetarian', + 'foods', + 'eat', + 'meal', + 'comida', + 'nourriture', + 'eats', + 'groceries', + 'grocery', + 'hungry', + 'tasty', + 'mmm', + 'yummy', + 'feed', + 'hunger', + 'petit dejeuner' + ]), + Emoji( + name: 'tomato', + char: '\u{1F345}', + shortName: 'tomato', + emojiGroup: EmojiGroup.foodDrink, + emojiSubgroup: EmojiSubgroup.foodFruit, + keywords: [ + 'fruit', + 'vegetable', + 'uc6', + 'food', + 'fruit', + 'vegetables', + 'diet', + 'picnic', + 'vegetarian', + 'foods', + 'eat', + 'meal', + 'comida', + 'nourriture', + 'eats', + 'groceries', + 'grocery', + 'hungry', + 'tasty', + 'mmm', + 'yummy', + 'feed', + 'hunger', + 'vegetable', + 'veggie', + 'legume' + ]), + Emoji( + name: 'eggplant', + char: '\u{1F346}', + shortName: 'eggplant', + emojiGroup: EmojiGroup.foodDrink, + emojiSubgroup: EmojiSubgroup.foodVegetable, + keywords: [ + 'aubergine', + 'vegetable', + 'uc6', + 'food', + 'vegetables', + 'penis', + 'sex', + 'diet', + 'porn', + 'vegetarian', + 'foods', + 'eat', + 'meal', + 'comida', + 'nourriture', + 'eats', + 'groceries', + 'grocery', + 'hungry', + 'tasty', + 'mmm', + 'yummy', + 'feed', + 'hunger', + 'vegetable', + 'veggie', + 'legume', + 'dick', + 'fuck', + 'fucking', + 'horny', + 'humping' + ]), + Emoji( + name: 'avocado', + char: '\u{1F951}', + shortName: 'avocado', + emojiGroup: EmojiGroup.foodDrink, + emojiSubgroup: EmojiSubgroup.foodVegetable, + keywords: [ + 'avocado', + 'food', + 'fruit', + 'uc9', + 'food', + 'fruit', + 'vegetables', + 'california', + 'diet', + 'avocado', + 'picnic', + 'vegetarian', + 'keto', + 'foods', + 'eat', + 'meal', + 'comida', + 'nourriture', + 'eats', + 'groceries', + 'grocery', + 'hungry', + 'tasty', + 'mmm', + 'yummy', + 'feed', + 'hunger', + 'vegetable', + 'veggie', + 'legume', + 'avacado' + ]), + Emoji( + name: 'olive', + char: '\u{1FAD2}', + shortName: 'olive', + emojiGroup: EmojiGroup.foodDrink, + emojiSubgroup: EmojiSubgroup.foodFruit, + keywords: [ + 'uc13', + 'food', + 'italian', + 'vegetarian', + 'appetizer', + 'foods', + 'eat', + 'meal', + 'comida', + 'nourriture', + 'eats', + 'groceries', + 'grocery', + 'hungry', + 'tasty', + 'mmm', + 'yummy', + 'feed', + 'hunger', + 'italy', + 'italie', + 'apéro', + 'entrée' + ]), + Emoji( + name: 'broccoli', + char: '\u{1F966}', + shortName: 'broccoli', + emojiGroup: EmojiGroup.foodDrink, + emojiSubgroup: EmojiSubgroup.foodVegetable, + keywords: [ + 'wild cabbage', + 'uc10', + 'food', + 'vegetables', + 'diet', + 'vegetarian', + 'keto', + 'foods', + 'eat', + 'meal', + 'comida', + 'nourriture', + 'eats', + 'groceries', + 'grocery', + 'hungry', + 'tasty', + 'mmm', + 'yummy', + 'feed', + 'hunger', + 'vegetable', + 'veggie', + 'legume' + ]), + Emoji( + name: 'leafy green', + char: '\u{1F96C}', + shortName: 'leafy_green', + emojiGroup: EmojiGroup.foodDrink, + emojiSubgroup: EmojiSubgroup.foodVegetable, + keywords: [ + 'uc11', + 'food', + 'vegetables', + 'diet', + 'lettuce', + 'vegetarian', + 'keto', + 'foods', + 'eat', + 'meal', + 'comida', + 'nourriture', + 'eats', + 'groceries', + 'grocery', + 'hungry', + 'tasty', + 'mmm', + 'yummy', + 'feed', + 'hunger', + 'vegetable', + 'veggie', + 'legume' + ]), + Emoji( + name: 'bell pepper', + char: '\u{1FAD1}', + shortName: 'bell_pepper', + emojiGroup: EmojiGroup.foodDrink, + emojiSubgroup: EmojiSubgroup.foodVegetable, + keywords: [ + 'uc13', + 'food', + 'diet', + 'vegetarian', + 'appetizer', + 'foods', + 'eat', + 'meal', + 'comida', + 'nourriture', + 'eats', + 'groceries', + 'grocery', + 'hungry', + 'tasty', + 'mmm', + 'yummy', + 'feed', + 'hunger', + 'apéro', + 'entrée' + ]), + Emoji( + name: 'cucumber', + char: '\u{1F952}', + shortName: 'cucumber', + emojiGroup: EmojiGroup.foodDrink, + emojiSubgroup: EmojiSubgroup.foodVegetable, + keywords: [ + 'cucumber', + 'food', + 'pickle', + 'vegetable', + 'uc9', + 'food', + 'fruit', + 'vegetables', + 'penis', + 'diet', + 'pickle', + 'picnic', + 'vegetarian', + 'appetizer', + 'foods', + 'eat', + 'meal', + 'comida', + 'nourriture', + 'eats', + 'groceries', + 'grocery', + 'hungry', + 'tasty', + 'mmm', + 'yummy', + 'feed', + 'hunger', + 'vegetable', + 'veggie', + 'legume', + 'dick', + 'apéro', + 'entrée' + ]), + Emoji( + name: 'hot pepper', + char: '\u{1F336}\u{FE0F}', + shortName: 'hot_pepper', + emojiGroup: EmojiGroup.foodDrink, + emojiSubgroup: EmojiSubgroup.foodVegetable, + keywords: [ + 'hot', + 'pepper', + 'uc7', + 'food', + 'vegetables', + 'mexican', + 'hot', + 'chili', + 'diet', + 'texas', + 'vegetarian', + 'foods', + 'eat', + 'meal', + 'comida', + 'nourriture', + 'eats', + 'groceries', + 'grocery', + 'hungry', + 'tasty', + 'mmm', + 'yummy', + 'feed', + 'hunger', + 'vegetable', + 'veggie', + 'legume', + 'mexico', + 'cinco de mayo', + 'español', + 'heat', + 'warm', + 'caliente', + 'chaud', + 'heiß' + ]), + Emoji( + name: 'ear of corn', + char: '\u{1F33D}', + shortName: 'corn', + emojiGroup: EmojiGroup.foodDrink, + emojiSubgroup: EmojiSubgroup.foodVegetable, + keywords: [ + 'corn', + 'ear', + 'maize', + 'maze', + 'uc6', + 'food', + 'vegetables', + 'diet', + 'farm', + 'picnic', + 'independence day', + 'thanksgiving', + 'vegetarian', + 'foods', + 'eat', + 'meal', + 'comida', + 'nourriture', + 'eats', + 'groceries', + 'grocery', + 'hungry', + 'tasty', + 'mmm', + 'yummy', + 'feed', + 'hunger', + 'vegetable', + 'veggie', + 'legume', + '4th of july' + ]), + Emoji( + name: 'carrot', + char: '\u{1F955}', + shortName: 'carrot', + emojiGroup: EmojiGroup.foodDrink, + emojiSubgroup: EmojiSubgroup.foodVegetable, + keywords: [ + 'carrot', + 'food', + 'vegetable', + 'uc9', + 'food', + 'vegetables', + 'penis', + 'diet', + 'vegetarian', + 'appetizer', + 'foods', + 'eat', + 'meal', + 'comida', + 'nourriture', + 'eats', + 'groceries', + 'grocery', + 'hungry', + 'tasty', + 'mmm', + 'yummy', + 'feed', + 'hunger', + 'vegetable', + 'veggie', + 'legume', + 'dick', + 'apéro', + 'entrée' + ]), + Emoji( + name: 'garlic', + char: '\u{1F9C4}', + shortName: 'garlic', + emojiGroup: EmojiGroup.foodDrink, + emojiSubgroup: EmojiSubgroup.foodVegetable, + keywords: [ + 'uc12', + 'food', + 'vegetables', + 'diet', + 'vampire', + 'foods', + 'eat', + 'meal', + 'comida', + 'nourriture', + 'eats', + 'groceries', + 'grocery', + 'hungry', + 'tasty', + 'mmm', + 'yummy', + 'feed', + 'hunger', + 'vegetable', + 'veggie', + 'legume', + 'dracula' + ]), + Emoji( + name: 'onion', + char: '\u{1F9C5}', + shortName: 'onion', + emojiGroup: EmojiGroup.foodDrink, + emojiSubgroup: EmojiSubgroup.foodVegetable, + keywords: [ + 'uc12', + 'food', + 'cry', + 'vegetables', + 'diet', + 'foods', + 'eat', + 'meal', + 'comida', + 'nourriture', + 'eats', + 'groceries', + 'grocery', + 'hungry', + 'tasty', + 'mmm', + 'yummy', + 'feed', + 'hunger', + 'crying', + 'weeping', + 'weep', + 'sob', + 'sobbing', + 'tear', + 'tears', + 'bawling', + 'vegetable', + 'veggie', + 'legume' + ]), + Emoji( + name: 'potato', + char: '\u{1F954}', + shortName: 'potato', + emojiGroup: EmojiGroup.foodDrink, + emojiSubgroup: EmojiSubgroup.foodVegetable, + keywords: [ + 'food', + 'potato', + 'vegetable', + 'uc9', + 'food', + 'vegetables', + 'carbs', + 'vegetarian', + 'foods', + 'eat', + 'meal', + 'comida', + 'nourriture', + 'eats', + 'groceries', + 'grocery', + 'hungry', + 'tasty', + 'mmm', + 'yummy', + 'feed', + 'hunger', + 'vegetable', + 'veggie', + 'legume', + 'carbohydrates' + ]), + Emoji( + name: 'roasted sweet potato', + char: '\u{1F360}', + shortName: 'sweet_potato', + emojiGroup: EmojiGroup.foodDrink, + emojiSubgroup: EmojiSubgroup.foodAsian, + keywords: [ + 'potato', + 'roasted', + 'sweet', + 'uc6', + 'food', + 'vegetables', + 'diet', + 'yam', + 'carbs', + 'thanksgiving', + 'vegetarian', + 'foods', + 'eat', + 'meal', + 'comida', + 'nourriture', + 'eats', + 'groceries', + 'grocery', + 'hungry', + 'tasty', + 'mmm', + 'yummy', + 'feed', + 'hunger', + 'vegetable', + 'veggie', + 'legume', + 'carbohydrates' + ]), + Emoji( + name: 'croissant', + char: '\u{1F950}', + shortName: 'croissant', + emojiGroup: EmojiGroup.foodDrink, + emojiSubgroup: EmojiSubgroup.foodPrepared, + keywords: [ + 'bread', + 'crescent roll', + 'croissant', + 'food', + 'french', + 'uc9', + 'food', + 'breakfast', + 'paris', + 'bake', + 'picnic', + 'carbs', + 'pastry', + 'vegetarian', + 'foods', + 'eat', + 'meal', + 'comida', + 'nourriture', + 'eats', + 'groceries', + 'grocery', + 'hungry', + 'tasty', + 'mmm', + 'yummy', + 'feed', + 'hunger', + 'petit dejeuner', + 'french', + 'france', + 'baking', + 'carbohydrates', + 'pastries', + 'pâtisserie' + ]), + Emoji( + name: 'bagel', + char: '\u{1F96F}', + shortName: 'bagel', + emojiGroup: EmojiGroup.foodDrink, + emojiSubgroup: EmojiSubgroup.foodPrepared, + keywords: [ + 'uc11', + 'food', + 'new york', + 'breakfast', + 'bake', + 'carbs', + 'vegetarian', + 'foods', + 'eat', + 'meal', + 'comida', + 'nourriture', + 'eats', + 'groceries', + 'grocery', + 'hungry', + 'tasty', + 'mmm', + 'yummy', + 'feed', + 'hunger', + 'petit dejeuner', + 'baking', + 'carbohydrates' + ]), + Emoji( + name: 'bread', + char: '\u{1F35E}', + shortName: 'bread', + emojiGroup: EmojiGroup.foodDrink, + emojiSubgroup: EmojiSubgroup.foodPrepared, + keywords: [ + 'loaf', + 'uc6', + 'food', + 'sandwich', + 'breakfast', + 'bake', + 'toast', + 'carbs', + 'vegetarian', + 'foods', + 'eat', + 'meal', + 'comida', + 'nourriture', + 'eats', + 'groceries', + 'grocery', + 'hungry', + 'tasty', + 'mmm', + 'yummy', + 'feed', + 'hunger', + 'sanwiches', + 'petit dejeuner', + 'baking', + 'carbohydrates' + ]), + Emoji( + name: 'baguette bread', + char: '\u{1F956}', + shortName: 'french_bread', + emojiGroup: EmojiGroup.foodDrink, + emojiSubgroup: EmojiSubgroup.foodPrepared, + keywords: [ + 'baguette', + 'bread', + 'food', + 'french', + 'uc9', + 'food', + 'penis', + 'paris', + 'bake', + 'picnic', + 'carbs', + 'vegetarian', + 'foods', + 'eat', + 'meal', + 'comida', + 'nourriture', + 'eats', + 'groceries', + 'grocery', + 'hungry', + 'tasty', + 'mmm', + 'yummy', + 'feed', + 'hunger', + 'dick', + 'french', + 'france', + 'baking', + 'carbohydrates' + ]), + Emoji( + name: 'flatbread', + char: '\u{1FAD3}', + shortName: 'flatbread', + emojiGroup: EmojiGroup.foodDrink, + emojiSubgroup: EmojiSubgroup.foodPrepared, + keywords: [ + 'uc13', + 'food', + 'carbs', + 'vegetarian', + 'pita', + 'foods', + 'eat', + 'meal', + 'comida', + 'nourriture', + 'eats', + 'groceries', + 'grocery', + 'hungry', + 'tasty', + 'mmm', + 'yummy', + 'feed', + 'hunger', + 'carbohydrates', + 'naan', + 'tortilla', + 'chepati', + 'focaccia', + 'fry bread', + 'lavash', + 'matzah', + 'roti' + ]), + Emoji( + name: 'pretzel', + char: '\u{1F968}', + shortName: 'pretzel', + emojiGroup: EmojiGroup.foodDrink, + emojiSubgroup: EmojiSubgroup.foodPrepared, + keywords: [ + 'uc10', + 'food', + 'bake', + 'snacks', + 'carbs', + 'german', + 'vegetarian', + 'appetizer', + 'foods', + 'eat', + 'meal', + 'comida', + 'nourriture', + 'eats', + 'groceries', + 'grocery', + 'hungry', + 'tasty', + 'mmm', + 'yummy', + 'feed', + 'hunger', + 'baking', + 'snack', + 'carbohydrates', + 'oktoberfest', + 'octoberfest', + 'bratwurst', + 'apéro', + 'entrée' + ]), + Emoji( + name: 'cheese wedge', + char: '\u{1F9C0}', + shortName: 'cheese', + emojiGroup: EmojiGroup.foodDrink, + emojiSubgroup: EmojiSubgroup.foodPrepared, + keywords: [ + 'cheese', + 'uc8', + 'food', + 'paris', + 'picnic', + 'cheese', + 'vegetarian', + 'keto', + 'appetizer', + 'foods', + 'eat', + 'meal', + 'comida', + 'nourriture', + 'eats', + 'groceries', + 'grocery', + 'hungry', + 'tasty', + 'mmm', + 'yummy', + 'feed', + 'hunger', + 'french', + 'france', + 'apéro', + 'entrée' + ]), + Emoji( + name: 'egg', + char: '\u{1F95A}', + shortName: 'egg', + emojiGroup: EmojiGroup.foodDrink, + emojiSubgroup: EmojiSubgroup.foodPrepared, + keywords: [ + 'egg', + 'food', + 'uc9', + 'food', + 'breakfast', + 'easter', + 'diet', + 'eggs', + 'vegetarian', + 'keto', + 'foods', + 'eat', + 'meal', + 'comida', + 'nourriture', + 'eats', + 'groceries', + 'grocery', + 'hungry', + 'tasty', + 'mmm', + 'yummy', + 'feed', + 'hunger', + 'petit dejeuner' + ]), + Emoji( + name: 'cooking', + char: '\u{1F373}', + shortName: 'cooking', + emojiGroup: EmojiGroup.foodDrink, + emojiSubgroup: EmojiSubgroup.foodPrepared, + keywords: [ + 'egg', + 'frying', + 'pan', + 'uc6', + 'food', + 'breakfast', + 'eggs', + 'restaurant', + 'vegetarian', + 'keto', + 'foods', + 'eat', + 'meal', + 'comida', + 'nourriture', + 'eats', + 'groceries', + 'grocery', + 'hungry', + 'tasty', + 'mmm', + 'yummy', + 'feed', + 'hunger', + 'petit dejeuner' + ]), + Emoji( + name: 'butter', + char: '\u{1F9C8}', + shortName: 'butter', + emojiGroup: EmojiGroup.foodDrink, + emojiSubgroup: EmojiSubgroup.foodPrepared, + keywords: [ + 'uc12', + 'food', + 'breakfast', + 'condiment', + 'butter', + 'keto', + 'foods', + 'eat', + 'meal', + 'comida', + 'nourriture', + 'eats', + 'groceries', + 'grocery', + 'hungry', + 'tasty', + 'mmm', + 'yummy', + 'feed', + 'hunger', + 'petit dejeuner', + 'condiments', + 'seasoning', + 'topping', + 'margarine', + 'ghee' + ]), + Emoji( + name: 'pancakes', + char: '\u{1F95E}', + shortName: 'pancakes', + emojiGroup: EmojiGroup.foodDrink, + emojiSubgroup: EmojiSubgroup.foodPrepared, + keywords: [ + 'crêpe', + 'food', + 'hotcake', + 'pancake', + 'uc9', + 'food', + 'breakfast', + 'carbs', + 'restaurant', + 'vegetarian', + 'pancake', + 'foods', + 'eat', + 'meal', + 'comida', + 'nourriture', + 'eats', + 'groceries', + 'grocery', + 'hungry', + 'tasty', + 'mmm', + 'yummy', + 'feed', + 'hunger', + 'petit dejeuner', + 'carbohydrates', + 'pannenkoeken', + 'maple syrup', + 'pfannkuchen', + 'panqueques', + 'crêpes' + ]), + Emoji( + name: 'waffle', + char: '\u{1F9C7}', + shortName: 'waffle', + emojiGroup: EmojiGroup.foodDrink, + emojiSubgroup: EmojiSubgroup.foodPrepared, + keywords: [ + 'uc12', + 'food', + 'breakfast', + 'carbs', + 'waffles', + 'vegetarian', + 'foods', + 'eat', + 'meal', + 'comida', + 'nourriture', + 'eats', + 'groceries', + 'grocery', + 'hungry', + 'tasty', + 'mmm', + 'yummy', + 'feed', + 'hunger', + 'petit dejeuner', + 'carbohydrates', + 'eggo', + 'gaufre', + 'gofre', + 'waffel', + 'wafel' + ]), + Emoji( + name: 'bacon', + char: '\u{1F953}', + shortName: 'bacon', + emojiGroup: EmojiGroup.foodDrink, + emojiSubgroup: EmojiSubgroup.foodPrepared, + keywords: [ + 'bacon', + 'food', + 'meat', + 'uc9', + 'food', + 'breakfast', + 'pig', + 'meat', + 'restaurant', + 'keto', + 'foods', + 'eat', + 'meal', + 'comida', + 'nourriture', + 'eats', + 'groceries', + 'grocery', + 'hungry', + 'tasty', + 'mmm', + 'yummy', + 'feed', + 'hunger', + 'petit dejeuner', + 'pork' + ]), + Emoji( + name: 'cut of meat', + char: '\u{1F969}', + shortName: 'cut_of_meat', + emojiGroup: EmojiGroup.foodDrink, + emojiSubgroup: EmojiSubgroup.foodPrepared, + keywords: [ + 'chop', + 'lambchop', + 'porkchop', + 'steak', + 'uc10', + 'food', + 'texas', + 'dinner', + 'steak', + 'meat', + 'restaurant', + 'keto', + 'foods', + 'eat', + 'meal', + 'comida', + 'nourriture', + 'eats', + 'groceries', + 'grocery', + 'hungry', + 'tasty', + 'mmm', + 'yummy', + 'feed', + 'hunger', + 'lunch' + ]), + Emoji( + name: 'poultry leg', + char: '\u{1F357}', + shortName: 'poultry_leg', + emojiGroup: EmojiGroup.foodDrink, + emojiSubgroup: EmojiSubgroup.foodPrepared, + keywords: [ + 'bone', + 'chicken', + 'leg', + 'poultry', + 'uc6', + 'food', + 'chicken leg', + 'disney', + 'viking', + 'dinner', + 'meat', + 'thanksgiving', + 'restaurant', + 'keto', + 'foods', + 'eat', + 'meal', + 'comida', + 'nourriture', + 'eats', + 'groceries', + 'grocery', + 'hungry', + 'tasty', + 'mmm', + 'yummy', + 'feed', + 'hunger', + 'cartoon', + 'knight', + 'lunch' + ]), + Emoji( + name: 'meat on bone', + char: '\u{1F356}', + shortName: 'meat_on_bone', + emojiGroup: EmojiGroup.foodDrink, + emojiSubgroup: EmojiSubgroup.foodPrepared, + keywords: [ + 'bone', + 'meat', + 'uc6', + 'food', + 'beef', + 'brazil', + 'viking', + 'dinner', + 'meat', + 'thanksgiving', + 'restaurant', + 'keto', + 'foods', + 'eat', + 'meal', + 'comida', + 'nourriture', + 'eats', + 'groceries', + 'grocery', + 'hungry', + 'tasty', + 'mmm', + 'yummy', + 'feed', + 'hunger', + 'brasil', + 'bresil', + 'knight', + 'lunch' + ]), + Emoji( + name: 'hot dog', + char: '\u{1F32D}', + shortName: 'hotdog', + emojiGroup: EmojiGroup.foodDrink, + emojiSubgroup: EmojiSubgroup.foodPrepared, + keywords: [ + 'frankfurter', + 'hotdog', + 'sausage', + 'uc8', + 'food', + 'america', + 'new york', + 'sandwich', + 'dinner', + 'franks', + 'independence day', + 'german', + 'restaurant', + 'foods', + 'eat', + 'meal', + 'comida', + 'nourriture', + 'eats', + 'groceries', + 'grocery', + 'hungry', + 'tasty', + 'mmm', + 'yummy', + 'feed', + 'hunger', + 'usa', + 'united states', + 'united states of america', + 'american', + 'sanwiches', + 'lunch', + 'sausage', + 'hot dog', + '4th of july', + 'oktoberfest', + 'octoberfest', + 'bratwurst' + ]), + Emoji( + name: 'hamburger', + char: '\u{1F354}', + shortName: 'hamburger', + emojiGroup: EmojiGroup.foodDrink, + emojiSubgroup: EmojiSubgroup.foodPrepared, + keywords: [ + 'burger', + 'uc6', + 'food', + 'america', + 'boys night', + 'sandwich', + 'beef', + 'mcdonalds', + 'dinner', + 'burger', + 'cheese', + 'independence day', + 'restaurant', + 'foods', + 'eat', + 'meal', + 'comida', + 'nourriture', + 'eats', + 'groceries', + 'grocery', + 'hungry', + 'tasty', + 'mmm', + 'yummy', + 'feed', + 'hunger', + 'usa', + 'united states', + 'united states of america', + 'american', + 'guys night', + 'sanwiches', + 'ronald mcdonald', + 'macdo', + 'lunch', + 'cheeseburger', + 'cheese burger', + '4th of july' + ]), + Emoji( + name: 'french fries', + char: '\u{1F35F}', + shortName: 'fries', + emojiGroup: EmojiGroup.foodDrink, + emojiSubgroup: EmojiSubgroup.foodPrepared, + keywords: [ + 'french', + 'fries', + 'uc6', + 'food', + 'america', + 'chips', + 'mcdonalds', + 'dinner', + 'carbs', + 'restaurant', + 'vegetarian', + 'foods', + 'eat', + 'meal', + 'comida', + 'nourriture', + 'eats', + 'groceries', + 'grocery', + 'hungry', + 'tasty', + 'mmm', + 'yummy', + 'feed', + 'hunger', + 'usa', + 'united states', + 'united states of america', + 'american', + 'ronald mcdonald', + 'macdo', + 'lunch', + 'carbohydrates' + ]), + Emoji( + name: 'pizza', + char: '\u{1F355}', + shortName: 'pizza', + emojiGroup: EmojiGroup.foodDrink, + emojiSubgroup: EmojiSubgroup.foodPrepared, + keywords: [ + 'cheese', + 'slice', + 'uc6', + 'food', + 'italian', + 'boys night', + 'new york', + 'dinner', + 'cheese', + 'carbs', + 'restaurant', + 'keto', + 'foods', + 'eat', + 'meal', + 'comida', + 'nourriture', + 'eats', + 'groceries', + 'grocery', + 'hungry', + 'tasty', + 'mmm', + 'yummy', + 'feed', + 'hunger', + 'italy', + 'italie', + 'guys night', + 'lunch', + 'carbohydrates' + ]), + Emoji( + name: 'sandwich', + char: '\u{1F96A}', + shortName: 'sandwich', + emojiGroup: EmojiGroup.foodDrink, + emojiSubgroup: EmojiSubgroup.foodPrepared, + keywords: [ + 'bread', + 'uc10', + 'food', + 'sandwich', + 'dinner', + 'restaurant', + 'foods', + 'eat', + 'meal', + 'comida', + 'nourriture', + 'eats', + 'groceries', + 'grocery', + 'hungry', + 'tasty', + 'mmm', + 'yummy', + 'feed', + 'hunger', + 'sanwiches', + 'lunch' + ]), + Emoji( + name: 'stuffed flatbread', + char: '\u{1F959}', + shortName: 'stuffed_flatbread', + emojiGroup: EmojiGroup.foodDrink, + emojiSubgroup: EmojiSubgroup.foodPrepared, + keywords: [ + 'falafel', + 'flatbread', + 'food', + 'gyro', + 'kebab', + 'stuffed', + 'uc9', + 'food', + 'sandwich', + 'dinner', + 'german', + 'restaurant', + 'vegetarian', + 'foods', + 'eat', + 'meal', + 'comida', + 'nourriture', + 'eats', + 'groceries', + 'grocery', + 'hungry', + 'tasty', + 'mmm', + 'yummy', + 'feed', + 'hunger', + 'sanwiches', + 'lunch', + 'oktoberfest', + 'octoberfest', + 'bratwurst' + ]), + Emoji( + name: 'falafel', + char: '\u{1F9C6}', + shortName: 'falafel', + emojiGroup: EmojiGroup.foodDrink, + emojiSubgroup: EmojiSubgroup.foodPrepared, + keywords: [ + 'uc12', + 'food', + 'dinner', + 'meatball', + 'felafel', + 'vegetarian', + 'foods', + 'eat', + 'meal', + 'comida', + 'nourriture', + 'eats', + 'groceries', + 'grocery', + 'hungry', + 'tasty', + 'mmm', + 'yummy', + 'feed', + 'hunger', + 'lunch', + 'chickpeas', + 'fava beans', + 'levantine', + 'meze' + ]), + Emoji( + name: 'taco', + char: '\u{1F32E}', + shortName: 'taco', + emojiGroup: EmojiGroup.foodDrink, + emojiSubgroup: EmojiSubgroup.foodPrepared, + keywords: [ + 'mexican', + 'uc8', + 'food', + 'mexican', + 'vagina', + 'tacos', + 'hola', + 'pussy', + 'porn', + 'texas', + 'dinner', + 'restaurant', + 'foods', + 'eat', + 'meal', + 'comida', + 'nourriture', + 'eats', + 'groceries', + 'grocery', + 'hungry', + 'tasty', + 'mmm', + 'yummy', + 'feed', + 'hunger', + 'mexico', + 'cinco de mayo', + 'español', + 'condom', + 'lunch' + ]), + Emoji( + name: 'burrito', + char: '\u{1F32F}', + shortName: 'burrito', + emojiGroup: EmojiGroup.foodDrink, + emojiSubgroup: EmojiSubgroup.foodPrepared, + keywords: [ + 'mexican', + 'wrap', + 'uc8', + 'food', + 'mexican', + 'hola', + 'texas', + 'dinner', + 'restaurant', + 'foods', + 'eat', + 'meal', + 'comida', + 'nourriture', + 'eats', + 'groceries', + 'grocery', + 'hungry', + 'tasty', + 'mmm', + 'yummy', + 'feed', + 'hunger', + 'mexico', + 'cinco de mayo', + 'español', + 'lunch' + ]), + Emoji( + name: 'tamale', + char: '\u{1FAD4}', + shortName: 'tamale', + emojiGroup: EmojiGroup.foodDrink, + emojiSubgroup: EmojiSubgroup.foodPrepared, + keywords: [ + 'uc13', + 'food', + 'dinner', + 'tamal', + 'foods', + 'eat', + 'meal', + 'comida', + 'nourriture', + 'eats', + 'groceries', + 'grocery', + 'hungry', + 'tasty', + 'mmm', + 'yummy', + 'feed', + 'hunger', + 'lunch', + 'chuchito', + 'pastelle', + 'pasteles', + 'hallaca', + 'zacahuil', + 'corunda', + 'bollo', + 'humita', + 'binaki', + 'masa', + 'dukunu', + 'paches' + ]), + Emoji( + name: 'green salad', + char: '\u{1F957}', + shortName: 'salad', + emojiGroup: EmojiGroup.foodDrink, + emojiSubgroup: EmojiSubgroup.foodPrepared, + keywords: [ + 'food', + 'green', + 'salad', + 'uc9', + 'food', + 'vegetables', + 'diet', + 'dinner', + 'lettuce', + 'picnic', + 'restaurant', + 'vegetarian', + 'keto', + 'appetizer', + 'foods', + 'eat', + 'meal', + 'comida', + 'nourriture', + 'eats', + 'groceries', + 'grocery', + 'hungry', + 'tasty', + 'mmm', + 'yummy', + 'feed', + 'hunger', + 'vegetable', + 'veggie', + 'legume', + 'lunch', + 'apéro', + 'entrée' + ]), + Emoji( + name: 'shallow pan of food', + char: '\u{1F958}', + shortName: 'shallow_pan_of_food', + emojiGroup: EmojiGroup.foodDrink, + emojiSubgroup: EmojiSubgroup.foodPrepared, + keywords: [ + 'casserole', + 'food', + 'paella', + 'pan', + 'shallow', + 'uc9', + 'food', + 'mexican', + 'barcelona', + 'beef', + 'brazil', + 'dinner', + 'restaurant', + 'foods', + 'eat', + 'meal', + 'comida', + 'nourriture', + 'eats', + 'groceries', + 'grocery', + 'hungry', + 'tasty', + 'mmm', + 'yummy', + 'feed', + 'hunger', + 'mexico', + 'cinco de mayo', + 'español', + 'españa', + 'spanish', + 'brasil', + 'bresil', + 'lunch' + ]), + Emoji( + name: 'fondue', + char: '\u{1FAD5}', + shortName: 'fondue', + emojiGroup: EmojiGroup.foodDrink, + emojiSubgroup: EmojiSubgroup.foodPrepared, + keywords: [ + 'uc13', + 'food', + 'dinner', + 'cheese', + 'vegetarian', + 'shabu', + 'foods', + 'eat', + 'meal', + 'comida', + 'nourriture', + 'eats', + 'groceries', + 'grocery', + 'hungry', + 'tasty', + 'mmm', + 'yummy', + 'feed', + 'hunger', + 'lunch', + 'hot pot' + ]), + Emoji( + name: 'canned food', + char: '\u{1F96B}', + shortName: 'canned_food', + emojiGroup: EmojiGroup.foodDrink, + emojiSubgroup: EmojiSubgroup.foodPrepared, + keywords: [ + 'can', + 'uc10', + 'food', + 'soup', + 'dinner', + 'restaurant', + 'vegetarian', + 'foods', + 'eat', + 'meal', + 'comida', + 'nourriture', + 'eats', + 'groceries', + 'grocery', + 'hungry', + 'tasty', + 'mmm', + 'yummy', + 'feed', + 'hunger', + 'lunch' + ]), + Emoji( + name: 'spaghetti', + char: '\u{1F35D}', + shortName: 'spaghetti', + emojiGroup: EmojiGroup.foodDrink, + emojiSubgroup: EmojiSubgroup.foodAsian, + keywords: [ + 'pasta', + 'uc6', + 'food', + 'noodles', + 'pasta', + 'italian', + 'dinner', + 'meatball', + 'restaurant', + 'vegetarian', + 'foods', + 'eat', + 'meal', + 'comida', + 'nourriture', + 'eats', + 'groceries', + 'grocery', + 'hungry', + 'tasty', + 'mmm', + 'yummy', + 'feed', + 'hunger', + 'noodle', + 'pâtes', + 'italy', + 'italie', + 'lunch' + ]), + Emoji( + name: 'steaming bowl', + char: '\u{1F35C}', + shortName: 'ramen', + emojiGroup: EmojiGroup.foodDrink, + emojiSubgroup: EmojiSubgroup.foodAsian, + keywords: [ + 'bowl', + 'noodle', + 'ramen', + 'steaming', + 'uc6', + 'food', + 'noodles', + 'ramen', + 'pasta', + 'japan', + 'steam', + 'thai', + 'chinese', + 'soup', + 'dinner', + 'restaurant', + 'vegetarian', + 'bone broth', + 'foods', + 'eat', + 'meal', + 'comida', + 'nourriture', + 'eats', + 'groceries', + 'grocery', + 'hungry', + 'tasty', + 'mmm', + 'yummy', + 'feed', + 'hunger', + 'noodle', + 'pâtes', + 'japanese', + 'ninja', + 'steaming', + 'piping', + 'pattaya', + 'chinois', + 'asian', + 'chine', + 'lunch' + ]), + Emoji( + name: 'pot of food', + char: '\u{1F372}', + shortName: 'stew', + emojiGroup: EmojiGroup.foodDrink, + emojiSubgroup: EmojiSubgroup.foodPrepared, + keywords: [ + 'pot', + 'stew', + 'uc6', + 'food', + 'steam', + 'thai', + 'brazil', + 'soup', + 'dinner', + 'stew', + 'thanksgiving', + 'restaurant', + 'bone broth', + 'shabu', + 'foods', + 'eat', + 'meal', + 'comida', + 'nourriture', + 'eats', + 'groceries', + 'grocery', + 'hungry', + 'tasty', + 'mmm', + 'yummy', + 'feed', + 'hunger', + 'steaming', + 'piping', + 'pattaya', + 'brasil', + 'bresil', + 'lunch', + 'braise', + 'hot pot' + ]), + Emoji( + name: 'curry rice', + char: '\u{1F35B}', + shortName: 'curry', + emojiGroup: EmojiGroup.foodDrink, + emojiSubgroup: EmojiSubgroup.foodAsian, + keywords: [ + 'curry', + 'rice', + 'uc6', + 'food', + 'japan', + 'thai', + 'dinner', + 'restaurant', + 'foods', + 'eat', + 'meal', + 'comida', + 'nourriture', + 'eats', + 'groceries', + 'grocery', + 'hungry', + 'tasty', + 'mmm', + 'yummy', + 'feed', + 'hunger', + 'japanese', + 'ninja', + 'pattaya', + 'lunch' + ]), + Emoji( + name: 'sushi', + char: '\u{1F363}', + shortName: 'sushi', + emojiGroup: EmojiGroup.foodDrink, + emojiSubgroup: EmojiSubgroup.foodAsian, + keywords: [ + 'sushi', + 'uc6', + 'food', + 'sushi', + 'japan', + 'california', + 'diet', + 'seafood', + 'dinner', + 'restaurant', + 'foods', + 'eat', + 'meal', + 'comida', + 'nourriture', + 'eats', + 'groceries', + 'grocery', + 'hungry', + 'tasty', + 'mmm', + 'yummy', + 'feed', + 'hunger', + 'japanese', + 'ninja', + 'lunch' + ]), + Emoji( + name: 'bento box', + char: '\u{1F371}', + shortName: 'bento', + emojiGroup: EmojiGroup.foodDrink, + emojiSubgroup: EmojiSubgroup.foodAsian, + keywords: [ + 'bento', + 'box', + 'uc6', + 'food', + 'sushi', + 'japan', + 'dinner', + 'restaurant', + 'foods', + 'eat', + 'meal', + 'comida', + 'nourriture', + 'eats', + 'groceries', + 'grocery', + 'hungry', + 'tasty', + 'mmm', + 'yummy', + 'feed', + 'hunger', + 'japanese', + 'ninja', + 'lunch' + ]), + Emoji( + name: 'dumpling', + char: '\u{1F95F}', + shortName: 'dumpling', + emojiGroup: EmojiGroup.foodDrink, + emojiSubgroup: EmojiSubgroup.foodAsian, + keywords: [ + 'empanada', + 'gyōza', + 'jiaozi', + 'pierogi', + 'potsticker', + 'uc10', + 'food', + 'chinese', + 'dinner', + 'dumpling', + 'pastry', + 'restaurant', + 'foods', + 'eat', + 'meal', + 'comida', + 'nourriture', + 'eats', + 'groceries', + 'grocery', + 'hungry', + 'tasty', + 'mmm', + 'yummy', + 'feed', + 'hunger', + 'chinois', + 'asian', + 'chine', + 'lunch', + 'Empanada', + 'Gyōza', + 'Pierogi', + 'pastries', + 'pâtisserie' + ]), + Emoji( + name: 'oyster', + char: '\u{1F9AA}', + shortName: 'oyster', + emojiGroup: EmojiGroup.foodDrink, + emojiSubgroup: EmojiSubgroup.foodMarine, + keywords: [ + 'uc12', + 'animal', + 'food', + 'seafood', + 'dinner', + 'ocean', + 'crustacean', + 'half shell', + 'animals', + 'animal kingdom', + 'foods', + 'eat', + 'meal', + 'comida', + 'nourriture', + 'eats', + 'groceries', + 'grocery', + 'hungry', + 'tasty', + 'mmm', + 'yummy', + 'feed', + 'hunger', + 'lunch', + 'sea', + 'pearl' + ]), + Emoji( + name: 'fried shrimp', + char: '\u{1F364}', + shortName: 'fried_shrimp', + emojiGroup: EmojiGroup.foodDrink, + emojiSubgroup: EmojiSubgroup.foodAsian, + keywords: [ + 'fried', + 'prawn', + 'shrimp', + 'tempura', + 'uc6', + 'food', + 'japan', + 'prawn', + 'seafood', + 'dinner', + 'crustacean', + 'restaurant', + 'foods', + 'eat', + 'meal', + 'comida', + 'nourriture', + 'eats', + 'groceries', + 'grocery', + 'hungry', + 'tasty', + 'mmm', + 'yummy', + 'feed', + 'hunger', + 'japanese', + 'ninja', + 'lunch' + ]), + Emoji( + name: 'rice ball', + char: '\u{1F359}', + shortName: 'rice_ball', + emojiGroup: EmojiGroup.foodDrink, + emojiSubgroup: EmojiSubgroup.foodAsian, + keywords: [ + 'Japanese', + 'ball', + 'rice', + 'uc6', + 'food', + 'sushi', + 'japan', + 'snacks', + 'dinner', + 'restaurant', + 'vegetarian', + 'foods', + 'eat', + 'meal', + 'comida', + 'nourriture', + 'eats', + 'groceries', + 'grocery', + 'hungry', + 'tasty', + 'mmm', + 'yummy', + 'feed', + 'hunger', + 'japanese', + 'ninja', + 'snack', + 'lunch' + ]), + Emoji( + name: 'cooked rice', + char: '\u{1F35A}', + shortName: 'rice', + emojiGroup: EmojiGroup.foodDrink, + emojiSubgroup: EmojiSubgroup.foodAsian, + keywords: [ + 'cooked', + 'rice', + 'uc6', + 'food', + 'sushi', + 'japan', + 'thai', + 'chinese', + 'brazil', + 'dinner', + 'carbs', + 'restaurant', + 'vegetarian', + 'foods', + 'eat', + 'meal', + 'comida', + 'nourriture', + 'eats', + 'groceries', + 'grocery', + 'hungry', + 'tasty', + 'mmm', + 'yummy', + 'feed', + 'hunger', + 'japanese', + 'ninja', + 'pattaya', + 'chinois', + 'asian', + 'chine', + 'brasil', + 'bresil', + 'lunch', + 'carbohydrates' + ]), + Emoji( + name: 'rice cracker', + char: '\u{1F358}', + shortName: 'rice_cracker', + emojiGroup: EmojiGroup.foodDrink, + emojiSubgroup: EmojiSubgroup.foodAsian, + keywords: [ + 'cracker', + 'rice', + 'uc6', + 'food', + 'sushi', + 'chinese', + 'vegetarian', + 'foods', + 'eat', + 'meal', + 'comida', + 'nourriture', + 'eats', + 'groceries', + 'grocery', + 'hungry', + 'tasty', + 'mmm', + 'yummy', + 'feed', + 'hunger', + 'chinois', + 'asian', + 'chine' + ]), + Emoji( + name: 'fish cake with swirl', + char: '\u{1F365}', + shortName: 'fish_cake', + emojiGroup: EmojiGroup.foodDrink, + emojiSubgroup: EmojiSubgroup.foodAsian, + keywords: [ + 'cake', + 'fish', + 'pastry', + 'swirl', + 'uc6', + 'food', + 'sushi', + 'japan', + 'seafood', + 'foods', + 'eat', + 'meal', + 'comida', + 'nourriture', + 'eats', + 'groceries', + 'grocery', + 'hungry', + 'tasty', + 'mmm', + 'yummy', + 'feed', + 'hunger', + 'japanese', + 'ninja' + ]), + Emoji( + name: 'fortune cookie', + char: '\u{1F960}', + shortName: 'fortune_cookie', + emojiGroup: EmojiGroup.foodDrink, + emojiSubgroup: EmojiSubgroup.foodAsian, + keywords: [ + 'prophecy', + 'uc10', + 'food', + 'luck', + 'sugar', + 'cookie', + 'chinese', + 'bake', + 'carbs', + 'pastry', + 'restaurant', + 'vegetarian', + 'foods', + 'eat', + 'meal', + 'comida', + 'nourriture', + 'eats', + 'groceries', + 'grocery', + 'hungry', + 'tasty', + 'mmm', + 'yummy', + 'feed', + 'hunger', + 'good luck', + 'lucky', + 'junk food', + 'dessert', + 'sweets', + 'cookies', + 'chinois', + 'asian', + 'chine', + 'baking', + 'carbohydrates', + 'pastries', + 'pâtisserie' + ]), + Emoji( + name: 'moon cake', + char: '\u{1F96E}', + shortName: 'moon_cake', + emojiGroup: EmojiGroup.foodDrink, + emojiSubgroup: EmojiSubgroup.foodAsian, + keywords: [ + 'uc11', + 'food', + 'cake', + 'celebrate', + 'sugar', + 'chinese', + 'bake', + 'carbs', + 'pastry', + 'foods', + 'eat', + 'meal', + 'comida', + 'nourriture', + 'eats', + 'groceries', + 'grocery', + 'hungry', + 'tasty', + 'mmm', + 'yummy', + 'feed', + 'hunger', + 'cupcake', + 'celebration', + 'event', + 'celebrating', + 'festa', + 'parties', + 'events', + 'new years', + 'new year', + 'fiesta', + 'fete', + 'newyear', + 'party', + 'festive', + 'festival', + 'yolo', + 'festejar', + 'junk food', + 'dessert', + 'sweets', + 'chinois', + 'asian', + 'chine', + 'baking', + 'carbohydrates', + 'pastries', + 'pâtisserie' + ]), + Emoji( + name: 'oden', + char: '\u{1F362}', + shortName: 'oden', + emojiGroup: EmojiGroup.foodDrink, + emojiSubgroup: EmojiSubgroup.foodAsian, + keywords: [ + 'kebab', + 'seafood', + 'skewer', + 'stick', + 'uc6', + 'food', + 'japan', + 'dinner', + 'restaurant', + 'foods', + 'eat', + 'meal', + 'comida', + 'nourriture', + 'eats', + 'groceries', + 'grocery', + 'hungry', + 'tasty', + 'mmm', + 'yummy', + 'feed', + 'hunger', + 'japanese', + 'ninja', + 'lunch' + ]), + Emoji( + name: 'dango', + char: '\u{1F361}', + shortName: 'dango', + emojiGroup: EmojiGroup.foodDrink, + emojiSubgroup: EmojiSubgroup.foodAsian, + keywords: [ + 'Japanese', + 'dessert', + 'skewer', + 'stick', + 'sweet', + 'uc6', + 'food', + 'japan', + 'sugar', + 'restaurant', + 'foods', + 'eat', + 'meal', + 'comida', + 'nourriture', + 'eats', + 'groceries', + 'grocery', + 'hungry', + 'tasty', + 'mmm', + 'yummy', + 'feed', + 'hunger', + 'japanese', + 'ninja', + 'junk food', + 'dessert', + 'sweets' + ]), + Emoji( + name: 'shaved ice', + char: '\u{1F367}', + shortName: 'shaved_ice', + emojiGroup: EmojiGroup.foodDrink, + emojiSubgroup: EmojiSubgroup.foodSweet, + keywords: [ + 'dessert', + 'ice', + 'shaved', + 'sweet', + 'uc6', + 'food', + 'ice cream', + 'hawaii', + 'sugar', + 'disney', + 'summer', + 'foods', + 'eat', + 'meal', + 'comida', + 'nourriture', + 'eats', + 'groceries', + 'grocery', + 'hungry', + 'tasty', + 'mmm', + 'yummy', + 'feed', + 'hunger', + 'aloha', + 'kawaii', + 'maui', + 'moana', + 'junk food', + 'dessert', + 'sweets', + 'cartoon', + 'weekend' + ]), + Emoji( + name: 'ice cream', + char: '\u{1F368}', + shortName: 'ice_cream', + emojiGroup: EmojiGroup.foodDrink, + emojiSubgroup: EmojiSubgroup.foodSweet, + keywords: [ + 'cream', + 'dessert', + 'ice', + 'sweet', + 'uc6', + 'food', + 'ice cream', + 'sugar', + 'summer', + 'restaurant', + 'foods', + 'eat', + 'meal', + 'comida', + 'nourriture', + 'eats', + 'groceries', + 'grocery', + 'hungry', + 'tasty', + 'mmm', + 'yummy', + 'feed', + 'hunger', + 'junk food', + 'dessert', + 'sweets', + 'weekend' + ]), + Emoji( + name: 'soft ice cream', + char: '\u{1F366}', + shortName: 'icecream', + emojiGroup: EmojiGroup.foodDrink, + emojiSubgroup: EmojiSubgroup.foodSweet, + keywords: [ + 'cream', + 'dessert', + 'ice', + 'icecream', + 'soft', + 'sweet', + 'uc6', + 'food', + 'italian', + 'ice cream', + 'sugar', + 'summer', + 'foods', + 'eat', + 'meal', + 'comida', + 'nourriture', + 'eats', + 'groceries', + 'grocery', + 'hungry', + 'tasty', + 'mmm', + 'yummy', + 'feed', + 'hunger', + 'italy', + 'italie', + 'junk food', + 'dessert', + 'sweets', + 'weekend' + ]), + Emoji( + name: 'pie', + char: '\u{1F967}', + shortName: 'pie', + emojiGroup: EmojiGroup.foodDrink, + emojiSubgroup: EmojiSubgroup.foodSweet, + keywords: [ + 'uc10', + 'food', + 'sugar', + 'bake', + 'carbs', + 'pastry', + 'quiche', + 'thanksgiving', + 'restaurant', + 'foods', + 'eat', + 'meal', + 'comida', + 'nourriture', + 'eats', + 'groceries', + 'grocery', + 'hungry', + 'tasty', + 'mmm', + 'yummy', + 'feed', + 'hunger', + 'junk food', + 'dessert', + 'sweets', + 'baking', + 'carbohydrates', + 'pastries', + 'pâtisserie', + 'tart' + ]), + Emoji( + name: 'cupcake', + char: '\u{1F9C1}', + shortName: 'cupcake', + emojiGroup: EmojiGroup.foodDrink, + emojiSubgroup: EmojiSubgroup.foodSweet, + keywords: [ + 'uc11', + 'food', + 'birthday', + 'happy birthday', + 'cake', + 'pink', + 'celebrate', + 'sugar', + 'bake', + 'carbs', + 'pastry', + 'foods', + 'eat', + 'meal', + 'comida', + 'nourriture', + 'eats', + 'groceries', + 'grocery', + 'hungry', + 'tasty', + 'mmm', + 'yummy', + 'feed', + 'hunger', + 'birth', + 'cumpleaños', + 'anniversaire', + 'bday', + 'Bon anniversaire', + 'joyeux anniversaire', + 'buon compleanno', + 'feliz cumpleaños', + 'alles Gute zum Geburtstag', + 'feliz Aniversário', + 'Gratulerer med dagen', + 'cupcake', + 'rose', + 'celebration', + 'event', + 'celebrating', + 'festa', + 'parties', + 'events', + 'new years', + 'new year', + 'fiesta', + 'fete', + 'newyear', + 'party', + 'festive', + 'festival', + 'yolo', + 'festejar', + 'junk food', + 'dessert', + 'sweets', + 'baking', + 'carbohydrates', + 'pastries', + 'pâtisserie' + ]), + Emoji( + name: 'shortcake', + char: '\u{1F370}', + shortName: 'cake', + emojiGroup: EmojiGroup.foodDrink, + emojiSubgroup: EmojiSubgroup.foodSweet, + keywords: [ + 'cake', + 'dessert', + 'pastry', + 'slice', + 'sweet', + 'uc6', + 'food', + 'cake', + 'sugar', + 'bake', + 'carbs', + 'pastry', + 'restaurant', + 'foods', + 'eat', + 'meal', + 'comida', + 'nourriture', + 'eats', + 'groceries', + 'grocery', + 'hungry', + 'tasty', + 'mmm', + 'yummy', + 'feed', + 'hunger', + 'cupcake', + 'junk food', + 'dessert', + 'sweets', + 'baking', + 'carbohydrates', + 'pastries', + 'pâtisserie' + ]), + Emoji( + name: 'birthday cake', + char: '\u{1F382}', + shortName: 'birthday', + emojiGroup: EmojiGroup.foodDrink, + emojiSubgroup: EmojiSubgroup.foodSweet, + keywords: [ + 'birthday', + 'cake', + 'celebration', + 'dessert', + 'pastry', + 'sweet', + 'uc6', + 'food', + 'holidays', + 'birthday', + 'happy birthday', + 'cake', + 'celebrate', + 'sugar', + 'facebook', + 'bake', + 'pastry', + 'foods', + 'eat', + 'meal', + 'comida', + 'nourriture', + 'eats', + 'groceries', + 'grocery', + 'hungry', + 'tasty', + 'mmm', + 'yummy', + 'feed', + 'hunger', + 'holiday', + 'birth', + 'cumpleaños', + 'anniversaire', + 'bday', + 'Bon anniversaire', + 'joyeux anniversaire', + 'buon compleanno', + 'feliz cumpleaños', + 'alles Gute zum Geburtstag', + 'feliz Aniversário', + 'Gratulerer med dagen', + 'cupcake', + 'celebration', + 'event', + 'celebrating', + 'festa', + 'parties', + 'events', + 'new years', + 'new year', + 'fiesta', + 'fete', + 'newyear', + 'party', + 'festive', + 'festival', + 'yolo', + 'festejar', + 'junk food', + 'dessert', + 'sweets', + 'baking', + 'pastries', + 'pâtisserie' + ]), + Emoji( + name: 'custard', + char: '\u{1F36E}', + shortName: 'custard', + emojiGroup: EmojiGroup.foodDrink, + emojiSubgroup: EmojiSubgroup.foodSweet, + keywords: [ + 'dessert', + 'pudding', + 'sweet', + 'uc6', + 'food', + 'sugar', + 'bake', + 'restaurant', + 'foods', + 'eat', + 'meal', + 'comida', + 'nourriture', + 'eats', + 'groceries', + 'grocery', + 'hungry', + 'tasty', + 'mmm', + 'yummy', + 'feed', + 'hunger', + 'junk food', + 'dessert', + 'sweets', + 'baking' + ]), + Emoji( + name: 'lollipop', + char: '\u{1F36D}', + shortName: 'lollipop', + emojiGroup: EmojiGroup.foodDrink, + emojiSubgroup: EmojiSubgroup.foodSweet, + keywords: [ + 'candy', + 'dessert', + 'sweet', + 'uc6', + 'food', + 'halloween', + 'candy', + 'sugar', + 'disney', + 'snacks', + 'foods', + 'eat', + 'meal', + 'comida', + 'nourriture', + 'eats', + 'groceries', + 'grocery', + 'hungry', + 'tasty', + 'mmm', + 'yummy', + 'feed', + 'hunger', + 'samhain', + 'candy cane', + 'candycane', + 'lolipop', + 'bonbon', + 'junk food', + 'dessert', + 'sweets', + 'cartoon', + 'snack' + ]), + Emoji( + name: 'candy', + char: '\u{1F36C}', + shortName: 'candy', + emojiGroup: EmojiGroup.foodDrink, + emojiSubgroup: EmojiSubgroup.foodSweet, + keywords: [ + 'dessert', + 'sweet', + 'uc6', + 'food', + 'halloween', + 'candy', + 'sugar', + 'snacks', + 'foods', + 'eat', + 'meal', + 'comida', + 'nourriture', + 'eats', + 'groceries', + 'grocery', + 'hungry', + 'tasty', + 'mmm', + 'yummy', + 'feed', + 'hunger', + 'samhain', + 'candy cane', + 'candycane', + 'lolipop', + 'bonbon', + 'junk food', + 'dessert', + 'sweets', + 'snack' + ]), + Emoji( + name: 'chocolate bar', + char: '\u{1F36B}', + shortName: 'chocolate_bar', + emojiGroup: EmojiGroup.foodDrink, + emojiSubgroup: EmojiSubgroup.foodSweet, + keywords: [ + 'bar', + 'chocolate', + 'dessert', + 'sweet', + 'uc6', + 'food', + 'halloween', + 'love', + 'girls night', + 'candy', + 'sugar', + 'easter', + 'cocoa', + 'snacks', + 'rich', + 'foods', + 'eat', + 'meal', + 'comida', + 'nourriture', + 'eats', + 'groceries', + 'grocery', + 'hungry', + 'tasty', + 'mmm', + 'yummy', + 'feed', + 'hunger', + 'samhain', + 'i love you', + 'te amo', + "je t'aime", + 'anniversary', + 'lovin', + 'amour', + 'aimer', + 'amor', + 'valentines day', + 'enamour', + 'lovey', + 'ladies night', + 'girls only', + 'girlfriend', + 'candy cane', + 'candycane', + 'lolipop', + 'bonbon', + 'junk food', + 'dessert', + 'sweets', + 'hot chocolate', + 'snack', + 'grand', + 'expensive', + 'fancy' + ]), + Emoji( + name: 'popcorn', + char: '\u{1F37F}', + shortName: 'popcorn', + emojiGroup: EmojiGroup.foodDrink, + emojiSubgroup: EmojiSubgroup.foodPrepared, + keywords: [ + 'popcorn', + 'uc8', + 'food', + 'celebrate', + 'snacks', + 'carbs', + 'vegetarian', + 'foods', + 'eat', + 'meal', + 'comida', + 'nourriture', + 'eats', + 'groceries', + 'grocery', + 'hungry', + 'tasty', + 'mmm', + 'yummy', + 'feed', + 'hunger', + 'celebration', + 'event', + 'celebrating', + 'festa', + 'parties', + 'events', + 'new years', + 'new year', + 'fiesta', + 'fete', + 'newyear', + 'party', + 'festive', + 'festival', + 'yolo', + 'festejar', + 'snack', + 'carbohydrates' + ]), + Emoji( + name: 'doughnut', + char: '\u{1F369}', + shortName: 'doughnut', + emojiGroup: EmojiGroup.foodDrink, + emojiSubgroup: EmojiSubgroup.foodSweet, + keywords: [ + 'dessert', + 'donut', + 'sweet', + 'uc6', + 'food', + 'sex', + 'vagina', + 'breakfast', + 'doughnut', + 'sugar', + 'bake', + 'carbs', + 'pastry', + 'foods', + 'eat', + 'meal', + 'comida', + 'nourriture', + 'eats', + 'groceries', + 'grocery', + 'hungry', + 'tasty', + 'mmm', + 'yummy', + 'feed', + 'hunger', + 'fuck', + 'fucking', + 'horny', + 'humping', + 'petit dejeuner', + 'donut', + 'junk food', + 'dessert', + 'sweets', + 'baking', + 'carbohydrates', + 'pastries', + 'pâtisserie' + ]), + Emoji( + name: 'cookie', + char: '\u{1F36A}', + shortName: 'cookie', + emojiGroup: EmojiGroup.foodDrink, + emojiSubgroup: EmojiSubgroup.foodSweet, + keywords: [ + 'dessert', + 'sweet', + 'uc6', + 'food', + 'christmas', + 'vagina', + 'sugar', + 'cookie', + 'bake', + 'snacks', + 'carbs', + 'pastry', + 'foods', + 'eat', + 'meal', + 'comida', + 'nourriture', + 'eats', + 'groceries', + 'grocery', + 'hungry', + 'tasty', + 'mmm', + 'yummy', + 'feed', + 'hunger', + 'navidad', + 'xmas', + 'noel', + 'merry christmas', + 'junk food', + 'dessert', + 'sweets', + 'cookies', + 'baking', + 'snack', + 'carbohydrates', + 'pastries', + 'pâtisserie' + ]), + Emoji( + name: 'chestnut', + char: '\u{1F330}', + shortName: 'chestnut', + emojiGroup: EmojiGroup.foodDrink, + emojiSubgroup: EmojiSubgroup.foodVegetable, + keywords: [ + 'plant', + 'uc6', + 'food', + 'nature', + 'plant', + 'christmas', + 'nut', + 'keto', + 'foods', + 'eat', + 'meal', + 'comida', + 'nourriture', + 'eats', + 'groceries', + 'grocery', + 'hungry', + 'tasty', + 'mmm', + 'yummy', + 'feed', + 'hunger', + 'plants', + 'navidad', + 'xmas', + 'noel', + 'merry christmas', + 'nuts' + ]), + Emoji( + name: 'peanuts', + char: '\u{1F95C}', + shortName: 'peanuts', + emojiGroup: EmojiGroup.foodDrink, + emojiSubgroup: EmojiSubgroup.foodVegetable, + keywords: [ + 'food', + 'nut', + 'peanut', + 'vegetable', + 'uc9', + 'food', + 'vegetables', + 'squirrel', + 'nut', + 'snacks', + 'picnic', + 'appetizer', + 'foods', + 'eat', + 'meal', + 'comida', + 'nourriture', + 'eats', + 'groceries', + 'grocery', + 'hungry', + 'tasty', + 'mmm', + 'yummy', + 'feed', + 'hunger', + 'vegetable', + 'veggie', + 'legume', + 'nuts', + 'snack', + 'apéro', + 'entrée' + ]), + Emoji( + name: 'honey pot', + char: '\u{1F36F}', + shortName: 'honey_pot', + emojiGroup: EmojiGroup.foodDrink, + emojiSubgroup: EmojiSubgroup.foodSweet, + keywords: [ + 'honey', + 'honeypot', + 'pot', + 'sweet', + 'uc6', + 'food', + 'vagina', + 'breakfast', + 'sugar', + 'condiment', + 'foods', + 'eat', + 'meal', + 'comida', + 'nourriture', + 'eats', + 'groceries', + 'grocery', + 'hungry', + 'tasty', + 'mmm', + 'yummy', + 'feed', + 'hunger', + 'petit dejeuner', + 'junk food', + 'dessert', + 'sweets', + 'condiments', + 'seasoning', + 'topping' + ]), + Emoji( + name: 'glass of milk', + char: '\u{1F95B}', + shortName: 'milk', + emojiGroup: EmojiGroup.foodDrink, + emojiSubgroup: EmojiSubgroup.drink, + keywords: [ + 'drink', + 'glass', + 'milk', + 'uc9', + 'drink', + 'christmas', + 'dinner', + 'restaurant', + 'drinks', + 'beverage', + 'navidad', + 'xmas', + 'noel', + 'merry christmas', + 'lunch' + ]), + Emoji( + name: 'baby bottle', + char: '\u{1F37C}', + shortName: 'baby_bottle', + emojiGroup: EmojiGroup.foodDrink, + emojiSubgroup: EmojiSubgroup.drink, + keywords: [ + 'baby', + 'bottle', + 'drink', + 'milk', + 'uc6', + 'food', + 'drink', + 'baby', + 'foods', + 'eat', + 'meal', + 'comida', + 'nourriture', + 'eats', + 'groceries', + 'grocery', + 'hungry', + 'tasty', + 'mmm', + 'yummy', + 'feed', + 'hunger', + 'drinks', + 'beverage', + 'kid', + 'babies', + 'infant', + 'infants', + 'crying kid', + 'bebe', + 'little', + 'petite', + 'bambino' + ]), + Emoji( + name: 'hot beverage', + char: '\u{2615}', + shortName: 'coffee', + emojiGroup: EmojiGroup.foodDrink, + emojiSubgroup: EmojiSubgroup.drink, + keywords: [ + 'beverage', + 'coffee', + 'drink', + 'hot', + 'steaming', + 'tea', + 'uc4', + 'drink', + 'caffeine', + 'steam', + 'morning', + 'coffee', + 'breakfast', + 'cocoa', + 'diet', + 'restaurant', + 'keto', + 'drinks', + 'beverage', + 'decaffeinated', + 'decaf', + 'steaming', + 'piping', + 'good morning', + 'starbucks', + 'petit dejeuner', + 'hot chocolate' + ]), + Emoji( + name: 'teacup without handle', + char: '\u{1F375}', + shortName: 'tea', + emojiGroup: EmojiGroup.foodDrink, + emojiSubgroup: EmojiSubgroup.drink, + keywords: [ + 'beverage', + 'cup', + 'drink', + 'tea', + 'teacup', + 'uc6', + 'drink', + 'japan', + 'caffeine', + 'steam', + 'morning', + 'tea', + 'breakfast', + 'england', + 'chinese', + 'diet', + 'restaurant', + 'keto', + 'drinks', + 'beverage', + 'japanese', + 'ninja', + 'decaffeinated', + 'decaf', + 'steaming', + 'piping', + 'good morning', + 'iced tea', + 'petit dejeuner', + 'united kingdom', + 'london', + 'uk', + 'chinois', + 'asian', + 'chine' + ]), + Emoji( + name: 'teapot', + char: '\u{1FAD6}', + shortName: 'teapot', + emojiGroup: EmojiGroup.foodDrink, + emojiSubgroup: EmojiSubgroup.drink, + keywords: [ + 'uc13', + 'japan', + 'breakfast', + 'chinese', + 'kettle', + 'japanese', + 'ninja', + 'petit dejeuner', + 'chinois', + 'asian', + 'chine', + 'teakettle', + 'caldron', + 'boiler', + 'théière', + 'teiera', + 'tetera', + 'infuser', + 'kyūsu', + 'tetsubin', + 'yixing' + ]), + Emoji( + name: 'mate', + char: '\u{1F9C9}', + shortName: 'mate', + emojiGroup: EmojiGroup.foodDrink, + emojiSubgroup: EmojiSubgroup.drink, + keywords: [ + 'uc12', + 'drink', + 'caffeine', + 'tea', + 'breakfast', + 'yerba', + 'drinks', + 'beverage', + 'decaffeinated', + 'decaf', + 'iced tea', + 'petit dejeuner', + 'chimarrão', + 'cimarrón', + 'maté' + ]), + Emoji( + name: 'bubble tea', + char: '\u{1F9CB}', + shortName: 'bubble_tea', + emojiGroup: EmojiGroup.foodDrink, + emojiSubgroup: EmojiSubgroup.drink, + keywords: [ + 'uc13', + 'drink', + 'japan', + 'thai', + 'chinese', + 'drinks', + 'beverage', + 'japanese', + 'ninja', + 'pattaya', + 'chinois', + 'asian', + 'chine' + ]), + Emoji( + name: 'beverage box', + char: '\u{1F9C3}', + shortName: 'beverage_box', + emojiGroup: EmojiGroup.foodDrink, + emojiSubgroup: EmojiSubgroup.drink, + keywords: [ + 'uc12', + 'drink', + 'apples', + 'citrus', + 'snacks', + 'drinks', + 'beverage', + 'juice', + 'lime', + 'snack' + ]), + Emoji( + name: 'cup with straw', + char: '\u{1F964}', + shortName: 'cup_with_straw', + emojiGroup: EmojiGroup.foodDrink, + emojiSubgroup: EmojiSubgroup.drink, + keywords: [ + 'uc10', + 'drink', + 'dinner', + 'soda', + 'restaurant', + 'keto', + 'drinks', + 'beverage', + 'lunch', + 'cola', + 'milkshake', + 'soft drink', + 'sippy cup', + 'coke', + 'pepsi' + ]), + Emoji( + name: 'sake', + char: '\u{1F376}', + shortName: 'sake', + emojiGroup: EmojiGroup.foodDrink, + emojiSubgroup: EmojiSubgroup.drink, + keywords: [ + 'bar', + 'beverage', + 'bottle', + 'cup', + 'drink', + 'uc6', + 'drink', + 'japan', + 'alcohol', + 'sake', + 'girls night', + 'chinese', + 'dinner', + 'restaurant', + 'drinks', + 'beverage', + 'japanese', + 'ninja', + 'liquor', + 'booze', + 'ladies night', + 'girls only', + 'girlfriend', + 'chinois', + 'asian', + 'chine', + 'lunch' + ]), + Emoji( + name: 'beer mug', + char: '\u{1F37A}', + shortName: 'beer', + emojiGroup: EmojiGroup.foodDrink, + emojiSubgroup: EmojiSubgroup.drink, + keywords: [ + 'bar', + 'beer', + 'drink', + 'mug', + 'uc6', + 'drink', + 'japan', + 'alcohol', + 'beer', + 'cocktail', + 'friend', + 'irish', + 'dinner', + 'german', + 'restaurant', + 'drinks', + 'beverage', + 'japanese', + 'ninja', + 'liquor', + 'booze', + 'martini', + 'friends', + 'friendship', + 'best friends', + 'bestfriends', + 'ami', + 'amiga', + 'amigo', + 'saint patricks day', + 'st patricks day', + 'leprechaun', + 'lunch', + 'oktoberfest', + 'octoberfest', + 'bratwurst' + ]), + Emoji( + name: 'clinking beer mugs', + char: '\u{1F37B}', + shortName: 'beers', + emojiGroup: EmojiGroup.foodDrink, + emojiSubgroup: EmojiSubgroup.drink, + keywords: [ + 'bar', + 'beer', + 'clink', + 'drink', + 'mug', + 'uc6', + 'drink', + 'alcohol', + 'cheers', + 'beer', + 'cocktail', + 'thank you', + 'girls night', + 'boys night', + 'harry potter', + 'friend', + 'celebrate', + 'irish', + 'toast', + 'german', + 'restaurant', + 'drinks', + 'beverage', + 'liquor', + 'booze', + 'gān bēi', + 'Na zdravi', + 'Proost', + 'Prost', + 'Sláinte', + 'Cin cin', + 'Kanpai', + 'Na zdrowie', + 'Saúde', + 'На здоровье', + 'Salud', + 'Skål', + 'Sei gesund', + 'santé', + 'martini', + 'thanks', + 'thankful', + 'praise', + 'gracias', + 'merci', + 'thankyou', + 'acceptable', + 'ladies night', + 'girls only', + 'girlfriend', + 'guys night', + 'friends', + 'friendship', + 'best friends', + 'bestfriends', + 'ami', + 'amiga', + 'amigo', + 'celebration', + 'event', + 'celebrating', + 'festa', + 'parties', + 'events', + 'new years', + 'new year', + 'fiesta', + 'fete', + 'newyear', + 'party', + 'festive', + 'festival', + 'yolo', + 'festejar', + 'saint patricks day', + 'st patricks day', + 'leprechaun', + 'oktoberfest', + 'octoberfest', + 'bratwurst' + ]), + Emoji( + name: 'clinking glasses', + char: '\u{1F942}', + shortName: 'champagne_glass', + emojiGroup: EmojiGroup.foodDrink, + emojiSubgroup: EmojiSubgroup.drink, + keywords: [ + 'celebrate', + 'clink', + 'drink', + 'glass', + 'uc9', + 'drink', + 'alcohol', + 'cheers', + 'girls night', + 'friend', + 'celebrate', + 'toast', + 'restaurant', + 'drinks', + 'beverage', + 'liquor', + 'booze', + 'gān bēi', + 'Na zdravi', + 'Proost', + 'Prost', + 'Sláinte', + 'Cin cin', + 'Kanpai', + 'Na zdrowie', + 'Saúde', + 'На здоровье', + 'Salud', + 'Skål', + 'Sei gesund', + 'santé', + 'ladies night', + 'girls only', + 'girlfriend', + 'friends', + 'friendship', + 'best friends', + 'bestfriends', + 'ami', + 'amiga', + 'amigo', + 'celebration', + 'event', + 'celebrating', + 'festa', + 'parties', + 'events', + 'new years', + 'new year', + 'fiesta', + 'fete', + 'newyear', + 'party', + 'festive', + 'festival', + 'yolo', + 'festejar' + ]), + Emoji( + name: 'wine glass', + char: '\u{1F377}', + shortName: 'wine_glass', + emojiGroup: EmojiGroup.foodDrink, + emojiSubgroup: EmojiSubgroup.drink, + keywords: [ + 'bar', + 'beverage', + 'drink', + 'glass', + 'wine', + 'uc6', + 'drink', + 'italian', + 'christmas', + 'alcohol', + 'cocktail', + 'girls night', + 'australia', + 'paris', + 'rich', + 'dinner', + 'picnic', + 'thanksgiving', + 'restaurant', + 'drinks', + 'beverage', + 'italy', + 'italie', + 'navidad', + 'xmas', + 'noel', + 'merry christmas', + 'liquor', + 'booze', + 'martini', + 'ladies night', + 'girls only', + 'girlfriend', + 'french', + 'france', + 'grand', + 'expensive', + 'fancy', + 'lunch' + ]), + Emoji( + name: 'tumbler glass', + char: '\u{1F943}', + shortName: 'tumbler_glass', + emojiGroup: EmojiGroup.foodDrink, + emojiSubgroup: EmojiSubgroup.drink, + keywords: [ + 'glass', + 'liquor', + 'shot', + 'tumbler', + 'whisky', + 'uc9', + 'drink', + 'japan', + 'alcohol', + 'cocktail', + 'boys night', + 'whisky', + 'irish', + 'scotland', + 'las vegas', + 'dinner', + 'shot', + 'restaurant', + 'keto', + 'drinks', + 'beverage', + 'japanese', + 'ninja', + 'liquor', + 'booze', + 'martini', + 'guys night', + 'whiskey', + 'scotch', + 'saint patricks day', + 'st patricks day', + 'leprechaun', + 'scottish', + 'vegas', + 'lunch' + ]), + Emoji( + name: 'cocktail glass', + char: '\u{1F378}', + shortName: 'cocktail', + emojiGroup: EmojiGroup.foodDrink, + emojiSubgroup: EmojiSubgroup.drink, + keywords: [ + 'bar', + 'cocktail', + 'drink', + 'glass', + 'uc6', + 'drink', + 'alcohol', + 'cocktail', + 'girls night', + 'las vegas', + 'dinner', + 'restaurant', + 'drinks', + 'beverage', + 'liquor', + 'booze', + 'martini', + 'ladies night', + 'girls only', + 'girlfriend', + 'vegas', + 'lunch' + ]), + Emoji( + name: 'tropical drink', + char: '\u{1F379}', + shortName: 'tropical_drink', + emojiGroup: EmojiGroup.foodDrink, + emojiSubgroup: EmojiSubgroup.drink, + keywords: [ + 'bar', + 'drink', + 'tropical', + 'uc6', + 'drink', + 'alcohol', + 'tropical', + 'cocktail', + 'tea', + 'citrus', + 'summer', + 'dinner', + 'restaurant', + 'drinks', + 'beverage', + 'liquor', + 'booze', + 'martini', + 'iced tea', + 'juice', + 'lime', + 'weekend', + 'lunch' + ]), + Emoji( + name: 'bottle with popping cork', + char: '\u{1F37E}', + shortName: 'champagne', + emojiGroup: EmojiGroup.foodDrink, + emojiSubgroup: EmojiSubgroup.drink, + keywords: [ + 'bar', + 'bottle', + 'cork', + 'drink', + 'popping', + 'uc8', + 'drink', + 'holidays', + 'alcohol', + 'cheers', + 'celebrate', + 'paris', + 'rich', + 'toast', + 'picnic', + 'restaurant', + 'drinks', + 'beverage', + 'holiday', + 'liquor', + 'booze', + 'gān bēi', + 'Na zdravi', + 'Proost', + 'Prost', + 'Sláinte', + 'Cin cin', + 'Kanpai', + 'Na zdrowie', + 'Saúde', + 'На здоровье', + 'Salud', + 'Skål', + 'Sei gesund', + 'santé', + 'celebration', + 'event', + 'celebrating', + 'festa', + 'parties', + 'events', + 'new years', + 'new year', + 'fiesta', + 'fete', + 'newyear', + 'party', + 'festive', + 'festival', + 'yolo', + 'festejar', + 'french', + 'france', + 'grand', + 'expensive', + 'fancy' + ]), + Emoji( + name: 'ice', + char: '\u{1F9CA}', + shortName: 'ice_cube', + emojiGroup: EmojiGroup.foodDrink, + emojiSubgroup: EmojiSubgroup.drink, + keywords: [ + 'uc12', + 'snow', + 'cold', + 'igloo', + 'cubo de hielo', + 'freeze', + 'frozen', + 'frost', + 'ice cube', + 'chilly', + 'chilled', + 'brisk', + 'freezing', + 'frostbite', + 'icicles', + 'glaçon', + 'cubetto di ghiaccio' + ]), + Emoji( + name: 'spoon', + char: '\u{1F944}', + shortName: 'spoon', + emojiGroup: EmojiGroup.foodDrink, + emojiSubgroup: EmojiSubgroup.dishware, + keywords: [ + 'spoon', + 'tableware', + 'uc9', + 'food', + 'cutlery', + 'steel', + 'utensils', + 'restaurant', + 'dishes', + 'foods', + 'eat', + 'meal', + 'comida', + 'nourriture', + 'eats', + 'groceries', + 'grocery', + 'hungry', + 'tasty', + 'mmm', + 'yummy', + 'feed', + 'hunger', + 'dish', + 'metal' + ]), + Emoji( + name: 'fork and knife', + char: '\u{1F374}', + shortName: 'fork_and_knife', + emojiGroup: EmojiGroup.foodDrink, + emojiSubgroup: EmojiSubgroup.dishware, + keywords: [ + 'cooking', + 'fork', + 'knife', + 'uc6', + 'food', + 'christmas', + 'cutlery', + 'dinner', + 'steel', + 'picnic', + 'independence day', + 'utensils', + 'restaurant', + 'dishes', + 'foods', + 'eat', + 'meal', + 'comida', + 'nourriture', + 'eats', + 'groceries', + 'grocery', + 'hungry', + 'tasty', + 'mmm', + 'yummy', + 'feed', + 'hunger', + 'navidad', + 'xmas', + 'noel', + 'merry christmas', + 'dish', + 'lunch', + 'metal', + '4th of july' + ]), + Emoji( + name: 'fork and knife with plate', + char: '\u{1F37D}\u{FE0F}', + shortName: 'fork_knife_plate', + emojiGroup: EmojiGroup.foodDrink, + emojiSubgroup: EmojiSubgroup.dishware, + keywords: [ + 'cooking', + 'fork', + 'knife', + 'plate', + 'uc7', + 'food', + 'cutlery', + 'diet', + 'dinner', + 'picnic', + 'utensils', + 'restaurant', + 'dishes', + 'foods', + 'eat', + 'meal', + 'comida', + 'nourriture', + 'eats', + 'groceries', + 'grocery', + 'hungry', + 'tasty', + 'mmm', + 'yummy', + 'feed', + 'hunger', + 'dish', + 'lunch' + ]), + Emoji( + name: 'bowl with spoon', + char: '\u{1F963}', + shortName: 'bowl_with_spoon', + emojiGroup: EmojiGroup.foodDrink, + emojiSubgroup: EmojiSubgroup.foodPrepared, + keywords: [ + 'uc10', + 'food', + 'breakfast', + 'soup', + 'dinner', + 'cereal', + 'restaurant', + 'foods', + 'eat', + 'meal', + 'comida', + 'nourriture', + 'eats', + 'groceries', + 'grocery', + 'hungry', + 'tasty', + 'mmm', + 'yummy', + 'feed', + 'hunger', + 'petit dejeuner', + 'lunch' + ]), + Emoji( + name: 'takeout box', + char: '\u{1F961}', + shortName: 'takeout_box', + emojiGroup: EmojiGroup.foodDrink, + emojiSubgroup: EmojiSubgroup.foodAsian, + keywords: [ + 'oyster pail', + 'uc10', + 'food', + 'chinese', + 'dinner', + 'oyster pail', + 'restaurant', + 'foods', + 'eat', + 'meal', + 'comida', + 'nourriture', + 'eats', + 'groceries', + 'grocery', + 'hungry', + 'tasty', + 'mmm', + 'yummy', + 'feed', + 'hunger', + 'chinois', + 'asian', + 'chine', + 'lunch' + ]), + Emoji( + name: 'chopsticks', + char: '\u{1F962}', + shortName: 'chopsticks', + emojiGroup: EmojiGroup.foodDrink, + emojiSubgroup: EmojiSubgroup.dishware, + keywords: [ + 'uc10', + 'food', + 'sushi', + 'japan', + 'chinese', + 'utensils', + 'restaurant', + 'foods', + 'eat', + 'meal', + 'comida', + 'nourriture', + 'eats', + 'groceries', + 'grocery', + 'hungry', + 'tasty', + 'mmm', + 'yummy', + 'feed', + 'hunger', + 'japanese', + 'ninja', + 'chinois', + 'asian', + 'chine' + ]), + Emoji( + name: 'salt', + char: '\u{1F9C2}', + shortName: 'salt', + emojiGroup: EmojiGroup.foodDrink, + emojiSubgroup: EmojiSubgroup.foodPrepared, + keywords: [ + 'uc11', + 'condiment', + 'restaurant', + 'condiments', + 'seasoning', + 'topping' + ]), + Emoji( + name: 'soccer ball', + char: '\u{26BD}', + shortName: 'soccer', + emojiGroup: EmojiGroup.activities, + emojiSubgroup: EmojiSubgroup.sport, + keywords: [ + 'ball', + 'football', + 'soccer', + 'uc5', + 'sport', + 'game', + 'ball', + 'football', + 'soccer', + 'play', + 'activity', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'games', + 'gaming', + 'balls', + 'ballon', + 'soccer ball', + 'world cup' + ]), + Emoji( + name: 'basketball', + char: '\u{1F3C0}', + shortName: 'basketball', + emojiGroup: EmojiGroup.activities, + emojiSubgroup: EmojiSubgroup.sport, + keywords: [ + 'ball', + 'hoop', + 'uc6', + 'sport', + 'game', + 'ball', + 'basketball', + 'play', + 'throw', + 'activity', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'games', + 'gaming', + 'balls', + 'ballon' + ]), + Emoji( + name: 'american football', + char: '\u{1F3C8}', + shortName: 'football', + emojiGroup: EmojiGroup.activities, + emojiSubgroup: EmojiSubgroup.sport, + keywords: [ + 'american', + 'ball', + 'football', + 'uc6', + 'sport', + 'america', + 'game', + 'ball', + 'football', + 'play', + 'texas', + 'throw', + 'activity', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'usa', + 'united states', + 'united states of america', + 'american', + 'games', + 'gaming', + 'balls', + 'ballon' + ]), + Emoji( + name: 'baseball', + char: '\u{26BE}', + shortName: 'baseball', + emojiGroup: EmojiGroup.activities, + emojiSubgroup: EmojiSubgroup.sport, + keywords: [ + 'ball', + 'uc5', + 'sport', + 'game', + 'ball', + 'play', + 'throw', + 'activity', + 'independence day', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'games', + 'gaming', + 'balls', + 'ballon', + '4th of july' + ]), + Emoji( + name: 'softball', + char: '\u{1F94E}', + shortName: 'softball', + emojiGroup: EmojiGroup.activities, + emojiSubgroup: EmojiSubgroup.sport, + keywords: [ + 'uc11', + 'sport', + 'game', + 'ball', + 'play', + 'fun', + 'throw', + 'activity', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'games', + 'gaming', + 'balls', + 'ballon' + ]), + Emoji( + name: 'tennis', + char: '\u{1F3BE}', + shortName: 'tennis', + emojiGroup: EmojiGroup.activities, + emojiSubgroup: EmojiSubgroup.sport, + keywords: [ + 'ball', + 'racquet', + 'uc6', + 'sport', + 'game', + 'ball', + 'tennis', + 'play', + 'fun', + 'activity', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'games', + 'gaming', + 'balls', + 'ballon', + 'tennis ball', + 'tennis racquet' + ]), + Emoji( + name: 'volleyball', + char: '\u{1F3D0}', + shortName: 'volleyball', + emojiGroup: EmojiGroup.activities, + emojiSubgroup: EmojiSubgroup.sport, + keywords: [ + 'ball', + 'game', + 'uc8', + 'sport', + 'game', + 'ball', + 'volley ball', + 'play', + 'activity', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'games', + 'gaming', + 'balls', + 'ballon' + ]), + Emoji( + name: 'rugby football', + char: '\u{1F3C9}', + shortName: 'rugby_football', + emojiGroup: EmojiGroup.activities, + emojiSubgroup: EmojiSubgroup.sport, + keywords: [ + 'ball', + 'football', + 'rugby', + 'uc6', + 'sport', + 'game', + 'ball', + 'football', + 'play', + 'throw', + 'activity', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'games', + 'gaming', + 'balls', + 'ballon' + ]), + Emoji( + name: 'flying disc', + char: '\u{1F94F}', + shortName: 'flying_disc', + emojiGroup: EmojiGroup.activities, + emojiSubgroup: EmojiSubgroup.sport, + keywords: [ + 'uc11', + 'sport', + 'game', + 'play', + 'fun', + 'throw', + 'activity', + 'frisbee', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'games', + 'gaming', + 'disque-volant', + 'boomerang' + ]), + Emoji( + name: 'boomerang', + char: '\u{1FA83}', + shortName: 'boomerang', + emojiGroup: EmojiGroup.objects, + emojiSubgroup: EmojiSubgroup.tool, + keywords: [ + 'uc13', + 'sport', + 'game', + 'play', + 'fun', + 'throw', + 'hunt', + 'activity', + 'boumerang', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'games', + 'gaming', + 'airfoil', + 'aerofoil' + ]), + Emoji( + name: 'pool 8 ball', + char: '\u{1F3B1}', + shortName: '8ball', + emojiGroup: EmojiGroup.activities, + emojiSubgroup: EmojiSubgroup.game, + keywords: [ + '8', + '8 ball', + 'ball', + 'billiard', + 'eight', + 'game', + 'uc6', + 'sport', + 'game', + 'ball', + 'billiards', + 'luck', + 'boys night', + 'play', + 'fun', + 'activity', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'games', + 'gaming', + 'balls', + 'ballon', + 'billiards ball', + '8 ball', + '8ball', + 'pool', + 'eight ball', + 'good luck', + 'lucky', + 'guys night' + ]), + Emoji( + name: 'yo-yo', + char: '\u{1FA80}', + shortName: 'yo_yo', + emojiGroup: EmojiGroup.activities, + emojiSubgroup: EmojiSubgroup.game, + keywords: [ + 'uc12', + 'game', + 'play', + 'activity', + 'yoyo', + 'toy', + 'stringed', + 'games', + 'gaming', + 'fluctuate' + ]), + Emoji( + name: 'ping pong', + char: '\u{1F3D3}', + shortName: 'ping_pong', + emojiGroup: EmojiGroup.activities, + emojiSubgroup: EmojiSubgroup.sport, + keywords: [ + 'ball', + 'bat', + 'game', + 'paddle', + 'ping pong', + 'table tennis', + 'uc8', + 'sport', + 'game', + 'ball', + 'ping pong', + 'play', + 'fun', + 'activity', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'games', + 'gaming', + 'balls', + 'ballon', + 'table tennis', + 'ping pong ball', + 'ping pong paddle', + 'paddle' + ]), + Emoji( + name: 'badminton', + char: '\u{1F3F8}', + shortName: 'badminton', + emojiGroup: EmojiGroup.activities, + emojiSubgroup: EmojiSubgroup.sport, + keywords: [ + 'birdie', + 'game', + 'racquet', + 'shuttlecock', + 'uc8', + 'sport', + 'game', + 'play', + 'fun', + 'activity', + 'stringed', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'games', + 'gaming' + ]), + Emoji( + name: 'ice hockey', + char: '\u{1F3D2}', + shortName: 'hockey', + emojiGroup: EmojiGroup.activities, + emojiSubgroup: EmojiSubgroup.sport, + keywords: [ + 'game', + 'hockey', + 'ice', + 'puck', + 'stick', + 'uc8', + 'sport', + 'game', + 'hockey', + 'play', + 'fun', + 'activity', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'games', + 'gaming', + 'field hockey' + ]), + Emoji( + name: 'field hockey', + char: '\u{1F3D1}', + shortName: 'field_hockey', + emojiGroup: EmojiGroup.activities, + emojiSubgroup: EmojiSubgroup.sport, + keywords: [ + 'ball', + 'field', + 'game', + 'hockey', + 'stick', + 'uc8', + 'sport', + 'game', + 'ball', + 'hockey', + 'play', + 'fun', + 'activity', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'games', + 'gaming', + 'balls', + 'ballon', + 'field hockey' + ]), + Emoji( + name: 'lacrosse', + char: '\u{1F94D}', + shortName: 'lacrosse', + emojiGroup: EmojiGroup.activities, + emojiSubgroup: EmojiSubgroup.sport, + keywords: [ + 'uc11', + 'sport', + 'game', + 'ball', + 'play', + 'fun', + 'throw', + 'activity', + 'stringed', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'games', + 'gaming', + 'balls', + 'ballon' + ]), + Emoji( + name: 'cricket game', + char: '\u{1F3CF}', + shortName: 'cricket_game', + emojiGroup: EmojiGroup.activities, + emojiSubgroup: EmojiSubgroup.sport, + keywords: [ + 'ball', + 'bat', + 'game', + 'uc8', + 'sport', + 'game', + 'ball', + 'cricket', + 'play', + 'fun', + 'activity', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'games', + 'gaming', + 'balls', + 'ballon', + 'cricket bat', + 'cricket ball' + ]), + Emoji( + name: 'goal net', + char: '\u{1F945}', + shortName: 'goal', + emojiGroup: EmojiGroup.activities, + emojiSubgroup: EmojiSubgroup.sport, + keywords: [ + 'goal', + 'net', + 'uc9', + 'sport', + 'football', + 'soccer', + 'play', + 'fun', + 'activity', + 'stringed', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'soccer ball', + 'world cup' + ]), + Emoji( + name: 'flag in hole', + char: '\u{26F3}', + shortName: 'golf', + emojiGroup: EmojiGroup.activities, + emojiSubgroup: EmojiSubgroup.sport, + keywords: [ + 'golf', + 'hole', + 'uc5', + 'sport', + 'game', + 'ball', + 'vacation', + 'golf', + 'play', + 'florida', + 'fun', + 'activity', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'games', + 'gaming', + 'balls', + 'ballon', + 'golfing', + 'golfer' + ]), + Emoji( + name: 'kite', + char: '\u{1FA81}', + shortName: 'kite', + emojiGroup: EmojiGroup.activities, + emojiSubgroup: EmojiSubgroup.game, + keywords: [ + 'uc12', + 'sport', + 'fly', + 'vacation', + 'fun', + 'activity', + 'toy', + 'kite', + 'stringed', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'flight', + 'flying', + 'flights', + 'avion' + ]), + Emoji( + name: 'bow and arrow', + char: '\u{1F3F9}', + shortName: 'bow_and_arrow', + emojiGroup: EmojiGroup.objects, + emojiSubgroup: EmojiSubgroup.tool, + keywords: [ + 'Sagittarius', + 'archer', + 'archery', + 'arrow', + 'bow', + 'tool', + 'weapon', + 'zodiac', + 'uc8', + 'sport', + 'weapon', + 'arrow', + 'game', + 'play', + 'target', + 'minecraft', + 'activity', + 'stringed', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'weapons', + 'arrows', + 'games', + 'gaming' + ]), + Emoji( + name: 'fishing pole', + char: '\u{1F3A3}', + shortName: 'fishing_pole_and_fish', + emojiGroup: EmojiGroup.activities, + emojiSubgroup: EmojiSubgroup.sport, + keywords: [ + 'fish', + 'pole', + 'uc6', + 'sport', + 'vacation', + 'fishing', + 'florida', + 'fun', + 'activity', + 'stringed', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'fish', + 'fishing pole', + 'fishing rod' + ]), + Emoji( + name: 'diving mask', + char: '\u{1F93F}', + shortName: 'diving_mask', + emojiGroup: EmojiGroup.activities, + emojiSubgroup: EmojiSubgroup.sport, + keywords: [ + 'uc12', + 'sport', + 'glasses', + 'vacation', + 'swim', + 'scuba', + 'fun', + 'activity', + 'mask', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'eyeglasses', + 'eye glasses', + 'swimming', + 'swimmer', + 'snorkel' + ]), + Emoji( + name: 'boxing glove', + char: '\u{1F94A}', + shortName: 'boxing_glove', + emojiGroup: EmojiGroup.activities, + emojiSubgroup: EmojiSubgroup.sport, + keywords: [ + 'boxing', + 'glove', + 'uc9', + 'sport', + 'fight', + 'gloves', + 'hit', + 'activity', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'punch', + 'pow', + 'bam' + ]), + Emoji( + name: 'martial arts uniform', + char: '\u{1F94B}', + shortName: 'martial_arts_uniform', + emojiGroup: EmojiGroup.activities, + emojiSubgroup: EmojiSubgroup.sport, + keywords: [ + 'judo', + 'karate', + 'martial arts', + 'taekwondo', + 'uniform', + 'uc9', + 'sport', + 'fight', + 'karate', + 'activity', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout' + ]), + Emoji( + name: 'running shirt', + char: '\u{1F3BD}', + shortName: 'running_shirt_with_sash', + emojiGroup: EmojiGroup.activities, + emojiSubgroup: EmojiSubgroup.sport, + keywords: [ + 'athletics', + 'running', + 'sash', + 'shirt', + 'uc6', + 'sport', + 'award', + 'activity', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'awards', + 'prize', + 'prizes', + 'trophy', + 'trophies', + 'spot', + 'best', + 'champion', + 'hero' + ]), + Emoji( + name: 'skateboard', + char: '\u{1F6F9}', + shortName: 'skateboard', + emojiGroup: EmojiGroup.travelPlaces, + emojiSubgroup: EmojiSubgroup.transportGround, + keywords: [ + 'uc11', + 'sport', + 'fun', + 'activity', + 'boosted', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'boosted board' + ]), + Emoji( + name: 'roller skate', + char: '\u{1F6FC}', + shortName: 'roller_skate', + emojiGroup: EmojiGroup.travelPlaces, + emojiSubgroup: EmojiSubgroup.transportGround, + keywords: [ + 'uc13', + 'sport', + 'fun', + 'activity', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout' + ]), + Emoji( + name: 'sled', + char: '\u{1F6F7}', + shortName: 'sled', + emojiGroup: EmojiGroup.activities, + emojiSubgroup: EmojiSubgroup.sport, + keywords: [ + 'uc10', + 'sport', + 'winter', + 'christmas', + 'fun', + 'activity', + 'sleigh', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'navidad', + 'xmas', + 'noel', + 'merry christmas', + 'sledge', + 'toboggan' + ]), + Emoji( + name: 'ice skate', + char: '\u{26F8}\u{FE0F}', + shortName: 'ice_skate', + emojiGroup: EmojiGroup.activities, + emojiSubgroup: EmojiSubgroup.sport, + keywords: [ + 'ice', + 'skate', + 'uc5', + 'sport', + 'winter', + 'cold', + 'ice skating', + 'disney', + 'fun', + 'activity', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'chilly', + 'chilled', + 'brisk', + 'freezing', + 'frostbite', + 'icicles', + 'cartoon' + ]), + Emoji( + name: 'curling stone', + char: '\u{1F94C}', + shortName: 'curling_stone', + emojiGroup: EmojiGroup.activities, + emojiSubgroup: EmojiSubgroup.sport, + keywords: [ + 'game', + 'rock', + 'uc10', + 'sport', + 'winter', + 'game', + 'play', + 'activity', + 'iron', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'games', + 'gaming' + ]), + Emoji( + name: 'skis', + char: '\u{1F3BF}', + shortName: 'ski', + emojiGroup: EmojiGroup.activities, + emojiSubgroup: EmojiSubgroup.sport, + keywords: [ + 'ski', + 'snow', + 'uc6', + 'sport', + 'winter', + 'cold', + 'skiing', + 'activity', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'chilly', + 'chilled', + 'brisk', + 'freezing', + 'frostbite', + 'icicles', + 'ski', + 'snow skiing', + 'ski boot' + ]), + Emoji( + name: 'skier', + char: '\u{26F7}\u{FE0F}', + shortName: 'skier', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personSport, + keywords: [ + 'ski', + 'snow', + 'uc5', + 'sport', + 'winter', + 'vacation', + 'cold', + 'skiing', + 'paris', + 'fun', + 'activity', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'chilly', + 'chilled', + 'brisk', + 'freezing', + 'frostbite', + 'icicles', + 'ski', + 'snow skiing', + 'ski boot', + 'french', + 'france' + ]), + Emoji( + name: 'snowboarder', + char: '\u{1F3C2}', + shortName: 'snowboarder', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personSport, + keywords: [ + 'ski', + 'snow', + 'snowboard', + 'uc6', + 'sport', + 'diversity', + 'winter', + 'vacation', + 'cold', + 'snowboarding', + 'fun', + 'activity', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'chilly', + 'chilled', + 'brisk', + 'freezing', + 'frostbite', + 'icicles', + 'snowboarder' + ]), + Emoji( + name: 'snowboarder: light skin tone', + char: '\u{1F3C2}\u{1F3FB}', + shortName: 'snowboarder_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personSport, + keywords: [ + 'light skin tone', + 'ski', + 'snow', + 'snowboard', + 'uc8', + 'sport', + 'diversity', + 'winter', + 'vacation', + 'cold', + 'snowboarding', + 'fun', + 'activity', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'chilly', + 'chilled', + 'brisk', + 'freezing', + 'frostbite', + 'icicles', + 'snowboarder' + ], + modifiable: true), + Emoji( + name: 'snowboarder: medium-light skin tone', + char: '\u{1F3C2}\u{1F3FC}', + shortName: 'snowboarder_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personSport, + keywords: [ + 'medium-light skin tone', + 'ski', + 'snow', + 'snowboard', + 'uc8', + 'sport', + 'diversity', + 'winter', + 'vacation', + 'cold', + 'snowboarding', + 'fun', + 'activity', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'chilly', + 'chilled', + 'brisk', + 'freezing', + 'frostbite', + 'icicles', + 'snowboarder' + ], + modifiable: true), + Emoji( + name: 'snowboarder: medium skin tone', + char: '\u{1F3C2}\u{1F3FD}', + shortName: 'snowboarder_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personSport, + keywords: [ + 'medium skin tone', + 'ski', + 'snow', + 'snowboard', + 'uc8', + 'sport', + 'diversity', + 'winter', + 'vacation', + 'cold', + 'snowboarding', + 'fun', + 'activity', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'chilly', + 'chilled', + 'brisk', + 'freezing', + 'frostbite', + 'icicles', + 'snowboarder' + ], + modifiable: true), + Emoji( + name: 'snowboarder: medium-dark skin tone', + char: '\u{1F3C2}\u{1F3FE}', + shortName: 'snowboarder_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personSport, + keywords: [ + 'medium-dark skin tone', + 'ski', + 'snow', + 'snowboard', + 'uc8', + 'sport', + 'diversity', + 'winter', + 'vacation', + 'cold', + 'snowboarding', + 'fun', + 'activity', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'chilly', + 'chilled', + 'brisk', + 'freezing', + 'frostbite', + 'icicles', + 'snowboarder' + ], + modifiable: true), + Emoji( + name: 'snowboarder: dark skin tone', + char: '\u{1F3C2}\u{1F3FF}', + shortName: 'snowboarder_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personSport, + keywords: [ + 'dark skin tone', + 'ski', + 'snow', + 'snowboard', + 'uc8', + 'sport', + 'diversity', + 'winter', + 'vacation', + 'cold', + 'snowboarding', + 'fun', + 'activity', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'chilly', + 'chilled', + 'brisk', + 'freezing', + 'frostbite', + 'icicles', + 'snowboarder' + ], + modifiable: true), + Emoji( + name: 'parachute', + char: '\u{1FA82}', + shortName: 'parachute', + emojiGroup: EmojiGroup.travelPlaces, + emojiSubgroup: EmojiSubgroup.transportAir, + keywords: [ + 'uc12', + 'sport', + 'fly', + 'vacation', + 'airplane', + 'fun', + 'activity', + 'kite', + 'skydive', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'flight', + 'flying', + 'flights', + 'avion', + 'airline', + 'aircraft', + 'airforce', + 'air force', + 'airport', + 'hang-glide', + 'parasail' + ]), + Emoji( + name: 'person lifting weights', + char: '\u{1F3CB}', + shortName: 'person_lifting_weights', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personSport, + keywords: [ + 'lifter', + 'weight', + 'uc7', + 'sport', + 'diversity', + 'flex', + 'weight lifting', + 'activity', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'strong', + 'weight lifter' + ]), + Emoji( + name: 'person lifting weights: light skin tone', + char: '\u{1F3CB}\u{1F3FB}', + shortName: 'person_lifting_weights_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personSport, + keywords: [ + 'lifter', + 'light skin tone', + 'weight', + 'uc8', + 'sport', + 'diversity', + 'flex', + 'weight lifting', + 'activity', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'strong', + 'weight lifter' + ], + modifiable: true), + Emoji( + name: 'person lifting weights: medium-light skin tone', + char: '\u{1F3CB}\u{1F3FC}', + shortName: 'person_lifting_weights_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personSport, + keywords: [ + 'lifter', + 'medium-light skin tone', + 'weight', + 'uc8', + 'sport', + 'diversity', + 'flex', + 'weight lifting', + 'activity', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'strong', + 'weight lifter' + ], + modifiable: true), + Emoji( + name: 'person lifting weights: medium skin tone', + char: '\u{1F3CB}\u{1F3FD}', + shortName: 'person_lifting_weights_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personSport, + keywords: [ + 'lifter', + 'medium skin tone', + 'weight', + 'uc8', + 'sport', + 'diversity', + 'flex', + 'weight lifting', + 'activity', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'strong', + 'weight lifter' + ], + modifiable: true), + Emoji( + name: 'person lifting weights: medium-dark skin tone', + char: '\u{1F3CB}\u{1F3FE}', + shortName: 'person_lifting_weights_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personSport, + keywords: [ + 'lifter', + 'medium-dark skin tone', + 'weight', + 'uc8', + 'sport', + 'diversity', + 'flex', + 'weight lifting', + 'activity', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'strong', + 'weight lifter' + ], + modifiable: true), + Emoji( + name: 'person lifting weights: dark skin tone', + char: '\u{1F3CB}\u{1F3FF}', + shortName: 'person_lifting_weights_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personSport, + keywords: [ + 'dark skin tone', + 'lifter', + 'weight', + 'uc8', + 'sport', + 'diversity', + 'flex', + 'weight lifting', + 'activity', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'strong', + 'weight lifter' + ], + modifiable: true), + Emoji( + name: 'woman lifting weights', + char: '\u{1F3CB}\u{FE0F}\u{200D}\u{2640}\u{FE0F}', + shortName: 'woman_lifting_weights', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personSport, + keywords: [ + 'weight lifter', + 'woman', + 'uc7', + 'sport', + 'diversity', + 'flex', + 'weight lifting', + 'activity', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'strong', + 'weight lifter' + ]), + Emoji( + name: 'woman lifting weights: light skin tone', + char: '\u{1F3CB}\u{1F3FB}\u{200D}\u{2640}\u{FE0F}', + shortName: 'woman_lifting_weights_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personSport, + keywords: [ + 'light skin tone', + 'weight lifter', + 'woman', + 'uc8', + 'sport', + 'diversity', + 'flex', + 'weight lifting', + 'activity', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'strong', + 'weight lifter' + ], + modifiable: true), + Emoji( + name: 'woman lifting weights: medium-light skin tone', + char: '\u{1F3CB}\u{1F3FC}\u{200D}\u{2640}\u{FE0F}', + shortName: 'woman_lifting_weights_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personSport, + keywords: [ + 'medium-light skin tone', + 'weight lifter', + 'woman', + 'uc8', + 'sport', + 'diversity', + 'flex', + 'weight lifting', + 'activity', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'strong', + 'weight lifter' + ], + modifiable: true), + Emoji( + name: 'woman lifting weights: medium skin tone', + char: '\u{1F3CB}\u{1F3FD}\u{200D}\u{2640}\u{FE0F}', + shortName: 'woman_lifting_weights_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personSport, + keywords: [ + 'medium skin tone', + 'weight lifter', + 'woman', + 'uc8', + 'sport', + 'diversity', + 'flex', + 'weight lifting', + 'activity', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'strong', + 'weight lifter' + ], + modifiable: true), + Emoji( + name: 'woman lifting weights: medium-dark skin tone', + char: '\u{1F3CB}\u{1F3FE}\u{200D}\u{2640}\u{FE0F}', + shortName: 'woman_lifting_weights_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personSport, + keywords: [ + 'medium-dark skin tone', + 'weight lifter', + 'woman', + 'uc8', + 'sport', + 'diversity', + 'flex', + 'weight lifting', + 'activity', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'strong', + 'weight lifter' + ], + modifiable: true), + Emoji( + name: 'woman lifting weights: dark skin tone', + char: '\u{1F3CB}\u{1F3FF}\u{200D}\u{2640}\u{FE0F}', + shortName: 'woman_lifting_weights_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personSport, + keywords: [ + 'dark skin tone', + 'weight lifter', + 'woman', + 'uc8', + 'sport', + 'diversity', + 'flex', + 'weight lifting', + 'activity', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'strong', + 'weight lifter' + ], + modifiable: true), + Emoji( + name: 'man lifting weights', + char: '\u{1F3CB}\u{FE0F}\u{200D}\u{2642}\u{FE0F}', + shortName: 'man_lifting_weights', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personSport, + keywords: [ + 'man', + 'weight lifter', + 'uc7', + 'sport', + 'diversity', + 'flex', + 'weight lifting', + 'activity', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'strong', + 'weight lifter' + ]), + Emoji( + name: 'man lifting weights: light skin tone', + char: '\u{1F3CB}\u{1F3FB}\u{200D}\u{2642}\u{FE0F}', + shortName: 'man_lifting_weights_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personSport, + keywords: [ + 'light skin tone', + 'man', + 'weight lifter', + 'uc8', + 'sport', + 'diversity', + 'flex', + 'weight lifting', + 'activity', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'strong', + 'weight lifter' + ], + modifiable: true), + Emoji( + name: 'man lifting weights: medium-light skin tone', + char: '\u{1F3CB}\u{1F3FC}\u{200D}\u{2642}\u{FE0F}', + shortName: 'man_lifting_weights_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personSport, + keywords: [ + 'man', + 'medium-light skin tone', + 'weight lifter', + 'uc8', + 'sport', + 'diversity', + 'flex', + 'weight lifting', + 'activity', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'strong', + 'weight lifter' + ], + modifiable: true), + Emoji( + name: 'man lifting weights: medium skin tone', + char: '\u{1F3CB}\u{1F3FD}\u{200D}\u{2642}\u{FE0F}', + shortName: 'man_lifting_weights_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personSport, + keywords: [ + 'man', + 'medium skin tone', + 'weight lifter', + 'uc8', + 'sport', + 'diversity', + 'flex', + 'weight lifting', + 'activity', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'strong', + 'weight lifter' + ], + modifiable: true), + Emoji( + name: 'man lifting weights: medium-dark skin tone', + char: '\u{1F3CB}\u{1F3FE}\u{200D}\u{2642}\u{FE0F}', + shortName: 'man_lifting_weights_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personSport, + keywords: [ + 'man', + 'medium-dark skin tone', + 'weight lifter', + 'uc8', + 'sport', + 'diversity', + 'flex', + 'weight lifting', + 'activity', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'strong', + 'weight lifter' + ], + modifiable: true), + Emoji( + name: 'man lifting weights: dark skin tone', + char: '\u{1F3CB}\u{1F3FF}\u{200D}\u{2642}\u{FE0F}', + shortName: 'man_lifting_weights_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personSport, + keywords: [ + 'dark skin tone', + 'man', + 'weight lifter', + 'uc8', + 'sport', + 'diversity', + 'flex', + 'weight lifting', + 'activity', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'strong', + 'weight lifter' + ], + modifiable: true), + Emoji( + name: 'people wrestling', + char: '\u{1F93C}', + shortName: 'people_wrestling', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personSport, + keywords: [ + 'wrestle', + 'wrestler', + 'uc9', + 'sport', + 'fight', + 'activity', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout' + ]), + Emoji( + name: 'women wrestling', + char: '\u{1F93C}\u{200D}\u{2640}\u{FE0F}', + shortName: 'women_wrestling', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personSport, + keywords: [ + 'women', + 'wrestle', + 'uc9', + 'sport', + 'fight', + 'activity', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout' + ]), + Emoji( + name: 'men wrestling', + char: '\u{1F93C}\u{200D}\u{2642}\u{FE0F}', + shortName: 'men_wrestling', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personSport, + keywords: [ + 'men', + 'wrestle', + 'uc9', + 'sport', + 'fight', + 'activity', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout' + ]), + Emoji( + name: 'person cartwheeling', + char: '\u{1F938}', + shortName: 'person_doing_cartwheel', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personSport, + keywords: [ + 'cartwheel', + 'gymnastics', + 'uc9', + 'sport', + 'circus', + 'gymnast', + 'yoga', + 'fun', + 'activity', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'circus tent', + 'clown', + 'clowns', + 'gymnastics', + 'meditation', + 'meditate', + 'zen', + 'om', + 'aum' + ]), + Emoji( + name: 'person cartwheeling: light skin tone', + char: '\u{1F938}\u{1F3FB}', + shortName: 'person_doing_cartwheel_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personSport, + keywords: [ + 'cartwheel', + 'gymnastics', + 'light skin tone', + 'uc9', + 'sport', + 'circus', + 'gymnast', + 'yoga', + 'fun', + 'activity', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'circus tent', + 'clown', + 'clowns', + 'gymnastics', + 'meditation', + 'meditate', + 'zen', + 'om', + 'aum' + ], + modifiable: true), + Emoji( + name: 'person cartwheeling: medium-light skin tone', + char: '\u{1F938}\u{1F3FC}', + shortName: 'person_doing_cartwheel_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personSport, + keywords: [ + 'cartwheel', + 'gymnastics', + 'medium-light skin tone', + 'uc9', + 'sport', + 'circus', + 'gymnast', + 'yoga', + 'fun', + 'activity', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'circus tent', + 'clown', + 'clowns', + 'gymnastics', + 'meditation', + 'meditate', + 'zen', + 'om', + 'aum' + ], + modifiable: true), + Emoji( + name: 'person cartwheeling: medium skin tone', + char: '\u{1F938}\u{1F3FD}', + shortName: 'person_doing_cartwheel_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personSport, + keywords: [ + 'cartwheel', + 'gymnastics', + 'medium skin tone', + 'uc9', + 'sport', + 'circus', + 'gymnast', + 'yoga', + 'fun', + 'activity', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'circus tent', + 'clown', + 'clowns', + 'gymnastics', + 'meditation', + 'meditate', + 'zen', + 'om', + 'aum' + ], + modifiable: true), + Emoji( + name: 'person cartwheeling: medium-dark skin tone', + char: '\u{1F938}\u{1F3FE}', + shortName: 'person_doing_cartwheel_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personSport, + keywords: [ + 'cartwheel', + 'gymnastics', + 'medium-dark skin tone', + 'uc9', + 'sport', + 'circus', + 'gymnast', + 'yoga', + 'fun', + 'activity', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'circus tent', + 'clown', + 'clowns', + 'gymnastics', + 'meditation', + 'meditate', + 'zen', + 'om', + 'aum' + ], + modifiable: true), + Emoji( + name: 'person cartwheeling: dark skin tone', + char: '\u{1F938}\u{1F3FF}', + shortName: 'person_doing_cartwheel_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personSport, + keywords: [ + 'cartwheel', + 'dark skin tone', + 'gymnastics', + 'uc9', + 'sport', + 'circus', + 'gymnast', + 'yoga', + 'fun', + 'activity', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'circus tent', + 'clown', + 'clowns', + 'gymnastics', + 'meditation', + 'meditate', + 'zen', + 'om', + 'aum' + ], + modifiable: true), + Emoji( + name: 'woman cartwheeling', + char: '\u{1F938}\u{200D}\u{2640}\u{FE0F}', + shortName: 'woman_cartwheeling', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personSport, + keywords: [ + 'cartwheel', + 'gymnastics', + 'woman', + 'uc9', + 'sport', + 'diversity', + 'circus', + 'gymnast', + 'yoga', + 'fun', + 'activity', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'circus tent', + 'clown', + 'clowns', + 'gymnastics', + 'meditation', + 'meditate', + 'zen', + 'om', + 'aum' + ]), + Emoji( + name: 'woman cartwheeling: light skin tone', + char: '\u{1F938}\u{1F3FB}\u{200D}\u{2640}\u{FE0F}', + shortName: 'woman_cartwheeling_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personSport, + keywords: [ + 'cartwheel', + 'gymnastics', + 'light skin tone', + 'woman', + 'uc9', + 'sport', + 'diversity', + 'circus', + 'gymnast', + 'yoga', + 'fun', + 'activity', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'circus tent', + 'clown', + 'clowns', + 'gymnastics', + 'meditation', + 'meditate', + 'zen', + 'om', + 'aum' + ], + modifiable: true), + Emoji( + name: 'woman cartwheeling: medium-light skin tone', + char: '\u{1F938}\u{1F3FC}\u{200D}\u{2640}\u{FE0F}', + shortName: 'woman_cartwheeling_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personSport, + keywords: [ + 'cartwheel', + 'gymnastics', + 'medium-light skin tone', + 'woman', + 'uc9', + 'sport', + 'diversity', + 'circus', + 'gymnast', + 'yoga', + 'fun', + 'activity', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'circus tent', + 'clown', + 'clowns', + 'gymnastics', + 'meditation', + 'meditate', + 'zen', + 'om', + 'aum' + ], + modifiable: true), + Emoji( + name: 'woman cartwheeling: medium skin tone', + char: '\u{1F938}\u{1F3FD}\u{200D}\u{2640}\u{FE0F}', + shortName: 'woman_cartwheeling_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personSport, + keywords: [ + 'cartwheel', + 'gymnastics', + 'medium skin tone', + 'woman', + 'uc9', + 'sport', + 'diversity', + 'circus', + 'gymnast', + 'yoga', + 'fun', + 'activity', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'circus tent', + 'clown', + 'clowns', + 'gymnastics', + 'meditation', + 'meditate', + 'zen', + 'om', + 'aum' + ], + modifiable: true), + Emoji( + name: 'woman cartwheeling: medium-dark skin tone', + char: '\u{1F938}\u{1F3FE}\u{200D}\u{2640}\u{FE0F}', + shortName: 'woman_cartwheeling_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personSport, + keywords: [ + 'cartwheel', + 'gymnastics', + 'medium-dark skin tone', + 'woman', + 'uc9', + 'sport', + 'diversity', + 'circus', + 'gymnast', + 'yoga', + 'fun', + 'activity', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'circus tent', + 'clown', + 'clowns', + 'gymnastics', + 'meditation', + 'meditate', + 'zen', + 'om', + 'aum' + ], + modifiable: true), + Emoji( + name: 'woman cartwheeling: dark skin tone', + char: '\u{1F938}\u{1F3FF}\u{200D}\u{2640}\u{FE0F}', + shortName: 'woman_cartwheeling_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personSport, + keywords: [ + 'cartwheel', + 'dark skin tone', + 'gymnastics', + 'woman', + 'uc9', + 'sport', + 'diversity', + 'circus', + 'gymnast', + 'yoga', + 'fun', + 'activity', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'circus tent', + 'clown', + 'clowns', + 'gymnastics', + 'meditation', + 'meditate', + 'zen', + 'om', + 'aum' + ], + modifiable: true), + Emoji( + name: 'man cartwheeling', + char: '\u{1F938}\u{200D}\u{2642}\u{FE0F}', + shortName: 'man_cartwheeling', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personSport, + keywords: [ + 'cartwheel', + 'gymnastics', + 'man', + 'uc9', + 'sport', + 'diversity', + 'circus', + 'gymnast', + 'yoga', + 'fun', + 'activity', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'circus tent', + 'clown', + 'clowns', + 'gymnastics', + 'meditation', + 'meditate', + 'zen', + 'om', + 'aum' + ]), + Emoji( + name: 'man cartwheeling: light skin tone', + char: '\u{1F938}\u{1F3FB}\u{200D}\u{2642}\u{FE0F}', + shortName: 'man_cartwheeling_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personSport, + keywords: [ + 'cartwheel', + 'gymnastics', + 'light skin tone', + 'man', + 'uc9', + 'sport', + 'diversity', + 'circus', + 'gymnast', + 'yoga', + 'fun', + 'activity', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'circus tent', + 'clown', + 'clowns', + 'gymnastics', + 'meditation', + 'meditate', + 'zen', + 'om', + 'aum' + ], + modifiable: true), + Emoji( + name: 'man cartwheeling: medium-light skin tone', + char: '\u{1F938}\u{1F3FC}\u{200D}\u{2642}\u{FE0F}', + shortName: 'man_cartwheeling_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personSport, + keywords: [ + 'cartwheel', + 'gymnastics', + 'man', + 'medium-light skin tone', + 'uc9', + 'sport', + 'diversity', + 'circus', + 'gymnast', + 'yoga', + 'fun', + 'activity', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'circus tent', + 'clown', + 'clowns', + 'gymnastics', + 'meditation', + 'meditate', + 'zen', + 'om', + 'aum' + ], + modifiable: true), + Emoji( + name: 'man cartwheeling: medium skin tone', + char: '\u{1F938}\u{1F3FD}\u{200D}\u{2642}\u{FE0F}', + shortName: 'man_cartwheeling_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personSport, + keywords: [ + 'cartwheel', + 'gymnastics', + 'man', + 'medium skin tone', + 'uc9', + 'sport', + 'diversity', + 'circus', + 'gymnast', + 'yoga', + 'fun', + 'activity', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'circus tent', + 'clown', + 'clowns', + 'gymnastics', + 'meditation', + 'meditate', + 'zen', + 'om', + 'aum' + ], + modifiable: true), + Emoji( + name: 'man cartwheeling: medium-dark skin tone', + char: '\u{1F938}\u{1F3FE}\u{200D}\u{2642}\u{FE0F}', + shortName: 'man_cartwheeling_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personSport, + keywords: [ + 'cartwheel', + 'gymnastics', + 'man', + 'medium-dark skin tone', + 'uc9', + 'sport', + 'diversity', + 'circus', + 'gymnast', + 'yoga', + 'fun', + 'activity', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'circus tent', + 'clown', + 'clowns', + 'gymnastics', + 'meditation', + 'meditate', + 'zen', + 'om', + 'aum' + ], + modifiable: true), + Emoji( + name: 'man cartwheeling: dark skin tone', + char: '\u{1F938}\u{1F3FF}\u{200D}\u{2642}\u{FE0F}', + shortName: 'man_cartwheeling_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personSport, + keywords: [ + 'cartwheel', + 'dark skin tone', + 'gymnastics', + 'man', + 'uc9', + 'sport', + 'diversity', + 'circus', + 'gymnast', + 'yoga', + 'fun', + 'activity', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'circus tent', + 'clown', + 'clowns', + 'gymnastics', + 'meditation', + 'meditate', + 'zen', + 'om', + 'aum' + ], + modifiable: true), + Emoji( + name: 'person bouncing ball', + char: '\u{26F9}', + shortName: 'person_bouncing_ball', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personSport, + keywords: [ + 'ball', + 'uc5', + 'sport', + 'diversity', + 'ball', + 'basketball', + 'play', + 'fame', + 'fun', + 'throw', + 'activity', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'balls', + 'ballon', + 'famous', + 'celebrity' + ]), + Emoji( + name: 'person bouncing ball: light skin tone', + char: '\u{26F9}\u{1F3FB}', + shortName: 'person_bouncing_ball_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personSport, + keywords: [ + 'ball', + 'light skin tone', + 'uc8', + 'sport', + 'diversity', + 'ball', + 'basketball', + 'play', + 'fame', + 'fun', + 'throw', + 'activity', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'balls', + 'ballon', + 'famous', + 'celebrity' + ], + modifiable: true), + Emoji( + name: 'person bouncing ball: medium-light skin tone', + char: '\u{26F9}\u{1F3FC}', + shortName: 'person_bouncing_ball_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personSport, + keywords: [ + 'ball', + 'medium-light skin tone', + 'uc8', + 'sport', + 'diversity', + 'ball', + 'basketball', + 'play', + 'fame', + 'fun', + 'throw', + 'activity', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'balls', + 'ballon', + 'famous', + 'celebrity' + ], + modifiable: true), + Emoji( + name: 'person bouncing ball: medium skin tone', + char: '\u{26F9}\u{1F3FD}', + shortName: 'person_bouncing_ball_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personSport, + keywords: [ + 'ball', + 'medium skin tone', + 'uc8', + 'sport', + 'diversity', + 'ball', + 'basketball', + 'play', + 'fame', + 'fun', + 'throw', + 'activity', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'balls', + 'ballon', + 'famous', + 'celebrity' + ], + modifiable: true), + Emoji( + name: 'person bouncing ball: medium-dark skin tone', + char: '\u{26F9}\u{1F3FE}', + shortName: 'person_bouncing_ball_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personSport, + keywords: [ + 'ball', + 'medium-dark skin tone', + 'uc8', + 'sport', + 'diversity', + 'ball', + 'basketball', + 'play', + 'fame', + 'fun', + 'throw', + 'activity', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'balls', + 'ballon', + 'famous', + 'celebrity' + ], + modifiable: true), + Emoji( + name: 'person bouncing ball: dark skin tone', + char: '\u{26F9}\u{1F3FF}', + shortName: 'person_bouncing_ball_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personSport, + keywords: [ + 'ball', + 'dark skin tone', + 'uc8', + 'sport', + 'diversity', + 'ball', + 'basketball', + 'play', + 'fame', + 'fun', + 'throw', + 'activity', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'balls', + 'ballon', + 'famous', + 'celebrity' + ], + modifiable: true), + Emoji( + name: 'woman bouncing ball', + char: '\u{26F9}\u{FE0F}\u{200D}\u{2640}\u{FE0F}', + shortName: 'woman_bouncing_ball', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personSport, + keywords: [ + 'ball', + 'woman', + 'uc5', + 'sport', + 'diversity', + 'ball', + 'basketball', + 'play', + 'fame', + 'fun', + 'throw', + 'activity', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'balls', + 'ballon', + 'famous', + 'celebrity' + ]), + Emoji( + name: 'woman bouncing ball: light skin tone', + char: '\u{26F9}\u{1F3FB}\u{200D}\u{2640}\u{FE0F}', + shortName: 'woman_bouncing_ball_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personSport, + keywords: [ + 'ball', + 'light skin tone', + 'woman', + 'uc8', + 'sport', + 'diversity', + 'ball', + 'basketball', + 'play', + 'fame', + 'fun', + 'throw', + 'activity', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'balls', + 'ballon', + 'famous', + 'celebrity' + ], + modifiable: true), + Emoji( + name: 'woman bouncing ball: medium-light skin tone', + char: '\u{26F9}\u{1F3FC}\u{200D}\u{2640}\u{FE0F}', + shortName: 'woman_bouncing_ball_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personSport, + keywords: [ + 'ball', + 'medium-light skin tone', + 'woman', + 'uc8', + 'sport', + 'diversity', + 'ball', + 'basketball', + 'play', + 'fame', + 'fun', + 'throw', + 'activity', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'balls', + 'ballon', + 'famous', + 'celebrity' + ], + modifiable: true), + Emoji( + name: 'woman bouncing ball: medium skin tone', + char: '\u{26F9}\u{1F3FD}\u{200D}\u{2640}\u{FE0F}', + shortName: 'woman_bouncing_ball_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personSport, + keywords: [ + 'ball', + 'medium skin tone', + 'woman', + 'uc8', + 'sport', + 'diversity', + 'ball', + 'basketball', + 'play', + 'fame', + 'fun', + 'throw', + 'activity', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'balls', + 'ballon', + 'famous', + 'celebrity' + ], + modifiable: true), + Emoji( + name: 'woman bouncing ball: medium-dark skin tone', + char: '\u{26F9}\u{1F3FE}\u{200D}\u{2640}\u{FE0F}', + shortName: 'woman_bouncing_ball_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personSport, + keywords: [ + 'ball', + 'medium-dark skin tone', + 'woman', + 'uc8', + 'sport', + 'diversity', + 'ball', + 'basketball', + 'play', + 'fame', + 'fun', + 'throw', + 'activity', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'balls', + 'ballon', + 'famous', + 'celebrity' + ], + modifiable: true), + Emoji( + name: 'woman bouncing ball: dark skin tone', + char: '\u{26F9}\u{1F3FF}\u{200D}\u{2640}\u{FE0F}', + shortName: 'woman_bouncing_ball_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personSport, + keywords: [ + 'ball', + 'dark skin tone', + 'woman', + 'uc8', + 'sport', + 'diversity', + 'ball', + 'basketball', + 'play', + 'fame', + 'fun', + 'throw', + 'activity', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'balls', + 'ballon', + 'famous', + 'celebrity' + ], + modifiable: true), + Emoji( + name: 'man bouncing ball', + char: '\u{26F9}\u{FE0F}\u{200D}\u{2642}\u{FE0F}', + shortName: 'man_bouncing_ball', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personSport, + keywords: [ + 'ball', + 'man', + 'uc5', + 'sport', + 'ball', + 'basketball', + 'play', + 'fame', + 'fun', + 'throw', + 'activity', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'balls', + 'ballon', + 'famous', + 'celebrity' + ]), + Emoji( + name: 'man bouncing ball: light skin tone', + char: '\u{26F9}\u{1F3FB}\u{200D}\u{2642}\u{FE0F}', + shortName: 'man_bouncing_ball_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personSport, + keywords: [ + 'ball', + 'light skin tone', + 'man', + 'uc8', + 'sport', + 'ball', + 'basketball', + 'play', + 'fame', + 'fun', + 'throw', + 'activity', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'balls', + 'ballon', + 'famous', + 'celebrity' + ], + modifiable: true), + Emoji( + name: 'man bouncing ball: medium-light skin tone', + char: '\u{26F9}\u{1F3FC}\u{200D}\u{2642}\u{FE0F}', + shortName: 'man_bouncing_ball_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personSport, + keywords: [ + 'ball', + 'man', + 'medium-light skin tone', + 'uc8', + 'sport', + 'ball', + 'basketball', + 'play', + 'fame', + 'fun', + 'throw', + 'activity', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'balls', + 'ballon', + 'famous', + 'celebrity' + ], + modifiable: true), + Emoji( + name: 'man bouncing ball: medium skin tone', + char: '\u{26F9}\u{1F3FD}\u{200D}\u{2642}\u{FE0F}', + shortName: 'man_bouncing_ball_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personSport, + keywords: [ + 'ball', + 'man', + 'medium skin tone', + 'uc8', + 'sport', + 'ball', + 'basketball', + 'play', + 'fame', + 'fun', + 'throw', + 'activity', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'balls', + 'ballon', + 'famous', + 'celebrity' + ], + modifiable: true), + Emoji( + name: 'man bouncing ball: medium-dark skin tone', + char: '\u{26F9}\u{1F3FE}\u{200D}\u{2642}\u{FE0F}', + shortName: 'man_bouncing_ball_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personSport, + keywords: [ + 'ball', + 'man', + 'medium-dark skin tone', + 'uc8', + 'sport', + 'ball', + 'basketball', + 'play', + 'fame', + 'fun', + 'throw', + 'activity', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'balls', + 'ballon', + 'famous', + 'celebrity' + ], + modifiable: true), + Emoji( + name: 'man bouncing ball: dark skin tone', + char: '\u{26F9}\u{1F3FF}\u{200D}\u{2642}\u{FE0F}', + shortName: 'man_bouncing_ball_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personSport, + keywords: [ + 'ball', + 'dark skin tone', + 'man', + 'uc8', + 'sport', + 'ball', + 'basketball', + 'play', + 'fame', + 'fun', + 'throw', + 'activity', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'balls', + 'ballon', + 'famous', + 'celebrity' + ], + modifiable: true), + Emoji( + name: 'person fencing', + char: '\u{1F93A}', + shortName: 'person_fencing', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personSport, + keywords: [ + 'fencer', + 'fencing', + 'sword', + 'uc9', + 'sport', + 'fight', + 'activity', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout' + ]), + Emoji( + name: 'person playing handball', + char: '\u{1F93E}', + shortName: 'person_playing_handball', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personSport, + keywords: [ + 'ball', + 'handball', + 'uc9', + 'sport', + 'diversity', + 'ball', + 'volley ball', + 'play', + 'throw', + 'jump', + 'activity', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'balls', + 'ballon' + ]), + Emoji( + name: 'person playing handball: light skin tone', + char: '\u{1F93E}\u{1F3FB}', + shortName: 'person_playing_handball_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personSport, + keywords: [ + 'ball', + 'handball', + 'light skin tone', + 'uc9', + 'sport', + 'diversity', + 'ball', + 'volley ball', + 'play', + 'throw', + 'jump', + 'activity', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'balls', + 'ballon' + ], + modifiable: true), + Emoji( + name: 'person playing handball: medium-light skin tone', + char: '\u{1F93E}\u{1F3FC}', + shortName: 'person_playing_handball_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personSport, + keywords: [ + 'ball', + 'handball', + 'medium-light skin tone', + 'uc9', + 'sport', + 'diversity', + 'ball', + 'volley ball', + 'play', + 'throw', + 'jump', + 'activity', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'balls', + 'ballon' + ], + modifiable: true), + Emoji( + name: 'person playing handball: medium skin tone', + char: '\u{1F93E}\u{1F3FD}', + shortName: 'person_playing_handball_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personSport, + keywords: [ + 'ball', + 'handball', + 'medium skin tone', + 'uc9', + 'sport', + 'diversity', + 'ball', + 'volley ball', + 'play', + 'throw', + 'jump', + 'activity', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'balls', + 'ballon' + ], + modifiable: true), + Emoji( + name: 'person playing handball: medium-dark skin tone', + char: '\u{1F93E}\u{1F3FE}', + shortName: 'person_playing_handball_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personSport, + keywords: [ + 'ball', + 'handball', + 'medium-dark skin tone', + 'uc9', + 'sport', + 'diversity', + 'ball', + 'volley ball', + 'play', + 'throw', + 'jump', + 'activity', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'balls', + 'ballon' + ], + modifiable: true), + Emoji( + name: 'person playing handball: dark skin tone', + char: '\u{1F93E}\u{1F3FF}', + shortName: 'person_playing_handball_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personSport, + keywords: [ + 'ball', + 'dark skin tone', + 'handball', + 'uc9', + 'sport', + 'diversity', + 'ball', + 'volley ball', + 'play', + 'throw', + 'jump', + 'activity', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'balls', + 'ballon' + ], + modifiable: true), + Emoji( + name: 'woman playing handball', + char: '\u{1F93E}\u{200D}\u{2640}\u{FE0F}', + shortName: 'woman_playing_handball', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personSport, + keywords: [ + 'handball', + 'woman', + 'uc9', + 'sport', + 'diversity', + 'ball', + 'volley ball', + 'play', + 'throw', + 'jump', + 'activity', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'balls', + 'ballon' + ]), + Emoji( + name: 'woman playing handball: light skin tone', + char: '\u{1F93E}\u{1F3FB}\u{200D}\u{2640}\u{FE0F}', + shortName: 'woman_playing_handball_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personSport, + keywords: [ + 'handball', + 'light skin tone', + 'woman', + 'uc9', + 'sport', + 'diversity', + 'ball', + 'volley ball', + 'play', + 'throw', + 'jump', + 'activity', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'balls', + 'ballon' + ], + modifiable: true), + Emoji( + name: 'woman playing handball: medium-light skin tone', + char: '\u{1F93E}\u{1F3FC}\u{200D}\u{2640}\u{FE0F}', + shortName: 'woman_playing_handball_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personSport, + keywords: [ + 'handball', + 'medium-light skin tone', + 'woman', + 'uc9', + 'sport', + 'diversity', + 'ball', + 'volley ball', + 'play', + 'throw', + 'jump', + 'activity', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'balls', + 'ballon' + ], + modifiable: true), + Emoji( + name: 'woman playing handball: medium skin tone', + char: '\u{1F93E}\u{1F3FD}\u{200D}\u{2640}\u{FE0F}', + shortName: 'woman_playing_handball_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personSport, + keywords: [ + 'handball', + 'medium skin tone', + 'woman', + 'uc9', + 'sport', + 'diversity', + 'ball', + 'volley ball', + 'play', + 'throw', + 'jump', + 'activity', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'balls', + 'ballon' + ], + modifiable: true), + Emoji( + name: 'woman playing handball: medium-dark skin tone', + char: '\u{1F93E}\u{1F3FE}\u{200D}\u{2640}\u{FE0F}', + shortName: 'woman_playing_handball_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personSport, + keywords: [ + 'handball', + 'medium-dark skin tone', + 'woman', + 'uc9', + 'sport', + 'diversity', + 'ball', + 'volley ball', + 'play', + 'throw', + 'jump', + 'activity', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'balls', + 'ballon' + ], + modifiable: true), + Emoji( + name: 'woman playing handball: dark skin tone', + char: '\u{1F93E}\u{1F3FF}\u{200D}\u{2640}\u{FE0F}', + shortName: 'woman_playing_handball_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personSport, + keywords: [ + 'dark skin tone', + 'handball', + 'woman', + 'uc9', + 'sport', + 'diversity', + 'ball', + 'volley ball', + 'play', + 'throw', + 'jump', + 'activity', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'balls', + 'ballon' + ], + modifiable: true), + Emoji( + name: 'man playing handball', + char: '\u{1F93E}\u{200D}\u{2642}\u{FE0F}', + shortName: 'man_playing_handball', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personSport, + keywords: [ + 'handball', + 'man', + 'uc9', + 'sport', + 'diversity', + 'ball', + 'volley ball', + 'play', + 'throw', + 'jump', + 'activity', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'balls', + 'ballon' + ]), + Emoji( + name: 'man playing handball: light skin tone', + char: '\u{1F93E}\u{1F3FB}\u{200D}\u{2642}\u{FE0F}', + shortName: 'man_playing_handball_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personSport, + keywords: [ + 'handball', + 'light skin tone', + 'man', + 'uc9', + 'sport', + 'diversity', + 'ball', + 'volley ball', + 'play', + 'throw', + 'jump', + 'activity', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'balls', + 'ballon' + ], + modifiable: true), + Emoji( + name: 'man playing handball: medium-light skin tone', + char: '\u{1F93E}\u{1F3FC}\u{200D}\u{2642}\u{FE0F}', + shortName: 'man_playing_handball_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personSport, + keywords: [ + 'handball', + 'man', + 'medium-light skin tone', + 'uc9', + 'sport', + 'diversity', + 'ball', + 'volley ball', + 'play', + 'throw', + 'jump', + 'activity', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'balls', + 'ballon' + ], + modifiable: true), + Emoji( + name: 'man playing handball: medium skin tone', + char: '\u{1F93E}\u{1F3FD}\u{200D}\u{2642}\u{FE0F}', + shortName: 'man_playing_handball_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personSport, + keywords: [ + 'handball', + 'man', + 'medium skin tone', + 'uc9', + 'sport', + 'diversity', + 'ball', + 'volley ball', + 'play', + 'throw', + 'jump', + 'activity', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'balls', + 'ballon' + ], + modifiable: true), + Emoji( + name: 'man playing handball: medium-dark skin tone', + char: '\u{1F93E}\u{1F3FE}\u{200D}\u{2642}\u{FE0F}', + shortName: 'man_playing_handball_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personSport, + keywords: [ + 'handball', + 'man', + 'medium-dark skin tone', + 'uc9', + 'sport', + 'diversity', + 'ball', + 'volley ball', + 'play', + 'throw', + 'jump', + 'activity', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'balls', + 'ballon' + ], + modifiable: true), + Emoji( + name: 'man playing handball: dark skin tone', + char: '\u{1F93E}\u{1F3FF}\u{200D}\u{2642}\u{FE0F}', + shortName: 'man_playing_handball_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personSport, + keywords: [ + 'dark skin tone', + 'handball', + 'man', + 'uc9', + 'sport', + 'diversity', + 'ball', + 'volley ball', + 'play', + 'throw', + 'jump', + 'activity', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'balls', + 'ballon' + ], + modifiable: true), + Emoji( + name: 'person golfing', + char: '\u{1F3CC}', + shortName: 'person_golfing', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personSport, + keywords: [ + 'ball', + 'golf', + 'uc7', + 'sport', + 'diversity', + 'ball', + 'vacation', + 'golf', + 'play', + 'florida', + 'fun', + 'activity', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'balls', + 'ballon', + 'golfing', + 'golfer' + ]), + Emoji( + name: 'person golfing: light skin tone', + char: '\u{1F3CC}\u{1F3FB}', + shortName: 'person_golfing_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personSport, + keywords: [ + 'ball', + 'golf', + 'light skin tone', + 'uc8', + 'sport', + 'diversity', + 'ball', + 'vacation', + 'golf', + 'play', + 'florida', + 'fun', + 'activity', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'balls', + 'ballon', + 'golfing', + 'golfer' + ], + modifiable: true), + Emoji( + name: 'person golfing: medium-light skin tone', + char: '\u{1F3CC}\u{1F3FC}', + shortName: 'person_golfing_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personSport, + keywords: [ + 'ball', + 'golf', + 'medium-light skin tone', + 'uc8', + 'sport', + 'diversity', + 'ball', + 'vacation', + 'golf', + 'play', + 'florida', + 'fun', + 'activity', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'balls', + 'ballon', + 'golfing', + 'golfer' + ], + modifiable: true), + Emoji( + name: 'person golfing: medium skin tone', + char: '\u{1F3CC}\u{1F3FD}', + shortName: 'person_golfing_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personSport, + keywords: [ + 'ball', + 'golf', + 'medium skin tone', + 'uc8', + 'sport', + 'diversity', + 'ball', + 'vacation', + 'golf', + 'play', + 'florida', + 'fun', + 'activity', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'balls', + 'ballon', + 'golfing', + 'golfer' + ], + modifiable: true), + Emoji( + name: 'person golfing: medium-dark skin tone', + char: '\u{1F3CC}\u{1F3FE}', + shortName: 'person_golfing_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personSport, + keywords: [ + 'ball', + 'golf', + 'medium-dark skin tone', + 'uc8', + 'sport', + 'diversity', + 'ball', + 'vacation', + 'golf', + 'play', + 'florida', + 'fun', + 'activity', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'balls', + 'ballon', + 'golfing', + 'golfer' + ], + modifiable: true), + Emoji( + name: 'person golfing: dark skin tone', + char: '\u{1F3CC}\u{1F3FF}', + shortName: 'person_golfing_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personSport, + keywords: [ + 'ball', + 'dark skin tone', + 'golf', + 'uc8', + 'sport', + 'diversity', + 'ball', + 'vacation', + 'golf', + 'play', + 'florida', + 'fun', + 'activity', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'balls', + 'ballon', + 'golfing', + 'golfer' + ], + modifiable: true), + Emoji( + name: 'woman golfing', + char: '\u{1F3CC}\u{FE0F}\u{200D}\u{2640}\u{FE0F}', + shortName: 'woman_golfing', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personSport, + keywords: [ + 'golf', + 'woman', + 'uc7', + 'sport', + 'diversity', + 'ball', + 'vacation', + 'golf', + 'play', + 'florida', + 'fun', + 'activity', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'balls', + 'ballon', + 'golfing', + 'golfer' + ]), + Emoji( + name: 'woman golfing: light skin tone', + char: '\u{1F3CC}\u{1F3FB}\u{200D}\u{2640}\u{FE0F}', + shortName: 'woman_golfing_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personSport, + keywords: [ + 'golf', + 'light skin tone', + 'woman', + 'uc8', + 'sport', + 'diversity', + 'ball', + 'vacation', + 'golf', + 'play', + 'florida', + 'fun', + 'activity', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'balls', + 'ballon', + 'golfing', + 'golfer' + ], + modifiable: true), + Emoji( + name: 'woman golfing: medium-light skin tone', + char: '\u{1F3CC}\u{1F3FC}\u{200D}\u{2640}\u{FE0F}', + shortName: 'woman_golfing_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personSport, + keywords: [ + 'golf', + 'medium-light skin tone', + 'woman', + 'uc8', + 'sport', + 'diversity', + 'ball', + 'vacation', + 'golf', + 'play', + 'florida', + 'fun', + 'activity', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'balls', + 'ballon', + 'golfing', + 'golfer' + ], + modifiable: true), + Emoji( + name: 'woman golfing: medium skin tone', + char: '\u{1F3CC}\u{1F3FD}\u{200D}\u{2640}\u{FE0F}', + shortName: 'woman_golfing_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personSport, + keywords: [ + 'golf', + 'medium skin tone', + 'woman', + 'uc8', + 'sport', + 'diversity', + 'ball', + 'vacation', + 'golf', + 'play', + 'florida', + 'fun', + 'activity', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'balls', + 'ballon', + 'golfing', + 'golfer' + ], + modifiable: true), + Emoji( + name: 'woman golfing: medium-dark skin tone', + char: '\u{1F3CC}\u{1F3FE}\u{200D}\u{2640}\u{FE0F}', + shortName: 'woman_golfing_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personSport, + keywords: [ + 'golf', + 'medium-dark skin tone', + 'woman', + 'uc8', + 'sport', + 'diversity', + 'ball', + 'vacation', + 'golf', + 'play', + 'florida', + 'fun', + 'activity', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'balls', + 'ballon', + 'golfing', + 'golfer' + ], + modifiable: true), + Emoji( + name: 'woman golfing: dark skin tone', + char: '\u{1F3CC}\u{1F3FF}\u{200D}\u{2640}\u{FE0F}', + shortName: 'woman_golfing_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personSport, + keywords: [ + 'dark skin tone', + 'golf', + 'woman', + 'uc8', + 'sport', + 'diversity', + 'ball', + 'vacation', + 'golf', + 'play', + 'florida', + 'fun', + 'activity', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'balls', + 'ballon', + 'golfing', + 'golfer' + ], + modifiable: true), + Emoji( + name: 'man golfing', + char: '\u{1F3CC}\u{FE0F}\u{200D}\u{2642}\u{FE0F}', + shortName: 'man_golfing', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personSport, + keywords: [ + 'golf', + 'man', + 'uc7', + 'sport', + 'diversity', + 'ball', + 'vacation', + 'golf', + 'play', + 'florida', + 'fun', + 'activity', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'balls', + 'ballon', + 'golfing', + 'golfer' + ]), + Emoji( + name: 'man golfing: light skin tone', + char: '\u{1F3CC}\u{1F3FB}\u{200D}\u{2642}\u{FE0F}', + shortName: 'man_golfing_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personSport, + keywords: [ + 'golf', + 'light skin tone', + 'man', + 'uc8', + 'sport', + 'diversity', + 'ball', + 'vacation', + 'golf', + 'play', + 'florida', + 'fun', + 'activity', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'balls', + 'ballon', + 'golfing', + 'golfer' + ], + modifiable: true), + Emoji( + name: 'man golfing: medium-light skin tone', + char: '\u{1F3CC}\u{1F3FC}\u{200D}\u{2642}\u{FE0F}', + shortName: 'man_golfing_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personSport, + keywords: [ + 'golf', + 'man', + 'medium-light skin tone', + 'uc8', + 'sport', + 'diversity', + 'ball', + 'vacation', + 'golf', + 'play', + 'florida', + 'fun', + 'activity', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'balls', + 'ballon', + 'golfing', + 'golfer' + ], + modifiable: true), + Emoji( + name: 'man golfing: medium skin tone', + char: '\u{1F3CC}\u{1F3FD}\u{200D}\u{2642}\u{FE0F}', + shortName: 'man_golfing_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personSport, + keywords: [ + 'golf', + 'man', + 'medium skin tone', + 'uc8', + 'sport', + 'diversity', + 'ball', + 'vacation', + 'golf', + 'play', + 'florida', + 'fun', + 'activity', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'balls', + 'ballon', + 'golfing', + 'golfer' + ], + modifiable: true), + Emoji( + name: 'man golfing: medium-dark skin tone', + char: '\u{1F3CC}\u{1F3FE}\u{200D}\u{2642}\u{FE0F}', + shortName: 'man_golfing_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personSport, + keywords: [ + 'golf', + 'man', + 'medium-dark skin tone', + 'uc8', + 'sport', + 'diversity', + 'ball', + 'vacation', + 'golf', + 'play', + 'florida', + 'fun', + 'activity', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'balls', + 'ballon', + 'golfing', + 'golfer' + ], + modifiable: true), + Emoji( + name: 'man golfing: dark skin tone', + char: '\u{1F3CC}\u{1F3FF}\u{200D}\u{2642}\u{FE0F}', + shortName: 'man_golfing_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personSport, + keywords: [ + 'dark skin tone', + 'golf', + 'man', + 'uc8', + 'sport', + 'diversity', + 'ball', + 'vacation', + 'golf', + 'play', + 'florida', + 'fun', + 'activity', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'balls', + 'ballon', + 'golfing', + 'golfer' + ], + modifiable: true), + Emoji( + name: 'horse racing', + char: '\u{1F3C7}', + shortName: 'horse_racing', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personSport, + keywords: [ + 'horse', + 'jockey', + 'racehorse', + 'racing', + 'uc6', + 'sport', + 'diversity', + 'horse racing', + 'las vegas', + 'fun', + 'activity', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'horseback riding', + 'horse and rider', + 'horses', + 'horseshoe', + 'pony', + 'vegas' + ]), + Emoji( + name: 'horse racing: light skin tone', + char: '\u{1F3C7}\u{1F3FB}', + shortName: 'horse_racing_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personSport, + keywords: [ + 'horse', + 'jockey', + 'light skin tone', + 'racehorse', + 'racing', + 'uc8', + 'sport', + 'diversity', + 'horse racing', + 'las vegas', + 'fun', + 'activity', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'horseback riding', + 'horse and rider', + 'horses', + 'horseshoe', + 'pony', + 'vegas' + ], + modifiable: true), + Emoji( + name: 'horse racing: medium-light skin tone', + char: '\u{1F3C7}\u{1F3FC}', + shortName: 'horse_racing_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personSport, + keywords: [ + 'horse', + 'jockey', + 'medium-light skin tone', + 'racehorse', + 'racing', + 'uc8', + 'sport', + 'diversity', + 'horse racing', + 'las vegas', + 'fun', + 'activity', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'horseback riding', + 'horse and rider', + 'horses', + 'horseshoe', + 'pony', + 'vegas' + ], + modifiable: true), + Emoji( + name: 'horse racing: medium skin tone', + char: '\u{1F3C7}\u{1F3FD}', + shortName: 'horse_racing_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personSport, + keywords: [ + 'horse', + 'jockey', + 'medium skin tone', + 'racehorse', + 'racing', + 'uc8', + 'sport', + 'diversity', + 'horse racing', + 'las vegas', + 'fun', + 'activity', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'horseback riding', + 'horse and rider', + 'horses', + 'horseshoe', + 'pony', + 'vegas' + ], + modifiable: true), + Emoji( + name: 'horse racing: medium-dark skin tone', + char: '\u{1F3C7}\u{1F3FE}', + shortName: 'horse_racing_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personSport, + keywords: [ + 'horse', + 'jockey', + 'medium-dark skin tone', + 'racehorse', + 'racing', + 'uc8', + 'sport', + 'diversity', + 'horse racing', + 'las vegas', + 'fun', + 'activity', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'horseback riding', + 'horse and rider', + 'horses', + 'horseshoe', + 'pony', + 'vegas' + ], + modifiable: true), + Emoji( + name: 'horse racing: dark skin tone', + char: '\u{1F3C7}\u{1F3FF}', + shortName: 'horse_racing_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personSport, + keywords: [ + 'dark skin tone', + 'horse', + 'jockey', + 'racehorse', + 'racing', + 'uc8', + 'sport', + 'diversity', + 'horse racing', + 'las vegas', + 'fun', + 'activity', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'horseback riding', + 'horse and rider', + 'horses', + 'horseshoe', + 'pony', + 'vegas' + ], + modifiable: true), + Emoji( + name: 'person in lotus position', + char: '\u{1F9D8}', + shortName: 'person_in_lotus_position', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personResting, + keywords: [ + 'uc10', + 'sport', + 'diversity', + 'vacation', + 'yoga', + 'california', + 'activity', + 'spa', + 'sit', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'meditation', + 'meditate', + 'zen', + 'om', + 'aum', + 'relax', + 'sauna', + 'sitting', + 'kneel', + 'kneeling' + ]), + Emoji( + name: 'person in lotus position: light skin tone', + char: '\u{1F9D8}\u{1F3FB}', + shortName: 'person_in_lotus_position_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personResting, + keywords: [ + 'light skin tone', + 'meditation', + 'yoga', + 'uc10', + 'sport', + 'diversity', + 'vacation', + 'yoga', + 'california', + 'activity', + 'spa', + 'sit', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'meditation', + 'meditate', + 'zen', + 'om', + 'aum', + 'relax', + 'sauna', + 'sitting', + 'kneel', + 'kneeling' + ], + modifiable: true), + Emoji( + name: 'person in lotus position: medium-light skin tone', + char: '\u{1F9D8}\u{1F3FC}', + shortName: 'person_in_lotus_position_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personResting, + keywords: [ + 'meditation', + 'medium-light skin tone', + 'yoga', + 'uc10', + 'sport', + 'diversity', + 'vacation', + 'yoga', + 'california', + 'activity', + 'spa', + 'sit', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'meditation', + 'meditate', + 'zen', + 'om', + 'aum', + 'relax', + 'sauna', + 'sitting', + 'kneel', + 'kneeling' + ], + modifiable: true), + Emoji( + name: 'person in lotus position: medium skin tone', + char: '\u{1F9D8}\u{1F3FD}', + shortName: 'person_in_lotus_position_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personResting, + keywords: [ + 'meditation', + 'medium skin tone', + 'yoga', + 'uc10', + 'sport', + 'diversity', + 'vacation', + 'yoga', + 'california', + 'activity', + 'spa', + 'sit', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'meditation', + 'meditate', + 'zen', + 'om', + 'aum', + 'relax', + 'sauna', + 'sitting', + 'kneel', + 'kneeling' + ], + modifiable: true), + Emoji( + name: 'person in lotus position: medium-dark skin tone', + char: '\u{1F9D8}\u{1F3FE}', + shortName: 'person_in_lotus_position_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personResting, + keywords: [ + 'meditation', + 'medium-dark skin tone', + 'yoga', + 'uc10', + 'sport', + 'diversity', + 'vacation', + 'yoga', + 'california', + 'activity', + 'spa', + 'sit', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'meditation', + 'meditate', + 'zen', + 'om', + 'aum', + 'relax', + 'sauna', + 'sitting', + 'kneel', + 'kneeling' + ], + modifiable: true), + Emoji( + name: 'person in lotus position: dark skin tone', + char: '\u{1F9D8}\u{1F3FF}', + shortName: 'person_in_lotus_position_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personResting, + keywords: [ + 'dark skin tone', + 'meditation', + 'yoga', + 'uc10', + 'sport', + 'diversity', + 'vacation', + 'yoga', + 'california', + 'activity', + 'spa', + 'sit', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'meditation', + 'meditate', + 'zen', + 'om', + 'aum', + 'relax', + 'sauna', + 'sitting', + 'kneel', + 'kneeling' + ], + modifiable: true), + Emoji( + name: 'woman in lotus position', + char: '\u{1F9D8}\u{200D}\u{2640}\u{FE0F}', + shortName: 'woman_in_lotus_position', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personResting, + keywords: [ + 'meditation', + 'yoga', + 'uc10', + 'sport', + 'diversity', + 'women', + 'vacation', + 'yoga', + 'california', + 'activity', + 'spa', + 'sit', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'woman', + 'female', + 'meditation', + 'meditate', + 'zen', + 'om', + 'aum', + 'relax', + 'sauna', + 'sitting', + 'kneel', + 'kneeling' + ]), + Emoji( + name: 'woman in lotus position: light skin tone', + char: '\u{1F9D8}\u{1F3FB}\u{200D}\u{2640}\u{FE0F}', + shortName: 'woman_in_lotus_position_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personResting, + keywords: [ + 'light skin tone', + 'meditation', + 'yoga', + 'uc10', + 'sport', + 'diversity', + 'women', + 'vacation', + 'yoga', + 'california', + 'activity', + 'spa', + 'sit', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'woman', + 'female', + 'meditation', + 'meditate', + 'zen', + 'om', + 'aum', + 'relax', + 'sauna', + 'sitting', + 'kneel', + 'kneeling' + ], + modifiable: true), + Emoji( + name: 'woman in lotus position: medium-light skin tone', + char: '\u{1F9D8}\u{1F3FC}\u{200D}\u{2640}\u{FE0F}', + shortName: 'woman_in_lotus_position_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personResting, + keywords: [ + 'meditation', + 'medium-light skin tone', + 'yoga', + 'uc10', + 'sport', + 'diversity', + 'women', + 'vacation', + 'yoga', + 'california', + 'activity', + 'spa', + 'sit', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'woman', + 'female', + 'meditation', + 'meditate', + 'zen', + 'om', + 'aum', + 'relax', + 'sauna', + 'sitting', + 'kneel', + 'kneeling' + ], + modifiable: true), + Emoji( + name: 'woman in lotus position: medium skin tone', + char: '\u{1F9D8}\u{1F3FD}\u{200D}\u{2640}\u{FE0F}', + shortName: 'woman_in_lotus_position_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personResting, + keywords: [ + 'meditation', + 'medium skin tone', + 'yoga', + 'uc10', + 'sport', + 'diversity', + 'women', + 'vacation', + 'yoga', + 'california', + 'activity', + 'spa', + 'sit', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'woman', + 'female', + 'meditation', + 'meditate', + 'zen', + 'om', + 'aum', + 'relax', + 'sauna', + 'sitting', + 'kneel', + 'kneeling' + ], + modifiable: true), + Emoji( + name: 'woman in lotus position: medium-dark skin tone', + char: '\u{1F9D8}\u{1F3FE}\u{200D}\u{2640}\u{FE0F}', + shortName: 'woman_in_lotus_position_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personResting, + keywords: [ + 'meditation', + 'medium-dark skin tone', + 'yoga', + 'uc10', + 'sport', + 'diversity', + 'women', + 'vacation', + 'yoga', + 'california', + 'activity', + 'spa', + 'sit', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'woman', + 'female', + 'meditation', + 'meditate', + 'zen', + 'om', + 'aum', + 'relax', + 'sauna', + 'sitting', + 'kneel', + 'kneeling' + ], + modifiable: true), + Emoji( + name: 'woman in lotus position: dark skin tone', + char: '\u{1F9D8}\u{1F3FF}\u{200D}\u{2640}\u{FE0F}', + shortName: 'woman_in_lotus_position_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personResting, + keywords: [ + 'dark skin tone', + 'meditation', + 'yoga', + 'uc10', + 'sport', + 'diversity', + 'women', + 'vacation', + 'yoga', + 'california', + 'activity', + 'spa', + 'sit', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'woman', + 'female', + 'meditation', + 'meditate', + 'zen', + 'om', + 'aum', + 'relax', + 'sauna', + 'sitting', + 'kneel', + 'kneeling' + ], + modifiable: true), + Emoji( + name: 'man in lotus position', + char: '\u{1F9D8}\u{200D}\u{2642}\u{FE0F}', + shortName: 'man_in_lotus_position', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personResting, + keywords: [ + 'meditation', + 'yoga', + 'uc10', + 'sport', + 'diversity', + 'vacation', + 'yoga', + 'california', + 'activity', + 'spa', + 'sit', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'meditation', + 'meditate', + 'zen', + 'om', + 'aum', + 'relax', + 'sauna', + 'sitting', + 'kneel', + 'kneeling' + ]), + Emoji( + name: 'man in lotus position: light skin tone', + char: '\u{1F9D8}\u{1F3FB}\u{200D}\u{2642}\u{FE0F}', + shortName: 'man_in_lotus_position_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personResting, + keywords: [ + 'light skin tone', + 'meditation', + 'yoga', + 'uc10', + 'sport', + 'diversity', + 'vacation', + 'yoga', + 'california', + 'activity', + 'spa', + 'sit', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'meditation', + 'meditate', + 'zen', + 'om', + 'aum', + 'relax', + 'sauna', + 'sitting', + 'kneel', + 'kneeling' + ], + modifiable: true), + Emoji( + name: 'man in lotus position: medium-light skin tone', + char: '\u{1F9D8}\u{1F3FC}\u{200D}\u{2642}\u{FE0F}', + shortName: 'man_in_lotus_position_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personResting, + keywords: [ + 'meditation', + 'medium-light skin tone', + 'yoga', + 'uc10', + 'sport', + 'diversity', + 'vacation', + 'yoga', + 'california', + 'activity', + 'spa', + 'sit', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'meditation', + 'meditate', + 'zen', + 'om', + 'aum', + 'relax', + 'sauna', + 'sitting', + 'kneel', + 'kneeling' + ], + modifiable: true), + Emoji( + name: 'man in lotus position: medium skin tone', + char: '\u{1F9D8}\u{1F3FD}\u{200D}\u{2642}\u{FE0F}', + shortName: 'man_in_lotus_position_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personResting, + keywords: [ + 'meditation', + 'medium skin tone', + 'yoga', + 'uc10', + 'sport', + 'diversity', + 'vacation', + 'yoga', + 'california', + 'activity', + 'spa', + 'sit', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'meditation', + 'meditate', + 'zen', + 'om', + 'aum', + 'relax', + 'sauna', + 'sitting', + 'kneel', + 'kneeling' + ], + modifiable: true), + Emoji( + name: 'man in lotus position: medium-dark skin tone', + char: '\u{1F9D8}\u{1F3FE}\u{200D}\u{2642}\u{FE0F}', + shortName: 'man_in_lotus_position_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personResting, + keywords: [ + 'meditation', + 'medium-dark skin tone', + 'yoga', + 'uc10', + 'sport', + 'diversity', + 'vacation', + 'yoga', + 'california', + 'activity', + 'spa', + 'sit', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'meditation', + 'meditate', + 'zen', + 'om', + 'aum', + 'relax', + 'sauna', + 'sitting', + 'kneel', + 'kneeling' + ], + modifiable: true), + Emoji( + name: 'man in lotus position: dark skin tone', + char: '\u{1F9D8}\u{1F3FF}\u{200D}\u{2642}\u{FE0F}', + shortName: 'man_in_lotus_position_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personResting, + keywords: [ + 'dark skin tone', + 'meditation', + 'yoga', + 'uc10', + 'sport', + 'diversity', + 'vacation', + 'yoga', + 'california', + 'activity', + 'spa', + 'sit', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'meditation', + 'meditate', + 'zen', + 'om', + 'aum', + 'relax', + 'sauna', + 'sitting', + 'kneel', + 'kneeling' + ], + modifiable: true), + Emoji( + name: 'person surfing', + char: '\u{1F3C4}', + shortName: 'person_surfing', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personSport, + keywords: [ + 'surfing', + 'uc6', + 'sport', + 'diversity', + 'tropical', + 'vacation', + 'hawaii', + 'california', + 'florida', + 'fun', + 'summer', + 'activity', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'aloha', + 'kawaii', + 'maui', + 'moana', + 'weekend' + ]), + Emoji( + name: 'person surfing: light skin tone', + char: '\u{1F3C4}\u{1F3FB}', + shortName: 'person_surfing_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personSport, + keywords: [ + 'light skin tone', + 'surfing', + 'uc8', + 'sport', + 'diversity', + 'tropical', + 'vacation', + 'hawaii', + 'california', + 'florida', + 'fun', + 'summer', + 'activity', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'aloha', + 'kawaii', + 'maui', + 'moana', + 'weekend' + ], + modifiable: true), + Emoji( + name: 'person surfing: medium-light skin tone', + char: '\u{1F3C4}\u{1F3FC}', + shortName: 'person_surfing_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personSport, + keywords: [ + 'medium-light skin tone', + 'surfing', + 'uc8', + 'sport', + 'diversity', + 'tropical', + 'vacation', + 'hawaii', + 'california', + 'florida', + 'fun', + 'summer', + 'activity', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'aloha', + 'kawaii', + 'maui', + 'moana', + 'weekend' + ], + modifiable: true), + Emoji( + name: 'person surfing: medium skin tone', + char: '\u{1F3C4}\u{1F3FD}', + shortName: 'person_surfing_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personSport, + keywords: [ + 'medium skin tone', + 'surfing', + 'uc8', + 'sport', + 'diversity', + 'tropical', + 'vacation', + 'hawaii', + 'california', + 'florida', + 'fun', + 'summer', + 'activity', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'aloha', + 'kawaii', + 'maui', + 'moana', + 'weekend' + ], + modifiable: true), + Emoji( + name: 'person surfing: medium-dark skin tone', + char: '\u{1F3C4}\u{1F3FE}', + shortName: 'person_surfing_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personSport, + keywords: [ + 'medium-dark skin tone', + 'surfing', + 'uc8', + 'sport', + 'diversity', + 'tropical', + 'vacation', + 'hawaii', + 'california', + 'florida', + 'fun', + 'summer', + 'activity', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'aloha', + 'kawaii', + 'maui', + 'moana', + 'weekend' + ], + modifiable: true), + Emoji( + name: 'person surfing: dark skin tone', + char: '\u{1F3C4}\u{1F3FF}', + shortName: 'person_surfing_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personSport, + keywords: [ + 'dark skin tone', + 'surfing', + 'uc8', + 'sport', + 'diversity', + 'tropical', + 'vacation', + 'hawaii', + 'california', + 'florida', + 'fun', + 'summer', + 'activity', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'aloha', + 'kawaii', + 'maui', + 'moana', + 'weekend' + ], + modifiable: true), + Emoji( + name: 'woman surfing', + char: '\u{1F3C4}\u{200D}\u{2640}\u{FE0F}', + shortName: 'woman_surfing', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personSport, + keywords: [ + 'surfing', + 'woman', + 'uc6', + 'sport', + 'diversity', + 'tropical', + 'vacation', + 'hawaii', + 'california', + 'florida', + 'fun', + 'summer', + 'activity', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'aloha', + 'kawaii', + 'maui', + 'moana', + 'weekend' + ]), + Emoji( + name: 'woman surfing: light skin tone', + char: '\u{1F3C4}\u{1F3FB}\u{200D}\u{2640}\u{FE0F}', + shortName: 'woman_surfing_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personSport, + keywords: [ + 'light skin tone', + 'surfing', + 'woman', + 'uc8', + 'sport', + 'diversity', + 'tropical', + 'vacation', + 'hawaii', + 'california', + 'florida', + 'fun', + 'summer', + 'activity', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'aloha', + 'kawaii', + 'maui', + 'moana', + 'weekend' + ], + modifiable: true), + Emoji( + name: 'woman surfing: medium-light skin tone', + char: '\u{1F3C4}\u{1F3FC}\u{200D}\u{2640}\u{FE0F}', + shortName: 'woman_surfing_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personSport, + keywords: [ + 'medium-light skin tone', + 'surfing', + 'woman', + 'uc8', + 'sport', + 'diversity', + 'tropical', + 'vacation', + 'hawaii', + 'california', + 'florida', + 'fun', + 'summer', + 'activity', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'aloha', + 'kawaii', + 'maui', + 'moana', + 'weekend' + ], + modifiable: true), + Emoji( + name: 'woman surfing: medium skin tone', + char: '\u{1F3C4}\u{1F3FD}\u{200D}\u{2640}\u{FE0F}', + shortName: 'woman_surfing_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personSport, + keywords: [ + 'medium skin tone', + 'surfing', + 'woman', + 'uc8', + 'sport', + 'diversity', + 'tropical', + 'vacation', + 'hawaii', + 'california', + 'florida', + 'fun', + 'summer', + 'activity', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'aloha', + 'kawaii', + 'maui', + 'moana', + 'weekend' + ], + modifiable: true), + Emoji( + name: 'woman surfing: medium-dark skin tone', + char: '\u{1F3C4}\u{1F3FE}\u{200D}\u{2640}\u{FE0F}', + shortName: 'woman_surfing_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personSport, + keywords: [ + 'medium-dark skin tone', + 'surfing', + 'woman', + 'uc8', + 'sport', + 'diversity', + 'tropical', + 'vacation', + 'hawaii', + 'california', + 'florida', + 'fun', + 'summer', + 'activity', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'aloha', + 'kawaii', + 'maui', + 'moana', + 'weekend' + ], + modifiable: true), + Emoji( + name: 'woman surfing: dark skin tone', + char: '\u{1F3C4}\u{1F3FF}\u{200D}\u{2640}\u{FE0F}', + shortName: 'woman_surfing_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personSport, + keywords: [ + 'dark skin tone', + 'surfing', + 'woman', + 'uc8', + 'sport', + 'diversity', + 'tropical', + 'vacation', + 'hawaii', + 'california', + 'florida', + 'fun', + 'summer', + 'activity', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'aloha', + 'kawaii', + 'maui', + 'moana', + 'weekend' + ], + modifiable: true), + Emoji( + name: 'man surfing', + char: '\u{1F3C4}\u{200D}\u{2642}\u{FE0F}', + shortName: 'man_surfing', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personSport, + keywords: [ + 'man', + 'surfing', + 'uc6', + 'sport', + 'diversity', + 'tropical', + 'vacation', + 'hawaii', + 'california', + 'florida', + 'fun', + 'summer', + 'activity', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'aloha', + 'kawaii', + 'maui', + 'moana', + 'weekend' + ]), + Emoji( + name: 'man surfing: light skin tone', + char: '\u{1F3C4}\u{1F3FB}\u{200D}\u{2642}\u{FE0F}', + shortName: 'man_surfing_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personSport, + keywords: [ + 'light skin tone', + 'man', + 'surfing', + 'uc8', + 'sport', + 'diversity', + 'tropical', + 'vacation', + 'hawaii', + 'california', + 'florida', + 'fun', + 'summer', + 'activity', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'aloha', + 'kawaii', + 'maui', + 'moana', + 'weekend' + ], + modifiable: true), + Emoji( + name: 'man surfing: medium-light skin tone', + char: '\u{1F3C4}\u{1F3FC}\u{200D}\u{2642}\u{FE0F}', + shortName: 'man_surfing_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personSport, + keywords: [ + 'man', + 'medium-light skin tone', + 'surfing', + 'uc8', + 'sport', + 'diversity', + 'tropical', + 'vacation', + 'hawaii', + 'california', + 'florida', + 'fun', + 'summer', + 'activity', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'aloha', + 'kawaii', + 'maui', + 'moana', + 'weekend' + ], + modifiable: true), + Emoji( + name: 'man surfing: medium skin tone', + char: '\u{1F3C4}\u{1F3FD}\u{200D}\u{2642}\u{FE0F}', + shortName: 'man_surfing_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personSport, + keywords: [ + 'man', + 'medium skin tone', + 'surfing', + 'uc8', + 'sport', + 'diversity', + 'tropical', + 'vacation', + 'hawaii', + 'california', + 'florida', + 'fun', + 'summer', + 'activity', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'aloha', + 'kawaii', + 'maui', + 'moana', + 'weekend' + ], + modifiable: true), + Emoji( + name: 'man surfing: medium-dark skin tone', + char: '\u{1F3C4}\u{1F3FE}\u{200D}\u{2642}\u{FE0F}', + shortName: 'man_surfing_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personSport, + keywords: [ + 'man', + 'medium-dark skin tone', + 'surfing', + 'uc8', + 'sport', + 'diversity', + 'tropical', + 'vacation', + 'hawaii', + 'california', + 'florida', + 'fun', + 'summer', + 'activity', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'aloha', + 'kawaii', + 'maui', + 'moana', + 'weekend' + ], + modifiable: true), + Emoji( + name: 'man surfing: dark skin tone', + char: '\u{1F3C4}\u{1F3FF}\u{200D}\u{2642}\u{FE0F}', + shortName: 'man_surfing_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personSport, + keywords: [ + 'dark skin tone', + 'man', + 'surfing', + 'uc8', + 'sport', + 'diversity', + 'tropical', + 'vacation', + 'hawaii', + 'california', + 'florida', + 'fun', + 'summer', + 'activity', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'aloha', + 'kawaii', + 'maui', + 'moana', + 'weekend' + ], + modifiable: true), + Emoji( + name: 'person swimming', + char: '\u{1F3CA}', + shortName: 'person_swimming', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personSport, + keywords: [ + 'swim', + 'uc6', + 'sport', + 'diversity', + 'vacation', + 'swim', + 'scuba', + 'summer', + 'activity', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'swimming', + 'swimmer', + 'snorkel', + 'weekend' + ]), + Emoji( + name: 'person swimming: light skin tone', + char: '\u{1F3CA}\u{1F3FB}', + shortName: 'person_swimming_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personSport, + keywords: [ + 'light skin tone', + 'swim', + 'uc8', + 'sport', + 'diversity', + 'vacation', + 'swim', + 'scuba', + 'summer', + 'activity', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'swimming', + 'swimmer', + 'snorkel', + 'weekend' + ], + modifiable: true), + Emoji( + name: 'person swimming: medium-light skin tone', + char: '\u{1F3CA}\u{1F3FC}', + shortName: 'person_swimming_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personSport, + keywords: [ + 'medium-light skin tone', + 'swim', + 'uc8', + 'sport', + 'diversity', + 'vacation', + 'swim', + 'scuba', + 'summer', + 'activity', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'swimming', + 'swimmer', + 'snorkel', + 'weekend' + ], + modifiable: true), + Emoji( + name: 'person swimming: medium skin tone', + char: '\u{1F3CA}\u{1F3FD}', + shortName: 'person_swimming_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personSport, + keywords: [ + 'medium skin tone', + 'swim', + 'uc8', + 'sport', + 'diversity', + 'vacation', + 'swim', + 'scuba', + 'summer', + 'activity', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'swimming', + 'swimmer', + 'snorkel', + 'weekend' + ], + modifiable: true), + Emoji( + name: 'person swimming: medium-dark skin tone', + char: '\u{1F3CA}\u{1F3FE}', + shortName: 'person_swimming_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personSport, + keywords: [ + 'medium-dark skin tone', + 'swim', + 'uc8', + 'sport', + 'diversity', + 'vacation', + 'swim', + 'scuba', + 'summer', + 'activity', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'swimming', + 'swimmer', + 'snorkel', + 'weekend' + ], + modifiable: true), + Emoji( + name: 'person swimming: dark skin tone', + char: '\u{1F3CA}\u{1F3FF}', + shortName: 'person_swimming_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personSport, + keywords: [ + 'dark skin tone', + 'swim', + 'uc8', + 'sport', + 'diversity', + 'vacation', + 'swim', + 'scuba', + 'summer', + 'activity', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'swimming', + 'swimmer', + 'snorkel', + 'weekend' + ], + modifiable: true), + Emoji( + name: 'woman swimming', + char: '\u{1F3CA}\u{200D}\u{2640}\u{FE0F}', + shortName: 'woman_swimming', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personSport, + keywords: [ + 'swim', + 'woman', + 'uc6', + 'sport', + 'diversity', + 'vacation', + 'swim', + 'scuba', + 'summer', + 'activity', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'swimming', + 'swimmer', + 'snorkel', + 'weekend' + ]), + Emoji( + name: 'woman swimming: light skin tone', + char: '\u{1F3CA}\u{1F3FB}\u{200D}\u{2640}\u{FE0F}', + shortName: 'woman_swimming_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personSport, + keywords: [ + 'light skin tone', + 'swim', + 'woman', + 'uc8', + 'sport', + 'diversity', + 'vacation', + 'swim', + 'scuba', + 'summer', + 'activity', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'swimming', + 'swimmer', + 'snorkel', + 'weekend' + ], + modifiable: true), + Emoji( + name: 'woman swimming: medium-light skin tone', + char: '\u{1F3CA}\u{1F3FC}\u{200D}\u{2640}\u{FE0F}', + shortName: 'woman_swimming_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personSport, + keywords: [ + 'medium-light skin tone', + 'swim', + 'woman', + 'uc8', + 'sport', + 'diversity', + 'vacation', + 'swim', + 'scuba', + 'summer', + 'activity', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'swimming', + 'swimmer', + 'snorkel', + 'weekend' + ], + modifiable: true), + Emoji( + name: 'woman swimming: medium skin tone', + char: '\u{1F3CA}\u{1F3FD}\u{200D}\u{2640}\u{FE0F}', + shortName: 'woman_swimming_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personSport, + keywords: [ + 'medium skin tone', + 'swim', + 'woman', + 'uc8', + 'sport', + 'diversity', + 'vacation', + 'swim', + 'scuba', + 'summer', + 'activity', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'swimming', + 'swimmer', + 'snorkel', + 'weekend' + ], + modifiable: true), + Emoji( + name: 'woman swimming: medium-dark skin tone', + char: '\u{1F3CA}\u{1F3FE}\u{200D}\u{2640}\u{FE0F}', + shortName: 'woman_swimming_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personSport, + keywords: [ + 'medium-dark skin tone', + 'swim', + 'woman', + 'uc8', + 'sport', + 'diversity', + 'vacation', + 'swim', + 'scuba', + 'summer', + 'activity', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'swimming', + 'swimmer', + 'snorkel', + 'weekend' + ], + modifiable: true), + Emoji( + name: 'woman swimming: dark skin tone', + char: '\u{1F3CA}\u{1F3FF}\u{200D}\u{2640}\u{FE0F}', + shortName: 'woman_swimming_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personSport, + keywords: [ + 'dark skin tone', + 'swim', + 'woman', + 'uc8', + 'sport', + 'diversity', + 'vacation', + 'swim', + 'scuba', + 'summer', + 'activity', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'swimming', + 'swimmer', + 'snorkel', + 'weekend' + ], + modifiable: true), + Emoji( + name: 'man swimming', + char: '\u{1F3CA}\u{200D}\u{2642}\u{FE0F}', + shortName: 'man_swimming', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personSport, + keywords: [ + 'man', + 'swim', + 'uc6', + 'sport', + 'diversity', + 'vacation', + 'swim', + 'scuba', + 'summer', + 'activity', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'swimming', + 'swimmer', + 'snorkel', + 'weekend' + ]), + Emoji( + name: 'man swimming: light skin tone', + char: '\u{1F3CA}\u{1F3FB}\u{200D}\u{2642}\u{FE0F}', + shortName: 'man_swimming_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personSport, + keywords: [ + 'light skin tone', + 'man', + 'swim', + 'uc8', + 'sport', + 'diversity', + 'vacation', + 'swim', + 'scuba', + 'summer', + 'activity', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'swimming', + 'swimmer', + 'snorkel', + 'weekend' + ], + modifiable: true), + Emoji( + name: 'man swimming: medium-light skin tone', + char: '\u{1F3CA}\u{1F3FC}\u{200D}\u{2642}\u{FE0F}', + shortName: 'man_swimming_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personSport, + keywords: [ + 'man', + 'medium-light skin tone', + 'swim', + 'uc8', + 'sport', + 'diversity', + 'vacation', + 'swim', + 'scuba', + 'summer', + 'activity', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'swimming', + 'swimmer', + 'snorkel', + 'weekend' + ], + modifiable: true), + Emoji( + name: 'man swimming: medium skin tone', + char: '\u{1F3CA}\u{1F3FD}\u{200D}\u{2642}\u{FE0F}', + shortName: 'man_swimming_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personSport, + keywords: [ + 'man', + 'medium skin tone', + 'swim', + 'uc8', + 'sport', + 'diversity', + 'vacation', + 'swim', + 'scuba', + 'summer', + 'activity', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'swimming', + 'swimmer', + 'snorkel', + 'weekend' + ], + modifiable: true), + Emoji( + name: 'man swimming: medium-dark skin tone', + char: '\u{1F3CA}\u{1F3FE}\u{200D}\u{2642}\u{FE0F}', + shortName: 'man_swimming_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personSport, + keywords: [ + 'man', + 'medium-dark skin tone', + 'swim', + 'uc8', + 'sport', + 'diversity', + 'vacation', + 'swim', + 'scuba', + 'summer', + 'activity', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'swimming', + 'swimmer', + 'snorkel', + 'weekend' + ], + modifiable: true), + Emoji( + name: 'man swimming: dark skin tone', + char: '\u{1F3CA}\u{1F3FF}\u{200D}\u{2642}\u{FE0F}', + shortName: 'man_swimming_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personSport, + keywords: [ + 'dark skin tone', + 'man', + 'swim', + 'uc8', + 'sport', + 'diversity', + 'vacation', + 'swim', + 'scuba', + 'summer', + 'activity', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'swimming', + 'swimmer', + 'snorkel', + 'weekend' + ], + modifiable: true), + Emoji( + name: 'person playing water polo', + char: '\u{1F93D}', + shortName: 'person_playing_water_polo', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personSport, + keywords: [ + 'polo', + 'water', + 'uc9', + 'sport', + 'diversity', + 'ball', + 'play', + 'throw', + 'activity', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'balls', + 'ballon' + ]), + Emoji( + name: 'person playing water polo: light skin tone', + char: '\u{1F93D}\u{1F3FB}', + shortName: 'person_playing_water_polo_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personSport, + keywords: [ + 'light skin tone', + 'polo', + 'water', + 'uc9', + 'sport', + 'diversity', + 'ball', + 'play', + 'throw', + 'activity', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'balls', + 'ballon' + ], + modifiable: true), + Emoji( + name: 'person playing water polo: medium-light skin tone', + char: '\u{1F93D}\u{1F3FC}', + shortName: 'person_playing_water_polo_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personSport, + keywords: [ + 'medium-light skin tone', + 'polo', + 'water', + 'uc9', + 'sport', + 'diversity', + 'ball', + 'play', + 'throw', + 'activity', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'balls', + 'ballon' + ], + modifiable: true), + Emoji( + name: 'person playing water polo: medium skin tone', + char: '\u{1F93D}\u{1F3FD}', + shortName: 'person_playing_water_polo_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personSport, + keywords: [ + 'medium skin tone', + 'polo', + 'water', + 'uc9', + 'sport', + 'diversity', + 'ball', + 'play', + 'throw', + 'activity', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'balls', + 'ballon' + ], + modifiable: true), + Emoji( + name: 'person playing water polo: medium-dark skin tone', + char: '\u{1F93D}\u{1F3FE}', + shortName: 'person_playing_water_polo_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personSport, + keywords: [ + 'medium-dark skin tone', + 'polo', + 'water', + 'uc9', + 'sport', + 'diversity', + 'ball', + 'play', + 'throw', + 'activity', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'balls', + 'ballon' + ], + modifiable: true), + Emoji( + name: 'person playing water polo: dark skin tone', + char: '\u{1F93D}\u{1F3FF}', + shortName: 'person_playing_water_polo_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personSport, + keywords: [ + 'dark skin tone', + 'polo', + 'water', + 'uc9', + 'sport', + 'diversity', + 'ball', + 'play', + 'throw', + 'activity', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'balls', + 'ballon' + ], + modifiable: true), + Emoji( + name: 'woman playing water polo', + char: '\u{1F93D}\u{200D}\u{2640}\u{FE0F}', + shortName: 'woman_playing_water_polo', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personSport, + keywords: [ + 'water polo', + 'woman', + 'uc9', + 'sport', + 'diversity', + 'ball', + 'play', + 'throw', + 'activity', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'balls', + 'ballon' + ]), + Emoji( + name: 'woman playing water polo: light skin tone', + char: '\u{1F93D}\u{1F3FB}\u{200D}\u{2640}\u{FE0F}', + shortName: 'woman_playing_water_polo_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personSport, + keywords: [ + 'light skin tone', + 'water polo', + 'woman', + 'uc9', + 'sport', + 'diversity', + 'ball', + 'play', + 'throw', + 'activity', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'balls', + 'ballon' + ], + modifiable: true), + Emoji( + name: 'woman playing water polo: medium-light skin tone', + char: '\u{1F93D}\u{1F3FC}\u{200D}\u{2640}\u{FE0F}', + shortName: 'woman_playing_water_polo_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personSport, + keywords: [ + 'medium-light skin tone', + 'water polo', + 'woman', + 'uc9', + 'sport', + 'diversity', + 'ball', + 'play', + 'throw', + 'activity', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'balls', + 'ballon' + ], + modifiable: true), + Emoji( + name: 'woman playing water polo: medium skin tone', + char: '\u{1F93D}\u{1F3FD}\u{200D}\u{2640}\u{FE0F}', + shortName: 'woman_playing_water_polo_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personSport, + keywords: [ + 'medium skin tone', + 'water polo', + 'woman', + 'uc9', + 'sport', + 'diversity', + 'ball', + 'play', + 'throw', + 'activity', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'balls', + 'ballon' + ], + modifiable: true), + Emoji( + name: 'woman playing water polo: medium-dark skin tone', + char: '\u{1F93D}\u{1F3FE}\u{200D}\u{2640}\u{FE0F}', + shortName: 'woman_playing_water_polo_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personSport, + keywords: [ + 'medium-dark skin tone', + 'water polo', + 'woman', + 'uc9', + 'sport', + 'diversity', + 'ball', + 'play', + 'throw', + 'activity', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'balls', + 'ballon' + ], + modifiable: true), + Emoji( + name: 'woman playing water polo: dark skin tone', + char: '\u{1F93D}\u{1F3FF}\u{200D}\u{2640}\u{FE0F}', + shortName: 'woman_playing_water_polo_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personSport, + keywords: [ + 'dark skin tone', + 'water polo', + 'woman', + 'uc9', + 'sport', + 'diversity', + 'ball', + 'play', + 'throw', + 'activity', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'balls', + 'ballon' + ], + modifiable: true), + Emoji( + name: 'man playing water polo', + char: '\u{1F93D}\u{200D}\u{2642}\u{FE0F}', + shortName: 'man_playing_water_polo', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personSport, + keywords: [ + 'man', + 'water polo', + 'uc9', + 'sport', + 'diversity', + 'ball', + 'play', + 'throw', + 'activity', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'balls', + 'ballon' + ]), + Emoji( + name: 'man playing water polo: light skin tone', + char: '\u{1F93D}\u{1F3FB}\u{200D}\u{2642}\u{FE0F}', + shortName: 'man_playing_water_polo_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personSport, + keywords: [ + 'light skin tone', + 'man', + 'water polo', + 'uc9', + 'sport', + 'diversity', + 'ball', + 'play', + 'throw', + 'activity', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'balls', + 'ballon' + ], + modifiable: true), + Emoji( + name: 'man playing water polo: medium-light skin tone', + char: '\u{1F93D}\u{1F3FC}\u{200D}\u{2642}\u{FE0F}', + shortName: 'man_playing_water_polo_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personSport, + keywords: [ + 'man', + 'medium-light skin tone', + 'water polo', + 'uc9', + 'sport', + 'diversity', + 'ball', + 'play', + 'throw', + 'activity', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'balls', + 'ballon' + ], + modifiable: true), + Emoji( + name: 'man playing water polo: medium skin tone', + char: '\u{1F93D}\u{1F3FD}\u{200D}\u{2642}\u{FE0F}', + shortName: 'man_playing_water_polo_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personSport, + keywords: [ + 'man', + 'medium skin tone', + 'water polo', + 'uc9', + 'sport', + 'diversity', + 'ball', + 'play', + 'throw', + 'activity', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'balls', + 'ballon' + ], + modifiable: true), + Emoji( + name: 'man playing water polo: medium-dark skin tone', + char: '\u{1F93D}\u{1F3FE}\u{200D}\u{2642}\u{FE0F}', + shortName: 'man_playing_water_polo_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personSport, + keywords: [ + 'man', + 'medium-dark skin tone', + 'water polo', + 'uc9', + 'sport', + 'diversity', + 'ball', + 'play', + 'throw', + 'activity', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'balls', + 'ballon' + ], + modifiable: true), + Emoji( + name: 'man playing water polo: dark skin tone', + char: '\u{1F93D}\u{1F3FF}\u{200D}\u{2642}\u{FE0F}', + shortName: 'man_playing_water_polo_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personSport, + keywords: [ + 'dark skin tone', + 'man', + 'water polo', + 'uc9', + 'sport', + 'diversity', + 'ball', + 'play', + 'throw', + 'activity', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'balls', + 'ballon' + ], + modifiable: true), + Emoji( + name: 'person rowing boat', + char: '\u{1F6A3}', + shortName: 'person_rowing_boat', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personSport, + keywords: [ + 'boat', + 'rowboat', + 'uc6', + 'sport', + 'diversity', + 'boat', + 'rowing', + 'hawaii', + 'scuba', + 'activity', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'boats', + 'boating', + 'rowboat', + 'canoe', + 'aloha', + 'kawaii', + 'maui', + 'moana', + 'snorkel' + ]), + Emoji( + name: 'person rowing boat: light skin tone', + char: '\u{1F6A3}\u{1F3FB}', + shortName: 'person_rowing_boat_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personSport, + keywords: [ + 'boat', + 'light skin tone', + 'rowboat', + 'uc8', + 'sport', + 'diversity', + 'boat', + 'rowing', + 'hawaii', + 'scuba', + 'activity', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'boats', + 'boating', + 'rowboat', + 'canoe', + 'aloha', + 'kawaii', + 'maui', + 'moana', + 'snorkel' + ], + modifiable: true), + Emoji( + name: 'person rowing boat: medium-light skin tone', + char: '\u{1F6A3}\u{1F3FC}', + shortName: 'person_rowing_boat_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personSport, + keywords: [ + 'boat', + 'medium-light skin tone', + 'rowboat', + 'uc8', + 'sport', + 'diversity', + 'boat', + 'rowing', + 'hawaii', + 'scuba', + 'activity', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'boats', + 'boating', + 'rowboat', + 'canoe', + 'aloha', + 'kawaii', + 'maui', + 'moana', + 'snorkel' + ], + modifiable: true), + Emoji( + name: 'person rowing boat: medium skin tone', + char: '\u{1F6A3}\u{1F3FD}', + shortName: 'person_rowing_boat_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personSport, + keywords: [ + 'boat', + 'medium skin tone', + 'rowboat', + 'uc8', + 'sport', + 'diversity', + 'boat', + 'rowing', + 'hawaii', + 'scuba', + 'activity', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'boats', + 'boating', + 'rowboat', + 'canoe', + 'aloha', + 'kawaii', + 'maui', + 'moana', + 'snorkel' + ], + modifiable: true), + Emoji( + name: 'person rowing boat: medium-dark skin tone', + char: '\u{1F6A3}\u{1F3FE}', + shortName: 'person_rowing_boat_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personSport, + keywords: [ + 'boat', + 'medium-dark skin tone', + 'rowboat', + 'uc8', + 'sport', + 'diversity', + 'boat', + 'rowing', + 'hawaii', + 'scuba', + 'activity', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'boats', + 'boating', + 'rowboat', + 'canoe', + 'aloha', + 'kawaii', + 'maui', + 'moana', + 'snorkel' + ], + modifiable: true), + Emoji( + name: 'person rowing boat: dark skin tone', + char: '\u{1F6A3}\u{1F3FF}', + shortName: 'person_rowing_boat_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personSport, + keywords: [ + 'boat', + 'dark skin tone', + 'rowboat', + 'uc8', + 'sport', + 'diversity', + 'boat', + 'rowing', + 'hawaii', + 'scuba', + 'activity', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'boats', + 'boating', + 'rowboat', + 'canoe', + 'aloha', + 'kawaii', + 'maui', + 'moana', + 'snorkel' + ], + modifiable: true), + Emoji( + name: 'woman rowing boat', + char: '\u{1F6A3}\u{200D}\u{2640}\u{FE0F}', + shortName: 'woman_rowing_boat', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personSport, + keywords: [ + 'boat', + 'rowboat', + 'woman', + 'uc6', + 'sport', + 'diversity', + 'boat', + 'rowing', + 'hawaii', + 'scuba', + 'activity', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'boats', + 'boating', + 'rowboat', + 'canoe', + 'aloha', + 'kawaii', + 'maui', + 'moana', + 'snorkel' + ]), + Emoji( + name: 'woman rowing boat: light skin tone', + char: '\u{1F6A3}\u{1F3FB}\u{200D}\u{2640}\u{FE0F}', + shortName: 'woman_rowing_boat_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personSport, + keywords: [ + 'boat', + 'light skin tone', + 'rowboat', + 'woman', + 'uc8', + 'sport', + 'diversity', + 'boat', + 'rowing', + 'hawaii', + 'scuba', + 'activity', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'boats', + 'boating', + 'rowboat', + 'canoe', + 'aloha', + 'kawaii', + 'maui', + 'moana', + 'snorkel' + ], + modifiable: true), + Emoji( + name: 'woman rowing boat: medium-light skin tone', + char: '\u{1F6A3}\u{1F3FC}\u{200D}\u{2640}\u{FE0F}', + shortName: 'woman_rowing_boat_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personSport, + keywords: [ + 'boat', + 'medium-light skin tone', + 'rowboat', + 'woman', + 'uc8', + 'sport', + 'diversity', + 'boat', + 'rowing', + 'hawaii', + 'scuba', + 'activity', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'boats', + 'boating', + 'rowboat', + 'canoe', + 'aloha', + 'kawaii', + 'maui', + 'moana', + 'snorkel' + ], + modifiable: true), + Emoji( + name: 'woman rowing boat: medium skin tone', + char: '\u{1F6A3}\u{1F3FD}\u{200D}\u{2640}\u{FE0F}', + shortName: 'woman_rowing_boat_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personSport, + keywords: [ + 'boat', + 'medium skin tone', + 'rowboat', + 'woman', + 'uc8', + 'sport', + 'diversity', + 'boat', + 'rowing', + 'hawaii', + 'scuba', + 'activity', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'boats', + 'boating', + 'rowboat', + 'canoe', + 'aloha', + 'kawaii', + 'maui', + 'moana', + 'snorkel' + ], + modifiable: true), + Emoji( + name: 'woman rowing boat: medium-dark skin tone', + char: '\u{1F6A3}\u{1F3FE}\u{200D}\u{2640}\u{FE0F}', + shortName: 'woman_rowing_boat_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personSport, + keywords: [ + 'boat', + 'medium-dark skin tone', + 'rowboat', + 'woman', + 'uc8', + 'sport', + 'diversity', + 'boat', + 'rowing', + 'hawaii', + 'scuba', + 'activity', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'boats', + 'boating', + 'rowboat', + 'canoe', + 'aloha', + 'kawaii', + 'maui', + 'moana', + 'snorkel' + ], + modifiable: true), + Emoji( + name: 'woman rowing boat: dark skin tone', + char: '\u{1F6A3}\u{1F3FF}\u{200D}\u{2640}\u{FE0F}', + shortName: 'woman_rowing_boat_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personSport, + keywords: [ + 'boat', + 'dark skin tone', + 'rowboat', + 'woman', + 'uc8', + 'sport', + 'diversity', + 'boat', + 'rowing', + 'hawaii', + 'scuba', + 'activity', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'boats', + 'boating', + 'rowboat', + 'canoe', + 'aloha', + 'kawaii', + 'maui', + 'moana', + 'snorkel' + ], + modifiable: true), + Emoji( + name: 'man rowing boat', + char: '\u{1F6A3}\u{200D}\u{2642}\u{FE0F}', + shortName: 'man_rowing_boat', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personSport, + keywords: [ + 'boat', + 'man', + 'rowboat', + 'uc6', + 'sport', + 'diversity', + 'boat', + 'rowing', + 'hawaii', + 'scuba', + 'activity', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'boats', + 'boating', + 'rowboat', + 'canoe', + 'aloha', + 'kawaii', + 'maui', + 'moana', + 'snorkel' + ]), + Emoji( + name: 'man rowing boat: light skin tone', + char: '\u{1F6A3}\u{1F3FB}\u{200D}\u{2642}\u{FE0F}', + shortName: 'man_rowing_boat_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personSport, + keywords: [ + 'boat', + 'light skin tone', + 'man', + 'rowboat', + 'uc8', + 'sport', + 'diversity', + 'boat', + 'rowing', + 'hawaii', + 'scuba', + 'activity', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'boats', + 'boating', + 'rowboat', + 'canoe', + 'aloha', + 'kawaii', + 'maui', + 'moana', + 'snorkel' + ], + modifiable: true), + Emoji( + name: 'man rowing boat: medium-light skin tone', + char: '\u{1F6A3}\u{1F3FC}\u{200D}\u{2642}\u{FE0F}', + shortName: 'man_rowing_boat_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personSport, + keywords: [ + 'boat', + 'man', + 'medium-light skin tone', + 'rowboat', + 'uc8', + 'sport', + 'diversity', + 'boat', + 'rowing', + 'hawaii', + 'scuba', + 'activity', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'boats', + 'boating', + 'rowboat', + 'canoe', + 'aloha', + 'kawaii', + 'maui', + 'moana', + 'snorkel' + ], + modifiable: true), + Emoji( + name: 'man rowing boat: medium skin tone', + char: '\u{1F6A3}\u{1F3FD}\u{200D}\u{2642}\u{FE0F}', + shortName: 'man_rowing_boat_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personSport, + keywords: [ + 'boat', + 'man', + 'medium skin tone', + 'rowboat', + 'uc8', + 'sport', + 'diversity', + 'boat', + 'rowing', + 'hawaii', + 'scuba', + 'activity', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'boats', + 'boating', + 'rowboat', + 'canoe', + 'aloha', + 'kawaii', + 'maui', + 'moana', + 'snorkel' + ], + modifiable: true), + Emoji( + name: 'man rowing boat: medium-dark skin tone', + char: '\u{1F6A3}\u{1F3FE}\u{200D}\u{2642}\u{FE0F}', + shortName: 'man_rowing_boat_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personSport, + keywords: [ + 'boat', + 'man', + 'medium-dark skin tone', + 'rowboat', + 'uc8', + 'sport', + 'diversity', + 'boat', + 'rowing', + 'hawaii', + 'scuba', + 'activity', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'boats', + 'boating', + 'rowboat', + 'canoe', + 'aloha', + 'kawaii', + 'maui', + 'moana', + 'snorkel' + ], + modifiable: true), + Emoji( + name: 'man rowing boat: dark skin tone', + char: '\u{1F6A3}\u{1F3FF}\u{200D}\u{2642}\u{FE0F}', + shortName: 'man_rowing_boat_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personSport, + keywords: [ + 'boat', + 'dark skin tone', + 'man', + 'rowboat', + 'uc8', + 'sport', + 'diversity', + 'boat', + 'rowing', + 'hawaii', + 'scuba', + 'activity', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'boats', + 'boating', + 'rowboat', + 'canoe', + 'aloha', + 'kawaii', + 'maui', + 'moana', + 'snorkel' + ], + modifiable: true), + Emoji( + name: 'person climbing', + char: '\u{1F9D7}', + shortName: 'person_climbing', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personActivity, + keywords: [ + 'climber', + 'uc10', + 'sport', + 'diversity', + 'fun', + 'activity', + 'rock climbing', + 'climb', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'climber' + ]), + Emoji( + name: 'person climbing: light skin tone', + char: '\u{1F9D7}\u{1F3FB}', + shortName: 'person_climbing_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personActivity, + keywords: [ + 'climber', + 'light skin tone', + 'uc10', + 'sport', + 'diversity', + 'fun', + 'activity', + 'rock climbing', + 'climb', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'climber' + ], + modifiable: true), + Emoji( + name: 'person climbing: medium-light skin tone', + char: '\u{1F9D7}\u{1F3FC}', + shortName: 'person_climbing_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personActivity, + keywords: [ + 'climber', + 'medium-light skin tone', + 'uc10', + 'sport', + 'diversity', + 'fun', + 'activity', + 'rock climbing', + 'climb', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'climber' + ], + modifiable: true), + Emoji( + name: 'person climbing: medium skin tone', + char: '\u{1F9D7}\u{1F3FD}', + shortName: 'person_climbing_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personActivity, + keywords: [ + 'climber', + 'medium skin tone', + 'uc10', + 'sport', + 'diversity', + 'fun', + 'activity', + 'rock climbing', + 'climb', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'climber' + ], + modifiable: true), + Emoji( + name: 'person climbing: medium-dark skin tone', + char: '\u{1F9D7}\u{1F3FE}', + shortName: 'person_climbing_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personActivity, + keywords: [ + 'climber', + 'medium-dark skin tone', + 'uc10', + 'sport', + 'diversity', + 'fun', + 'activity', + 'rock climbing', + 'climb', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'climber' + ], + modifiable: true), + Emoji( + name: 'person climbing: dark skin tone', + char: '\u{1F9D7}\u{1F3FF}', + shortName: 'person_climbing_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personActivity, + keywords: [ + 'climber', + 'dark skin tone', + 'uc10', + 'sport', + 'diversity', + 'fun', + 'activity', + 'rock climbing', + 'climb', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'climber' + ], + modifiable: true), + Emoji( + name: 'woman climbing', + char: '\u{1F9D7}\u{200D}\u{2640}\u{FE0F}', + shortName: 'woman_climbing', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personActivity, + keywords: [ + 'climber', + 'uc10', + 'sport', + 'diversity', + 'fun', + 'activity', + 'rock climbing', + 'climb', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'climber' + ]), + Emoji( + name: 'woman climbing: light skin tone', + char: '\u{1F9D7}\u{1F3FB}\u{200D}\u{2640}\u{FE0F}', + shortName: 'woman_climbing_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personActivity, + keywords: [ + 'climber', + 'light skin tone', + 'uc10', + 'sport', + 'diversity', + 'fun', + 'activity', + 'rock climbing', + 'climb', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'climber' + ], + modifiable: true), + Emoji( + name: 'woman climbing: medium-light skin tone', + char: '\u{1F9D7}\u{1F3FC}\u{200D}\u{2640}\u{FE0F}', + shortName: 'woman_climbing_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personActivity, + keywords: [ + 'climber', + 'medium-light skin tone', + 'uc10', + 'sport', + 'diversity', + 'fun', + 'activity', + 'rock climbing', + 'climb', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'climber' + ], + modifiable: true), + Emoji( + name: 'woman climbing: medium skin tone', + char: '\u{1F9D7}\u{1F3FD}\u{200D}\u{2640}\u{FE0F}', + shortName: 'woman_climbing_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personActivity, + keywords: [ + 'climber', + 'medium skin tone', + 'uc10', + 'sport', + 'diversity', + 'fun', + 'activity', + 'rock climbing', + 'climb', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'climber' + ], + modifiable: true), + Emoji( + name: 'woman climbing: medium-dark skin tone', + char: '\u{1F9D7}\u{1F3FE}\u{200D}\u{2640}\u{FE0F}', + shortName: 'woman_climbing_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personActivity, + keywords: [ + 'climber', + 'medium-dark skin tone', + 'uc10', + 'sport', + 'diversity', + 'fun', + 'activity', + 'rock climbing', + 'climb', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'climber' + ], + modifiable: true), + Emoji( + name: 'woman climbing: dark skin tone', + char: '\u{1F9D7}\u{1F3FF}\u{200D}\u{2640}\u{FE0F}', + shortName: 'woman_climbing_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personActivity, + keywords: [ + 'climber', + 'dark skin tone', + 'uc10', + 'sport', + 'diversity', + 'fun', + 'activity', + 'rock climbing', + 'climb', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'climber' + ], + modifiable: true), + Emoji( + name: 'man climbing', + char: '\u{1F9D7}\u{200D}\u{2642}\u{FE0F}', + shortName: 'man_climbing', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personActivity, + keywords: [ + 'climber', + 'uc10', + 'sport', + 'diversity', + 'fun', + 'activity', + 'rock climbing', + 'climb', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'climber' + ]), + Emoji( + name: 'man climbing: light skin tone', + char: '\u{1F9D7}\u{1F3FB}\u{200D}\u{2642}\u{FE0F}', + shortName: 'man_climbing_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personActivity, + keywords: [ + 'climber', + 'light skin tone', + 'uc10', + 'sport', + 'diversity', + 'fun', + 'activity', + 'rock climbing', + 'climb', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'climber' + ], + modifiable: true), + Emoji( + name: 'man climbing: medium-light skin tone', + char: '\u{1F9D7}\u{1F3FC}\u{200D}\u{2642}\u{FE0F}', + shortName: 'man_climbing_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personActivity, + keywords: [ + 'climber', + 'medium-light skin tone', + 'uc10', + 'sport', + 'diversity', + 'fun', + 'activity', + 'rock climbing', + 'climb', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'climber' + ], + modifiable: true), + Emoji( + name: 'man climbing: medium skin tone', + char: '\u{1F9D7}\u{1F3FD}\u{200D}\u{2642}\u{FE0F}', + shortName: 'man_climbing_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personActivity, + keywords: [ + 'climber', + 'medium skin tone', + 'uc10', + 'sport', + 'diversity', + 'fun', + 'activity', + 'rock climbing', + 'climb', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'climber' + ], + modifiable: true), + Emoji( + name: 'man climbing: medium-dark skin tone', + char: '\u{1F9D7}\u{1F3FE}\u{200D}\u{2642}\u{FE0F}', + shortName: 'man_climbing_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personActivity, + keywords: [ + 'climber', + 'medium-dark skin tone', + 'uc10', + 'sport', + 'diversity', + 'fun', + 'activity', + 'rock climbing', + 'climb', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'climber' + ], + modifiable: true), + Emoji( + name: 'man climbing: dark skin tone', + char: '\u{1F9D7}\u{1F3FF}\u{200D}\u{2642}\u{FE0F}', + shortName: 'man_climbing_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personActivity, + keywords: [ + 'climber', + 'dark skin tone', + 'uc10', + 'sport', + 'diversity', + 'fun', + 'activity', + 'rock climbing', + 'climb', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'climber' + ], + modifiable: true), + Emoji( + name: 'person mountain biking', + char: '\u{1F6B5}', + shortName: 'person_mountain_biking', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personSport, + keywords: [ + 'bicycle', + 'bicyclist', + 'bike', + 'cyclist', + 'mountain', + 'uc6', + 'sport', + 'diversity', + 'bike', + 'fun', + 'activity', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'bikes', + 'bicycle', + 'bicycling' + ]), + Emoji( + name: 'person mountain biking: light skin tone', + char: '\u{1F6B5}\u{1F3FB}', + shortName: 'person_mountain_biking_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personSport, + keywords: [ + 'bicycle', + 'bicyclist', + 'bike', + 'cyclist', + 'light skin tone', + 'mountain', + 'uc8', + 'sport', + 'diversity', + 'bike', + 'fun', + 'activity', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'bikes', + 'bicycle', + 'bicycling' + ], + modifiable: true), + Emoji( + name: 'person mountain biking: medium-light skin tone', + char: '\u{1F6B5}\u{1F3FC}', + shortName: 'person_mountain_biking_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personSport, + keywords: [ + 'bicycle', + 'bicyclist', + 'bike', + 'cyclist', + 'medium-light skin tone', + 'mountain', + 'uc8', + 'sport', + 'diversity', + 'bike', + 'fun', + 'activity', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'bikes', + 'bicycle', + 'bicycling' + ], + modifiable: true), + Emoji( + name: 'person mountain biking: medium skin tone', + char: '\u{1F6B5}\u{1F3FD}', + shortName: 'person_mountain_biking_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personSport, + keywords: [ + 'bicycle', + 'bicyclist', + 'bike', + 'cyclist', + 'medium skin tone', + 'mountain', + 'uc8', + 'sport', + 'diversity', + 'bike', + 'fun', + 'activity', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'bikes', + 'bicycle', + 'bicycling' + ], + modifiable: true), + Emoji( + name: 'person mountain biking: medium-dark skin tone', + char: '\u{1F6B5}\u{1F3FE}', + shortName: 'person_mountain_biking_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personSport, + keywords: [ + 'bicycle', + 'bicyclist', + 'bike', + 'cyclist', + 'medium-dark skin tone', + 'mountain', + 'uc8', + 'sport', + 'diversity', + 'bike', + 'fun', + 'activity', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'bikes', + 'bicycle', + 'bicycling' + ], + modifiable: true), + Emoji( + name: 'person mountain biking: dark skin tone', + char: '\u{1F6B5}\u{1F3FF}', + shortName: 'person_mountain_biking_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personSport, + keywords: [ + 'bicycle', + 'bicyclist', + 'bike', + 'cyclist', + 'dark skin tone', + 'mountain', + 'uc8', + 'sport', + 'diversity', + 'bike', + 'fun', + 'activity', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'bikes', + 'bicycle', + 'bicycling' + ], + modifiable: true), + Emoji( + name: 'woman mountain biking', + char: '\u{1F6B5}\u{200D}\u{2640}\u{FE0F}', + shortName: 'woman_mountain_biking', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personSport, + keywords: [ + 'bicycle', + 'bike', + 'biking', + 'cyclist', + 'mountain', + 'woman', + 'uc6', + 'sport', + 'diversity', + 'bike', + 'fun', + 'activity', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'bikes', + 'bicycle', + 'bicycling' + ]), + Emoji( + name: 'woman mountain biking: light skin tone', + char: '\u{1F6B5}\u{1F3FB}\u{200D}\u{2640}\u{FE0F}', + shortName: 'woman_mountain_biking_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personSport, + keywords: [ + 'bicycle', + 'bike', + 'biking', + 'cyclist', + 'light skin tone', + 'mountain', + 'woman', + 'uc8', + 'sport', + 'diversity', + 'bike', + 'fun', + 'activity', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'bikes', + 'bicycle', + 'bicycling' + ], + modifiable: true), + Emoji( + name: 'woman mountain biking: medium-light skin tone', + char: '\u{1F6B5}\u{1F3FC}\u{200D}\u{2640}\u{FE0F}', + shortName: 'woman_mountain_biking_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personSport, + keywords: [ + 'bicycle', + 'bike', + 'biking', + 'cyclist', + 'medium-light skin tone', + 'mountain', + 'woman', + 'uc8', + 'sport', + 'diversity', + 'bike', + 'fun', + 'activity', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'bikes', + 'bicycle', + 'bicycling' + ], + modifiable: true), + Emoji( + name: 'woman mountain biking: medium skin tone', + char: '\u{1F6B5}\u{1F3FD}\u{200D}\u{2640}\u{FE0F}', + shortName: 'woman_mountain_biking_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personSport, + keywords: [ + 'bicycle', + 'bike', + 'biking', + 'cyclist', + 'medium skin tone', + 'mountain', + 'woman', + 'uc8', + 'sport', + 'diversity', + 'bike', + 'fun', + 'activity', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'bikes', + 'bicycle', + 'bicycling' + ], + modifiable: true), + Emoji( + name: 'woman mountain biking: medium-dark skin tone', + char: '\u{1F6B5}\u{1F3FE}\u{200D}\u{2640}\u{FE0F}', + shortName: 'woman_mountain_biking_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personSport, + keywords: [ + 'bicycle', + 'bike', + 'biking', + 'cyclist', + 'medium-dark skin tone', + 'mountain', + 'woman', + 'uc8', + 'sport', + 'diversity', + 'bike', + 'fun', + 'activity', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'bikes', + 'bicycle', + 'bicycling' + ], + modifiable: true), + Emoji( + name: 'woman mountain biking: dark skin tone', + char: '\u{1F6B5}\u{1F3FF}\u{200D}\u{2640}\u{FE0F}', + shortName: 'woman_mountain_biking_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personSport, + keywords: [ + 'bicycle', + 'bike', + 'biking', + 'cyclist', + 'dark skin tone', + 'mountain', + 'woman', + 'uc8', + 'sport', + 'diversity', + 'bike', + 'fun', + 'activity', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'bikes', + 'bicycle', + 'bicycling' + ], + modifiable: true), + Emoji( + name: 'man mountain biking', + char: '\u{1F6B5}\u{200D}\u{2642}\u{FE0F}', + shortName: 'man_mountain_biking', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personSport, + keywords: [ + 'bicycle', + 'bike', + 'cyclist', + 'man', + 'mountain', + 'uc6', + 'sport', + 'diversity', + 'bike', + 'fun', + 'activity', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'bikes', + 'bicycle', + 'bicycling' + ]), + Emoji( + name: 'man mountain biking: light skin tone', + char: '\u{1F6B5}\u{1F3FB}\u{200D}\u{2642}\u{FE0F}', + shortName: 'man_mountain_biking_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personSport, + keywords: [ + 'bicycle', + 'bike', + 'cyclist', + 'light skin tone', + 'man', + 'mountain', + 'uc8', + 'sport', + 'diversity', + 'bike', + 'fun', + 'activity', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'bikes', + 'bicycle', + 'bicycling' + ], + modifiable: true), + Emoji( + name: 'man mountain biking: medium-light skin tone', + char: '\u{1F6B5}\u{1F3FC}\u{200D}\u{2642}\u{FE0F}', + shortName: 'man_mountain_biking_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personSport, + keywords: [ + 'bicycle', + 'bike', + 'cyclist', + 'man', + 'medium-light skin tone', + 'mountain', + 'uc8', + 'sport', + 'diversity', + 'bike', + 'fun', + 'activity', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'bikes', + 'bicycle', + 'bicycling' + ], + modifiable: true), + Emoji( + name: 'man mountain biking: medium skin tone', + char: '\u{1F6B5}\u{1F3FD}\u{200D}\u{2642}\u{FE0F}', + shortName: 'man_mountain_biking_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personSport, + keywords: [ + 'bicycle', + 'bike', + 'cyclist', + 'man', + 'medium skin tone', + 'mountain', + 'uc8', + 'sport', + 'diversity', + 'bike', + 'fun', + 'activity', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'bikes', + 'bicycle', + 'bicycling' + ], + modifiable: true), + Emoji( + name: 'man mountain biking: medium-dark skin tone', + char: '\u{1F6B5}\u{1F3FE}\u{200D}\u{2642}\u{FE0F}', + shortName: 'man_mountain_biking_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personSport, + keywords: [ + 'bicycle', + 'bike', + 'cyclist', + 'man', + 'medium-dark skin tone', + 'mountain', + 'uc8', + 'sport', + 'diversity', + 'bike', + 'fun', + 'activity', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'bikes', + 'bicycle', + 'bicycling' + ], + modifiable: true), + Emoji( + name: 'man mountain biking: dark skin tone', + char: '\u{1F6B5}\u{1F3FF}\u{200D}\u{2642}\u{FE0F}', + shortName: 'man_mountain_biking_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personSport, + keywords: [ + 'bicycle', + 'bike', + 'cyclist', + 'dark skin tone', + 'man', + 'mountain', + 'uc8', + 'sport', + 'diversity', + 'bike', + 'fun', + 'activity', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'bikes', + 'bicycle', + 'bicycling' + ], + modifiable: true), + Emoji( + name: 'person biking', + char: '\u{1F6B4}', + shortName: 'person_biking', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personSport, + keywords: [ + 'bicycle', + 'biking', + 'cyclist', + 'uc6', + 'sport', + 'diversity', + 'bike', + 'fame', + 'fun', + 'activity', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'bikes', + 'bicycle', + 'bicycling', + 'famous', + 'celebrity' + ]), + Emoji( + name: 'person biking: light skin tone', + char: '\u{1F6B4}\u{1F3FB}', + shortName: 'person_biking_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personSport, + keywords: [ + 'bicycle', + 'biking', + 'cyclist', + 'light skin tone', + 'uc8', + 'sport', + 'diversity', + 'bike', + 'fame', + 'fun', + 'activity', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'bikes', + 'bicycle', + 'bicycling', + 'famous', + 'celebrity' + ], + modifiable: true), + Emoji( + name: 'person biking: medium-light skin tone', + char: '\u{1F6B4}\u{1F3FC}', + shortName: 'person_biking_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personSport, + keywords: [ + 'bicycle', + 'biking', + 'cyclist', + 'medium-light skin tone', + 'uc8', + 'sport', + 'diversity', + 'bike', + 'fame', + 'fun', + 'activity', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'bikes', + 'bicycle', + 'bicycling', + 'famous', + 'celebrity' + ], + modifiable: true), + Emoji( + name: 'person biking: medium skin tone', + char: '\u{1F6B4}\u{1F3FD}', + shortName: 'person_biking_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personSport, + keywords: [ + 'bicycle', + 'biking', + 'cyclist', + 'medium skin tone', + 'uc8', + 'sport', + 'diversity', + 'bike', + 'fame', + 'fun', + 'activity', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'bikes', + 'bicycle', + 'bicycling', + 'famous', + 'celebrity' + ], + modifiable: true), + Emoji( + name: 'person biking: medium-dark skin tone', + char: '\u{1F6B4}\u{1F3FE}', + shortName: 'person_biking_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personSport, + keywords: [ + 'bicycle', + 'biking', + 'cyclist', + 'medium-dark skin tone', + 'uc8', + 'sport', + 'diversity', + 'bike', + 'fame', + 'fun', + 'activity', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'bikes', + 'bicycle', + 'bicycling', + 'famous', + 'celebrity' + ], + modifiable: true), + Emoji( + name: 'person biking: dark skin tone', + char: '\u{1F6B4}\u{1F3FF}', + shortName: 'person_biking_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personSport, + keywords: [ + 'bicycle', + 'biking', + 'cyclist', + 'dark skin tone', + 'uc8', + 'sport', + 'diversity', + 'bike', + 'fame', + 'fun', + 'activity', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'bikes', + 'bicycle', + 'bicycling', + 'famous', + 'celebrity' + ], + modifiable: true), + Emoji( + name: 'woman biking', + char: '\u{1F6B4}\u{200D}\u{2640}\u{FE0F}', + shortName: 'woman_biking', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personSport, + keywords: [ + 'bicycle', + 'biking', + 'cyclist', + 'woman', + 'uc6', + 'sport', + 'diversity', + 'bike', + 'fun', + 'activity', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'bikes', + 'bicycle', + 'bicycling' + ]), + Emoji( + name: 'woman biking: light skin tone', + char: '\u{1F6B4}\u{1F3FB}\u{200D}\u{2640}\u{FE0F}', + shortName: 'woman_biking_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personSport, + keywords: [ + 'bicycle', + 'biking', + 'cyclist', + 'light skin tone', + 'woman', + 'uc8', + 'sport', + 'diversity', + 'bike', + 'fun', + 'activity', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'bikes', + 'bicycle', + 'bicycling' + ], + modifiable: true), + Emoji( + name: 'woman biking: medium-light skin tone', + char: '\u{1F6B4}\u{1F3FC}\u{200D}\u{2640}\u{FE0F}', + shortName: 'woman_biking_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personSport, + keywords: [ + 'bicycle', + 'biking', + 'cyclist', + 'medium-light skin tone', + 'woman', + 'uc8', + 'sport', + 'diversity', + 'bike', + 'fun', + 'activity', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'bikes', + 'bicycle', + 'bicycling' + ], + modifiable: true), + Emoji( + name: 'woman biking: medium skin tone', + char: '\u{1F6B4}\u{1F3FD}\u{200D}\u{2640}\u{FE0F}', + shortName: 'woman_biking_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personSport, + keywords: [ + 'bicycle', + 'biking', + 'cyclist', + 'medium skin tone', + 'woman', + 'uc8', + 'sport', + 'diversity', + 'bike', + 'fun', + 'activity', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'bikes', + 'bicycle', + 'bicycling' + ], + modifiable: true), + Emoji( + name: 'woman biking: medium-dark skin tone', + char: '\u{1F6B4}\u{1F3FE}\u{200D}\u{2640}\u{FE0F}', + shortName: 'woman_biking_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personSport, + keywords: [ + 'bicycle', + 'biking', + 'cyclist', + 'medium-dark skin tone', + 'woman', + 'uc8', + 'sport', + 'diversity', + 'bike', + 'fun', + 'activity', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'bikes', + 'bicycle', + 'bicycling' + ], + modifiable: true), + Emoji( + name: 'woman biking: dark skin tone', + char: '\u{1F6B4}\u{1F3FF}\u{200D}\u{2640}\u{FE0F}', + shortName: 'woman_biking_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personSport, + keywords: [ + 'bicycle', + 'biking', + 'cyclist', + 'dark skin tone', + 'woman', + 'uc8', + 'sport', + 'diversity', + 'bike', + 'fun', + 'activity', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'bikes', + 'bicycle', + 'bicycling' + ], + modifiable: true), + Emoji( + name: 'man biking', + char: '\u{1F6B4}\u{200D}\u{2642}\u{FE0F}', + shortName: 'man_biking', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personSport, + keywords: [ + 'bicycle', + 'biking', + 'cyclist', + 'man', + 'uc6', + 'sport', + 'diversity', + 'bike', + 'fame', + 'fun', + 'activity', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'bikes', + 'bicycle', + 'bicycling', + 'famous', + 'celebrity' + ]), + Emoji( + name: 'man biking: light skin tone', + char: '\u{1F6B4}\u{1F3FB}\u{200D}\u{2642}\u{FE0F}', + shortName: 'man_biking_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personSport, + keywords: [ + 'bicycle', + 'biking', + 'cyclist', + 'light skin tone', + 'man', + 'uc8', + 'sport', + 'diversity', + 'bike', + 'fame', + 'fun', + 'activity', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'bikes', + 'bicycle', + 'bicycling', + 'famous', + 'celebrity' + ], + modifiable: true), + Emoji( + name: 'man biking: medium-light skin tone', + char: '\u{1F6B4}\u{1F3FC}\u{200D}\u{2642}\u{FE0F}', + shortName: 'man_biking_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personSport, + keywords: [ + 'bicycle', + 'biking', + 'cyclist', + 'man', + 'medium-light skin tone', + 'uc8', + 'sport', + 'diversity', + 'bike', + 'fame', + 'fun', + 'activity', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'bikes', + 'bicycle', + 'bicycling', + 'famous', + 'celebrity' + ], + modifiable: true), + Emoji( + name: 'man biking: medium skin tone', + char: '\u{1F6B4}\u{1F3FD}\u{200D}\u{2642}\u{FE0F}', + shortName: 'man_biking_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personSport, + keywords: [ + 'bicycle', + 'biking', + 'cyclist', + 'man', + 'medium skin tone', + 'uc8', + 'sport', + 'diversity', + 'bike', + 'fame', + 'fun', + 'activity', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'bikes', + 'bicycle', + 'bicycling', + 'famous', + 'celebrity' + ], + modifiable: true), + Emoji( + name: 'man biking: medium-dark skin tone', + char: '\u{1F6B4}\u{1F3FE}\u{200D}\u{2642}\u{FE0F}', + shortName: 'man_biking_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personSport, + keywords: [ + 'bicycle', + 'biking', + 'cyclist', + 'man', + 'medium-dark skin tone', + 'uc8', + 'sport', + 'diversity', + 'bike', + 'fame', + 'fun', + 'activity', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'bikes', + 'bicycle', + 'bicycling', + 'famous', + 'celebrity' + ], + modifiable: true), + Emoji( + name: 'man biking: dark skin tone', + char: '\u{1F6B4}\u{1F3FF}\u{200D}\u{2642}\u{FE0F}', + shortName: 'man_biking_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personSport, + keywords: [ + 'bicycle', + 'biking', + 'cyclist', + 'dark skin tone', + 'man', + 'uc8', + 'sport', + 'diversity', + 'bike', + 'fame', + 'fun', + 'activity', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'bikes', + 'bicycle', + 'bicycling', + 'famous', + 'celebrity' + ], + modifiable: true), + Emoji( + name: 'trophy', + char: '\u{1F3C6}', + shortName: 'trophy', + emojiGroup: EmojiGroup.activities, + emojiSubgroup: EmojiSubgroup.awardMedal, + keywords: [ + 'prize', + 'uc6', + 'sport', + 'game', + 'award', + 'football', + 'soccer', + 'perfect', + 'win', + 'harry potter', + 'nerd', + 'fame', + 'activity', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'games', + 'gaming', + 'awards', + 'prize', + 'prizes', + 'trophy', + 'trophies', + 'spot', + 'best', + 'champion', + 'hero', + 'soccer ball', + 'world cup', + 'perfecto', + 'perfection', + 'superb', + 'flawless', + 'excellent', + 'supreme', + 'super', + 'great', + 'winning', + 'killing it', + 'crushing it', + 'victory', + 'victorious', + 'success', + 'successful', + 'winner', + 'smart', + 'geek', + 'serious', + 'famous', + 'celebrity' + ]), + Emoji( + name: '1st place medal', + char: '\u{1F947}', + shortName: 'first_place', + emojiGroup: EmojiGroup.activities, + emojiSubgroup: EmojiSubgroup.awardMedal, + keywords: [ + 'first', + 'gold', + 'medal', + 'uc9', + 'sport', + 'award', + 'win', + 'medal', + 'gymnast', + 'fame', + 'activity', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'awards', + 'prize', + 'prizes', + 'trophy', + 'trophies', + 'spot', + 'best', + 'champion', + 'hero', + 'winning', + 'killing it', + 'crushing it', + 'victory', + 'victorious', + 'success', + 'successful', + 'winner', + 'medals', + 'gold medal', + 'silver medal', + 'bronze medal', + 'gymnastics', + 'famous', + 'celebrity' + ]), + Emoji( + name: '2nd place medal', + char: '\u{1F948}', + shortName: 'second_place', + emojiGroup: EmojiGroup.activities, + emojiSubgroup: EmojiSubgroup.awardMedal, + keywords: [ + 'medal', + 'second', + 'silver', + 'uc9', + 'sport', + 'award', + 'win', + 'medal', + 'gymnast', + 'activity', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'awards', + 'prize', + 'prizes', + 'trophy', + 'trophies', + 'spot', + 'best', + 'champion', + 'hero', + 'winning', + 'killing it', + 'crushing it', + 'victory', + 'victorious', + 'success', + 'successful', + 'winner', + 'medals', + 'gold medal', + 'silver medal', + 'bronze medal', + 'gymnastics' + ]), + Emoji( + name: '3rd place medal', + char: '\u{1F949}', + shortName: 'third_place', + emojiGroup: EmojiGroup.activities, + emojiSubgroup: EmojiSubgroup.awardMedal, + keywords: [ + 'bronze', + 'medal', + 'third', + 'uc9', + 'sport', + 'award', + 'win', + 'medal', + 'gymnast', + 'activity', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'awards', + 'prize', + 'prizes', + 'trophy', + 'trophies', + 'spot', + 'best', + 'champion', + 'hero', + 'winning', + 'killing it', + 'crushing it', + 'victory', + 'victorious', + 'success', + 'successful', + 'winner', + 'medals', + 'gold medal', + 'silver medal', + 'bronze medal', + 'gymnastics' + ]), + Emoji( + name: 'sports medal', + char: '\u{1F3C5}', + shortName: 'medal', + emojiGroup: EmojiGroup.activities, + emojiSubgroup: EmojiSubgroup.awardMedal, + keywords: [ + 'medal', + 'uc7', + 'sport', + 'award', + 'perfect', + 'win', + 'medal', + 'gymnast', + 'activity', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'awards', + 'prize', + 'prizes', + 'trophy', + 'trophies', + 'spot', + 'best', + 'champion', + 'hero', + 'perfecto', + 'perfection', + 'superb', + 'flawless', + 'excellent', + 'supreme', + 'super', + 'great', + 'winning', + 'killing it', + 'crushing it', + 'victory', + 'victorious', + 'success', + 'successful', + 'winner', + 'medals', + 'gold medal', + 'silver medal', + 'bronze medal', + 'gymnastics' + ]), + Emoji( + name: 'military medal', + char: '\u{1F396}\u{FE0F}', + shortName: 'military_medal', + emojiGroup: EmojiGroup.activities, + emojiSubgroup: EmojiSubgroup.awardMedal, + keywords: [ + 'celebration', + 'medal', + 'military', + 'uc7', + 'award', + 'medal', + 'gymnast', + 'activity', + 'awards', + 'prize', + 'prizes', + 'trophy', + 'trophies', + 'spot', + 'best', + 'champion', + 'hero', + 'medals', + 'gold medal', + 'silver medal', + 'bronze medal', + 'gymnastics' + ]), + Emoji( + name: 'rosette', + char: '\u{1F3F5}\u{FE0F}', + shortName: 'rosette', + emojiGroup: EmojiGroup.animalsNature, + emojiSubgroup: EmojiSubgroup.plantFlower, + keywords: ['plant', 'uc7', 'tropical', 'activity']), + Emoji( + name: 'reminder ribbon', + char: '\u{1F397}\u{FE0F}', + shortName: 'reminder_ribbon', + emojiGroup: EmojiGroup.activities, + emojiSubgroup: EmojiSubgroup.event, + keywords: [ + 'celebration', + 'reminder', + 'ribbon', + 'uc7', + 'award', + 'hope', + 'activity', + 'important', + 'awards', + 'prize', + 'prizes', + 'trophy', + 'trophies', + 'spot', + 'best', + 'champion', + 'hero', + 'swear', + 'promise' + ]), + Emoji( + name: 'ticket', + char: '\u{1F3AB}', + shortName: 'ticket', + emojiGroup: EmojiGroup.activities, + emojiSubgroup: EmojiSubgroup.event, + keywords: [ + 'admission', + 'uc6', + 'theatre', + 'instruments', + 'movie', + 'amusement park', + 'circus', + 'pink', + 'disney', + 'discount', + 'las vegas', + 'activity', + 'opera', + 'theater', + 'craft', + 'drama', + 'monet', + 'music', + 'instrument', + 'singing', + 'concert', + 'jaz', + 'listen', + 'singer', + 'song', + 'musique', + 'movies', + 'cinema', + 'film', + 'films', + 'video', + 'videos', + 'theme park', + 'circus tent', + 'clown', + 'clowns', + 'rose', + 'cartoon', + 'sale', + 'bargain', + 'vegas' + ]), + Emoji( + name: 'admission tickets', + char: '\u{1F39F}\u{FE0F}', + shortName: 'tickets', + emojiGroup: EmojiGroup.activities, + emojiSubgroup: EmojiSubgroup.event, + keywords: [ + 'admission', + 'ticket', + 'uc7', + 'theatre', + 'instruments', + 'movie', + 'amusement park', + 'circus', + 'disney', + 'activity', + 'theater', + 'craft', + 'drama', + 'monet', + 'music', + 'instrument', + 'singing', + 'concert', + 'jaz', + 'listen', + 'singer', + 'song', + 'musique', + 'movies', + 'cinema', + 'film', + 'films', + 'video', + 'videos', + 'theme park', + 'circus tent', + 'clown', + 'clowns', + 'cartoon' + ]), + Emoji( + name: 'circus tent', + char: '\u{1F3AA}', + shortName: 'circus_tent', + emojiGroup: EmojiGroup.travelPlaces, + emojiSubgroup: EmojiSubgroup.placeOther, + keywords: [ + 'circus', + 'tent', + 'uc6', + 'amusement park', + 'circus', + 'magic', + 'activity', + 'independence day', + 'opera', + 'theme park', + 'circus tent', + 'clown', + 'clowns', + 'spell', + 'genie', + 'magical', + '4th of july' + ]), + Emoji( + name: 'person juggling', + char: '\u{1F939}', + shortName: 'person_juggling', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personSport, + keywords: [ + 'balance', + 'juggle', + 'multitask', + 'skill', + 'uc9', + 'diversity', + 'ball', + 'circus', + 'throw', + 'activity', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'balls', + 'ballon', + 'circus tent', + 'clown', + 'clowns' + ]), + Emoji( + name: 'person juggling: light skin tone', + char: '\u{1F939}\u{1F3FB}', + shortName: 'person_juggling_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personSport, + keywords: [ + 'balance', + 'juggle', + 'light skin tone', + 'multitask', + 'skill', + 'uc9', + 'diversity', + 'ball', + 'circus', + 'throw', + 'activity', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'balls', + 'ballon', + 'circus tent', + 'clown', + 'clowns' + ], + modifiable: true), + Emoji( + name: 'person juggling: medium-light skin tone', + char: '\u{1F939}\u{1F3FC}', + shortName: 'person_juggling_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personSport, + keywords: [ + 'balance', + 'juggle', + 'medium-light skin tone', + 'multitask', + 'skill', + 'uc9', + 'diversity', + 'ball', + 'circus', + 'throw', + 'activity', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'balls', + 'ballon', + 'circus tent', + 'clown', + 'clowns' + ], + modifiable: true), + Emoji( + name: 'person juggling: medium skin tone', + char: '\u{1F939}\u{1F3FD}', + shortName: 'person_juggling_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personSport, + keywords: [ + 'balance', + 'juggle', + 'medium skin tone', + 'multitask', + 'skill', + 'uc9', + 'diversity', + 'ball', + 'circus', + 'throw', + 'activity', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'balls', + 'ballon', + 'circus tent', + 'clown', + 'clowns' + ], + modifiable: true), + Emoji( + name: 'person juggling: medium-dark skin tone', + char: '\u{1F939}\u{1F3FE}', + shortName: 'person_juggling_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personSport, + keywords: [ + 'balance', + 'juggle', + 'medium-dark skin tone', + 'multitask', + 'skill', + 'uc9', + 'diversity', + 'ball', + 'circus', + 'throw', + 'activity', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'balls', + 'ballon', + 'circus tent', + 'clown', + 'clowns' + ], + modifiable: true), + Emoji( + name: 'person juggling: dark skin tone', + char: '\u{1F939}\u{1F3FF}', + shortName: 'person_juggling_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personSport, + keywords: [ + 'balance', + 'dark skin tone', + 'juggle', + 'multitask', + 'skill', + 'uc9', + 'diversity', + 'ball', + 'circus', + 'throw', + 'activity', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'balls', + 'ballon', + 'circus tent', + 'clown', + 'clowns' + ], + modifiable: true), + Emoji( + name: 'woman juggling', + char: '\u{1F939}\u{200D}\u{2640}\u{FE0F}', + shortName: 'woman_juggling', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personSport, + keywords: [ + 'juggling', + 'multitask', + 'woman', + 'uc9', + 'diversity', + 'ball', + 'circus', + 'throw', + 'activity', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'balls', + 'ballon', + 'circus tent', + 'clown', + 'clowns' + ]), + Emoji( + name: 'woman juggling: light skin tone', + char: '\u{1F939}\u{1F3FB}\u{200D}\u{2640}\u{FE0F}', + shortName: 'woman_juggling_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personSport, + keywords: [ + 'juggling', + 'light skin tone', + 'multitask', + 'woman', + 'uc9', + 'diversity', + 'ball', + 'circus', + 'throw', + 'activity', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'balls', + 'ballon', + 'circus tent', + 'clown', + 'clowns' + ], + modifiable: true), + Emoji( + name: 'woman juggling: medium-light skin tone', + char: '\u{1F939}\u{1F3FC}\u{200D}\u{2640}\u{FE0F}', + shortName: 'woman_juggling_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personSport, + keywords: [ + 'juggling', + 'medium-light skin tone', + 'multitask', + 'woman', + 'uc9', + 'diversity', + 'ball', + 'circus', + 'throw', + 'activity', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'balls', + 'ballon', + 'circus tent', + 'clown', + 'clowns' + ], + modifiable: true), + Emoji( + name: 'woman juggling: medium skin tone', + char: '\u{1F939}\u{1F3FD}\u{200D}\u{2640}\u{FE0F}', + shortName: 'woman_juggling_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personSport, + keywords: [ + 'juggling', + 'medium skin tone', + 'multitask', + 'woman', + 'uc9', + 'diversity', + 'ball', + 'circus', + 'throw', + 'activity', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'balls', + 'ballon', + 'circus tent', + 'clown', + 'clowns' + ], + modifiable: true), + Emoji( + name: 'woman juggling: medium-dark skin tone', + char: '\u{1F939}\u{1F3FE}\u{200D}\u{2640}\u{FE0F}', + shortName: 'woman_juggling_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personSport, + keywords: [ + 'juggling', + 'medium-dark skin tone', + 'multitask', + 'woman', + 'uc9', + 'diversity', + 'ball', + 'circus', + 'throw', + 'activity', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'balls', + 'ballon', + 'circus tent', + 'clown', + 'clowns' + ], + modifiable: true), + Emoji( + name: 'woman juggling: dark skin tone', + char: '\u{1F939}\u{1F3FF}\u{200D}\u{2640}\u{FE0F}', + shortName: 'woman_juggling_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personSport, + keywords: [ + 'dark skin tone', + 'juggling', + 'multitask', + 'woman', + 'uc9', + 'diversity', + 'ball', + 'circus', + 'throw', + 'activity', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'balls', + 'ballon', + 'circus tent', + 'clown', + 'clowns' + ], + modifiable: true), + Emoji( + name: 'man juggling', + char: '\u{1F939}\u{200D}\u{2642}\u{FE0F}', + shortName: 'man_juggling', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personSport, + keywords: [ + 'juggling', + 'man', + 'multitask', + 'uc9', + 'diversity', + 'ball', + 'circus', + 'throw', + 'activity', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'balls', + 'ballon', + 'circus tent', + 'clown', + 'clowns' + ]), + Emoji( + name: 'man juggling: light skin tone', + char: '\u{1F939}\u{1F3FB}\u{200D}\u{2642}\u{FE0F}', + shortName: 'man_juggling_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personSport, + keywords: [ + 'juggling', + 'light skin tone', + 'man', + 'multitask', + 'uc9', + 'diversity', + 'ball', + 'circus', + 'throw', + 'activity', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'balls', + 'ballon', + 'circus tent', + 'clown', + 'clowns' + ], + modifiable: true), + Emoji( + name: 'man juggling: medium-light skin tone', + char: '\u{1F939}\u{1F3FC}\u{200D}\u{2642}\u{FE0F}', + shortName: 'man_juggling_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personSport, + keywords: [ + 'juggling', + 'man', + 'medium-light skin tone', + 'multitask', + 'uc9', + 'diversity', + 'ball', + 'circus', + 'throw', + 'activity', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'balls', + 'ballon', + 'circus tent', + 'clown', + 'clowns' + ], + modifiable: true), + Emoji( + name: 'man juggling: medium skin tone', + char: '\u{1F939}\u{1F3FD}\u{200D}\u{2642}\u{FE0F}', + shortName: 'man_juggling_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personSport, + keywords: [ + 'juggling', + 'man', + 'medium skin tone', + 'multitask', + 'uc9', + 'diversity', + 'ball', + 'circus', + 'throw', + 'activity', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'balls', + 'ballon', + 'circus tent', + 'clown', + 'clowns' + ], + modifiable: true), + Emoji( + name: 'man juggling: medium-dark skin tone', + char: '\u{1F939}\u{1F3FE}\u{200D}\u{2642}\u{FE0F}', + shortName: 'man_juggling_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personSport, + keywords: [ + 'juggling', + 'man', + 'medium-dark skin tone', + 'multitask', + 'uc9', + 'diversity', + 'ball', + 'circus', + 'throw', + 'activity', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'balls', + 'ballon', + 'circus tent', + 'clown', + 'clowns' + ], + modifiable: true), + Emoji( + name: 'man juggling: dark skin tone', + char: '\u{1F939}\u{1F3FF}\u{200D}\u{2642}\u{FE0F}', + shortName: 'man_juggling_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personSport, + keywords: [ + 'dark skin tone', + 'juggling', + 'man', + 'multitask', + 'uc9', + 'diversity', + 'ball', + 'circus', + 'throw', + 'activity', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'balls', + 'ballon', + 'circus tent', + 'clown', + 'clowns' + ], + modifiable: true), + Emoji( + name: 'performing arts', + char: '\u{1F3AD}', + shortName: 'performing_arts', + emojiGroup: EmojiGroup.activities, + emojiSubgroup: EmojiSubgroup.artsCrafts, + keywords: [ + 'art', + 'mask', + 'performing', + 'theater', + 'theatre', + 'uc6', + 'theatre', + 'halloween', + 'movie', + 'circus', + 'girls night', + 'play', + 'fame', + 'las vegas', + 'fun', + 'activity', + 'mask', + 'fantasy', + 'opera', + 'theater', + 'craft', + 'drama', + 'monet', + 'samhain', + 'movies', + 'cinema', + 'film', + 'films', + 'video', + 'videos', + 'circus tent', + 'clown', + 'clowns', + 'ladies night', + 'girls only', + 'girlfriend', + 'famous', + 'celebrity', + 'vegas' + ]), + Emoji( + name: 'ballet shoes', + char: '\u{1FA70}', + shortName: 'ballet_shoes', + emojiGroup: EmojiGroup.objects, + emojiSubgroup: EmojiSubgroup.clothing, + keywords: [ + 'uc12', + 'shoe', + 'dance', + 'vintage', + 'activity', + 'opera', + 'shoes', + 'baskets', + 'loafers', + 'sandals', + 'pumps', + 'boots', + 'heels', + 'dancers', + 'dancing', + 'ballet', + 'ballerina', + 'dabbing', + 'salsa' + ]), + Emoji( + name: 'artist palette', + char: '\u{1F3A8}', + shortName: 'art', + emojiGroup: EmojiGroup.activities, + emojiSubgroup: EmojiSubgroup.artsCrafts, + keywords: [ + 'art', + 'museum', + 'painting', + 'palette', + 'uc6', + 'theatre', + 'painting', + 'color', + 'instagram', + 'fun', + 'activity', + 'theater', + 'craft', + 'drama', + 'monet', + 'painter', + 'arts', + 'colour', + 'coloring', + 'colouring', + 'drawing', + 'marker', + 'sketch' + ]), + Emoji( + name: 'clapper board', + char: '\u{1F3AC}', + shortName: 'clapper', + emojiGroup: EmojiGroup.objects, + emojiSubgroup: EmojiSubgroup.lightVideo, + keywords: [ + 'clapper', + 'movie', + 'uc6', + 'movie', + 'disney', + 'california', + 'fame', + 'activity', + 'movies', + 'cinema', + 'film', + 'films', + 'video', + 'videos', + 'cartoon', + 'famous', + 'celebrity' + ]), + Emoji( + name: 'microphone', + char: '\u{1F3A4}', + shortName: 'microphone', + emojiGroup: EmojiGroup.objects, + emojiSubgroup: EmojiSubgroup.music, + keywords: [ + 'karaoke', + 'mic', + 'uc6', + 'instruments', + 'rock and roll', + 'disco', + 'fame', + 'activity', + 'music', + 'instrument', + 'singing', + 'concert', + 'jaz', + 'listen', + 'singer', + 'song', + 'musique', + 'famous', + 'celebrity' + ]), + Emoji( + name: 'headphone', + char: '\u{1F3A7}', + shortName: 'headphones', + emojiGroup: EmojiGroup.objects, + emojiSubgroup: EmojiSubgroup.music, + keywords: [ + 'earbud', + 'uc6', + 'instruments', + 'headphones', + 'rock and roll', + 'earphone', + 'activity', + 'music', + 'instrument', + 'singing', + 'concert', + 'jaz', + 'listen', + 'singer', + 'song', + 'musique', + 'headphone', + 'head phones', + 'casque', + 'earbud' + ]), + Emoji( + name: 'musical score', + char: '\u{1F3BC}', + shortName: 'musical_score', + emojiGroup: EmojiGroup.objects, + emojiSubgroup: EmojiSubgroup.music, + keywords: [ + 'music', + 'score', + 'uc6', + 'instruments', + 'piano', + 'rock and roll', + 'disco', + 'activity', + 'music', + 'instrument', + 'singing', + 'concert', + 'jaz', + 'listen', + 'singer', + 'song', + 'musique' + ]), + Emoji( + name: 'musical keyboard', + char: '\u{1F3B9}', + shortName: 'musical_keyboard', + emojiGroup: EmojiGroup.objects, + emojiSubgroup: EmojiSubgroup.musicalInstrument, + keywords: [ + 'instrument', + 'keyboard', + 'music', + 'piano', + 'uc6', + 'instruments', + 'play', + 'keyboard', + 'piano', + 'rock and roll', + 'activity', + 'music', + 'instrument', + 'singing', + 'concert', + 'jaz', + 'listen', + 'singer', + 'song', + 'musique', + 'keyboards' + ]), + Emoji( + name: 'drum', + char: '\u{1F941}', + shortName: 'drum', + emojiGroup: EmojiGroup.objects, + emojiSubgroup: EmojiSubgroup.musicalInstrument, + keywords: [ + 'drum', + 'drumsticks', + 'music', + 'uc9', + 'instruments', + 'play', + 'rock and roll', + 'activity', + 'toy', + 'music', + 'instrument', + 'singing', + 'concert', + 'jaz', + 'listen', + 'singer', + 'song', + 'musique' + ]), + Emoji( + name: 'long drum', + char: '\u{1FA98}', + shortName: 'long_drum', + emojiGroup: EmojiGroup.objects, + emojiSubgroup: EmojiSubgroup.musicalInstrument, + keywords: [ + 'uc13', + 'instruments', + 'play', + 'activity', + 'music', + 'instrument', + 'singing', + 'concert', + 'jaz', + 'listen', + 'singer', + 'song', + 'musique' + ]), + Emoji( + name: 'saxophone', + char: '\u{1F3B7}', + shortName: 'saxophone', + emojiGroup: EmojiGroup.objects, + emojiSubgroup: EmojiSubgroup.musicalInstrument, + keywords: [ + 'instrument', + 'music', + 'sax', + 'uc6', + 'instruments', + 'play', + 'activity', + 'music', + 'instrument', + 'singing', + 'concert', + 'jaz', + 'listen', + 'singer', + 'song', + 'musique' + ]), + Emoji( + name: 'trumpet', + char: '\u{1F3BA}', + shortName: 'trumpet', + emojiGroup: EmojiGroup.objects, + emojiSubgroup: EmojiSubgroup.musicalInstrument, + keywords: [ + 'instrument', + 'music', + 'uc6', + 'instruments', + 'play', + 'activity', + 'independence day', + 'music', + 'instrument', + 'singing', + 'concert', + 'jaz', + 'listen', + 'singer', + 'song', + 'musique', + '4th of july' + ]), + Emoji( + name: 'guitar', + char: '\u{1F3B8}', + shortName: 'guitar', + emojiGroup: EmojiGroup.objects, + emojiSubgroup: EmojiSubgroup.musicalInstrument, + keywords: [ + 'instrument', + 'music', + 'uc6', + 'instruments', + 'mexican', + 'play', + 'rock and roll', + 'activity', + 'guitarra', + 'stringed', + 'music', + 'instrument', + 'singing', + 'concert', + 'jaz', + 'listen', + 'singer', + 'song', + 'musique', + 'mexico', + 'cinco de mayo', + 'español', + 'guitare', + 'gitarre', + 'chitarra', + 'bangio' + ]), + Emoji( + name: 'banjo', + char: '\u{1FA95}', + shortName: 'banjo', + emojiGroup: EmojiGroup.objects, + emojiSubgroup: EmojiSubgroup.musicalInstrument, + keywords: [ + 'uc12', + 'instruments', + 'play', + 'activity', + 'toy', + 'guitarra', + 'stringed', + 'music', + 'instrument', + 'singing', + 'concert', + 'jaz', + 'listen', + 'singer', + 'song', + 'musique', + 'guitare', + 'gitarre', + 'chitarra', + 'bangio' + ]), + Emoji( + name: 'violin', + char: '\u{1F3BB}', + shortName: 'violin', + emojiGroup: EmojiGroup.objects, + emojiSubgroup: EmojiSubgroup.musicalInstrument, + keywords: [ + 'instrument', + 'music', + 'uc6', + 'instruments', + 'sarcastic', + 'play', + 'activity', + 'stringed', + 'music', + 'instrument', + 'singing', + 'concert', + 'jaz', + 'listen', + 'singer', + 'song', + 'musique', + 'sarcasm' + ]), + Emoji( + name: 'accordion', + char: '\u{1FA97}', + shortName: 'accordion', + emojiGroup: EmojiGroup.objects, + emojiSubgroup: EmojiSubgroup.musicalInstrument, + keywords: [ + 'uc13', + 'instruments', + 'play', + 'keyboard', + 'activity', + 'squeezebox', + 'music', + 'instrument', + 'singing', + 'concert', + 'jaz', + 'listen', + 'singer', + 'song', + 'musique', + 'keyboards', + 'akkordeon', + 'bellows', + 'aerophone' + ]), + Emoji( + name: 'game die', + char: '\u{1F3B2}', + shortName: 'game_die', + emojiGroup: EmojiGroup.activities, + emojiSubgroup: EmojiSubgroup.game, + keywords: [ + 'dice', + 'die', + 'game', + 'uc6', + 'game', + 'boys night', + 'play', + 'bingo', + 'las vegas', + 'dice', + 'fun', + 'activity', + 'toy', + 'games', + 'gaming', + 'guys night', + 'vegas' + ]), + Emoji( + name: 'chess pawn', + char: '\u{265F}\u{FE0F}', + shortName: 'chess_pawn', + emojiGroup: EmojiGroup.activities, + emojiSubgroup: EmojiSubgroup.game, + keywords: ['uc1', 'game', 'play', 'fun', 'activity', 'games', 'gaming']), + Emoji( + name: 'direct hit', + char: '\u{1F3AF}', + shortName: 'dart', + emojiGroup: EmojiGroup.activities, + emojiSubgroup: EmojiSubgroup.game, + keywords: [ + 'bull', + 'bullseye', + 'dart', + 'eye', + 'game', + 'hit', + 'target', + 'uc6', + 'sport', + 'game', + 'boys night', + 'play', + 'target', + 'fun', + 'activity', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'games', + 'gaming', + 'guys night' + ]), + Emoji( + name: 'bowling', + char: '\u{1F3B3}', + shortName: 'bowling', + emojiGroup: EmojiGroup.activities, + emojiSubgroup: EmojiSubgroup.sport, + keywords: [ + 'ball', + 'game', + 'uc6', + 'sport', + 'game', + 'ball', + 'boys night', + 'play', + 'fun', + 'throw', + 'activity', + 'sports', + 'exercise', + 'athlete', + 'athletes', + 'athletic', + 'team', + 'fitness', + 'work out', + 'workout', + 'games', + 'gaming', + 'balls', + 'ballon', + 'guys night' + ]), + Emoji( + name: 'video game', + char: '\u{1F3AE}', + shortName: 'video_game', + emojiGroup: EmojiGroup.activities, + emojiSubgroup: EmojiSubgroup.game, + keywords: [ + 'controller', + 'game', + 'uc6', + 'electronics', + 'game', + 'boys night', + 'play', + 'controller', + 'fun', + 'activity', + 'games', + 'gaming', + 'guys night', + 'remote' + ]), + Emoji( + name: 'slot machine', + char: '\u{1F3B0}', + shortName: 'slot_machine', + emojiGroup: EmojiGroup.activities, + emojiSubgroup: EmojiSubgroup.game, + keywords: [ + 'game', + 'slot', + 'uc6', + 'game', + 'boys night', + 'play', + 'bingo', + 'las vegas', + 'fun', + 'activity', + 'games', + 'gaming', + 'guys night', + 'vegas' + ]), + Emoji( + name: 'puzzle piece', + char: '\u{1F9E9}', + shortName: 'jigsaw', + emojiGroup: EmojiGroup.activities, + emojiSubgroup: EmojiSubgroup.game, + keywords: [ + 'uc11', + 'game', + 'play', + 'fun', + 'puzzle', + 'activity', + 'household', + 'toy', + 'question', + 'games', + 'gaming', + 'quiz', + 'puzzled' + ]), + Emoji( + name: 'automobile', + char: '\u{1F697}', + shortName: 'red_car', + emojiGroup: EmojiGroup.travelPlaces, + emojiSubgroup: EmojiSubgroup.transportGround, + keywords: [ + 'car', + 'uc6', + 'transportation', + 'car', + 'travel', + 'toy', + 'cars', + 'vehicle', + 'fast car', + 'drive', + 'driving', + 'auto' + ]), + Emoji( + name: 'taxi', + char: '\u{1F695}', + shortName: 'taxi', + emojiGroup: EmojiGroup.travelPlaces, + emojiSubgroup: EmojiSubgroup.transportGround, + keywords: [ + 'vehicle', + 'uc6', + 'transportation', + 'car', + 'travel', + 'cars', + 'vehicle', + 'fast car', + 'drive', + 'driving', + 'auto' + ]), + Emoji( + name: 'sport utility vehicle', + char: '\u{1F699}', + shortName: 'blue_car', + emojiGroup: EmojiGroup.travelPlaces, + emojiSubgroup: EmojiSubgroup.transportGround, + keywords: [ + 'recreational', + 'sport utility', + 'uc6', + 'transportation', + 'car', + 'travel', + 'cars', + 'vehicle', + 'fast car', + 'drive', + 'driving', + 'auto' + ]), + Emoji( + name: 'pickup truck', + char: '\u{1F6FB}', + shortName: 'pickup_truck', + emojiGroup: EmojiGroup.travelPlaces, + emojiSubgroup: EmojiSubgroup.transportGround, + keywords: [ + 'uc13', + 'transportation', + 'car', + 'truck', + 'travel', + 'cars', + 'vehicle', + 'fast car', + 'drive', + 'driving', + 'auto', + 'trucks' + ]), + Emoji( + name: 'bus', + char: '\u{1F68C}', + shortName: 'bus', + emojiGroup: EmojiGroup.travelPlaces, + emojiSubgroup: EmojiSubgroup.transportGround, + keywords: [ + 'vehicle', + 'uc6', + 'transportation', + 'bus', + 'classroom', + 'travel', + 'vacation', + 'buses', + 'school', + 'teach', + 'learn', + 'study', + 'college', + 'degree', + 'education', + 'homework', + 'student', + 'teacher', + 'university', + 'test', + 'learning' + ]), + Emoji( + name: 'trolleybus', + char: '\u{1F68E}', + shortName: 'trolleybus', + emojiGroup: EmojiGroup.travelPlaces, + emojiSubgroup: EmojiSubgroup.transportGround, + keywords: [ + 'bus', + 'tram', + 'trolley', + 'uc6', + 'transportation', + 'bus', + 'travel', + 'buses' + ]), + Emoji( + name: 'racing car', + char: '\u{1F3CE}\u{FE0F}', + shortName: 'race_car', + emojiGroup: EmojiGroup.travelPlaces, + emojiSubgroup: EmojiSubgroup.transportGround, + keywords: [ + 'car', + 'racing', + 'uc7', + 'transportation', + 'car', + 'disney', + 'fun', + 'rich', + 'cars', + 'vehicle', + 'fast car', + 'drive', + 'driving', + 'auto', + 'cartoon', + 'grand', + 'expensive', + 'fancy' + ]), + Emoji( + name: 'police car', + char: '\u{1F693}', + shortName: 'police_car', + emojiGroup: EmojiGroup.travelPlaces, + emojiSubgroup: EmojiSubgroup.transportGround, + keywords: [ + 'car', + 'patrol', + 'police', + 'uc6', + 'transportation', + 'car', + 'police', + '911', + 'sirens', + 'help', + 'cars', + 'vehicle', + 'fast car', + 'drive', + 'driving', + 'auto', + 'cop', + 'policeman', + 'popo', + 'prison', + 'handcuff', + 'jail', + 'justice', + 'emergency', + 'injury', + 'switch' + ]), + Emoji( + name: 'ambulance', + char: '\u{1F691}', + shortName: 'ambulance', + emojiGroup: EmojiGroup.travelPlaces, + emojiSubgroup: EmojiSubgroup.transportGround, + keywords: [ + 'vehicle', + 'uc6', + 'transportation', + '911', + 'sirens', + 'help', + 'poison', + 'covid', + 'emergency', + 'injury', + 'switch', + 'toxic', + 'toxins' + ]), + Emoji( + name: 'fire engine', + char: '\u{1F692}', + shortName: 'fire_engine', + emojiGroup: EmojiGroup.travelPlaces, + emojiSubgroup: EmojiSubgroup.transportGround, + keywords: [ + 'engine', + 'fire', + 'truck', + 'uc6', + 'transportation', + 'truck', + '911', + 'sirens', + 'help', + 'fires', + 'trucks', + 'emergency', + 'injury', + 'switch' + ]), + Emoji( + name: 'minibus', + char: '\u{1F690}', + shortName: 'minibus', + emojiGroup: EmojiGroup.travelPlaces, + emojiSubgroup: EmojiSubgroup.transportGround, + keywords: [ + 'bus', + 'uc6', + 'transportation', + 'bus', + 'travel', + 'camp', + 'vacation', + 'buses', + 'camping', + 'tent', + 'camper', + 'outdoor', + 'outside' + ]), + Emoji( + name: 'delivery truck', + char: '\u{1F69A}', + shortName: 'truck', + emojiGroup: EmojiGroup.travelPlaces, + emojiSubgroup: EmojiSubgroup.transportGround, + keywords: [ + 'delivery', + 'truck', + 'uc6', + 'transportation', + 'truck', + 'moving', + 'trucks' + ]), + Emoji( + name: 'articulated lorry', + char: '\u{1F69B}', + shortName: 'articulated_lorry', + emojiGroup: EmojiGroup.travelPlaces, + emojiSubgroup: EmojiSubgroup.transportGround, + keywords: [ + 'lorry', + 'semi', + 'truck', + 'uc6', + 'transportation', + 'truck', + 'moving', + 'trucks' + ]), + Emoji( + name: 'tractor', + char: '\u{1F69C}', + shortName: 'tractor', + emojiGroup: EmojiGroup.travelPlaces, + emojiSubgroup: EmojiSubgroup.transportGround, + keywords: ['vehicle', 'uc6', 'transportation', 'farm']), + Emoji( + name: 'white cane', + char: '\u{1F9AF}', + shortName: 'probing_cane', + emojiGroup: EmojiGroup.objects, + emojiSubgroup: EmojiSubgroup.tool, + keywords: [ + 'uc12', + 'transportation', + 'cane', + 'handicap', + 'navigate', + 'blind', + 'probe', + 'accessibility', + 'disabled', + 'disability', + 'white cane' + ]), + Emoji( + name: 'manual wheelchair', + char: '\u{1F9BD}', + shortName: 'manual_wheelchair', + emojiGroup: EmojiGroup.travelPlaces, + emojiSubgroup: EmojiSubgroup.transportGround, + keywords: [ + 'uc12', + 'transportation', + 'handicap', + 'accessibility', + 'disabled', + 'disability' + ]), + Emoji( + name: 'motorized wheelchair', + char: '\u{1F9BC}', + shortName: 'motorized_wheelchair', + emojiGroup: EmojiGroup.travelPlaces, + emojiSubgroup: EmojiSubgroup.transportGround, + keywords: [ + 'uc12', + 'transportation', + 'handicap', + 'accessibility', + 'disabled', + 'disability' + ]), + Emoji( + name: 'kick scooter', + char: '\u{1F6F4}', + shortName: 'scooter', + emojiGroup: EmojiGroup.travelPlaces, + emojiSubgroup: EmojiSubgroup.transportGround, + keywords: ['kick', 'scooter', 'uc9', 'transportation']), + Emoji( + name: 'bicycle', + char: '\u{1F6B2}', + shortName: 'bike', + emojiGroup: EmojiGroup.travelPlaces, + emojiSubgroup: EmojiSubgroup.transportGround, + keywords: [ + 'bike', + 'uc6', + 'transportation', + 'bike', + 'travel', + 'bikes', + 'bicycle', + 'bicycling' + ]), + Emoji( + name: 'motor scooter', + char: '\u{1F6F5}', + shortName: 'motor_scooter', + emojiGroup: EmojiGroup.travelPlaces, + emojiSubgroup: EmojiSubgroup.transportGround, + keywords: [ + 'motor', + 'scooter', + 'uc9', + 'transportation', + 'travel', + 'thai', + 'pattaya' + ]), + Emoji( + name: 'motorcycle', + char: '\u{1F3CD}\u{FE0F}', + shortName: 'motorcycle', + emojiGroup: EmojiGroup.travelPlaces, + emojiSubgroup: EmojiSubgroup.transportGround, + keywords: [ + 'racing', + 'uc7', + 'transportation', + 'bike', + 'travel', + 'super hero', + 'fun', + 'bikes', + 'bicycle', + 'bicycling', + 'superhero', + 'superman', + 'batman' + ]), + Emoji( + name: 'auto rickshaw', + char: '\u{1F6FA}', + shortName: 'auto_rickshaw', + emojiGroup: EmojiGroup.travelPlaces, + emojiSubgroup: EmojiSubgroup.transportGround, + keywords: [ + 'uc12', + 'transportation', + 'car', + 'travel', + 'vacation', + 'thai', + 'chinese', + 'cart', + 'tuk tuk', + 'cars', + 'vehicle', + 'fast car', + 'drive', + 'driving', + 'auto', + 'pattaya', + 'chinois', + 'asian', + 'chine', + 'pedicab', + 'trishaw', + 'jinrikisha' + ]), + Emoji( + name: 'police car light', + char: '\u{1F6A8}', + shortName: 'rotating_light', + emojiGroup: EmojiGroup.travelPlaces, + emojiSubgroup: EmojiSubgroup.transportGround, + keywords: [ + 'beacon', + 'car', + 'light', + 'police', + 'revolving', + 'uc6', + 'transportation', + 'police', + '911', + 'sirens', + 'help', + 'cop', + 'policeman', + 'popo', + 'prison', + 'handcuff', + 'jail', + 'justice', + 'emergency', + 'injury', + 'switch' + ]), + Emoji( + name: 'oncoming police car', + char: '\u{1F694}', + shortName: 'oncoming_police_car', + emojiGroup: EmojiGroup.travelPlaces, + emojiSubgroup: EmojiSubgroup.transportGround, + keywords: [ + 'car', + 'oncoming', + 'police', + 'uc6', + 'transportation', + 'car', + 'police', + '911', + 'sirens', + 'help', + 'cars', + 'vehicle', + 'fast car', + 'drive', + 'driving', + 'auto', + 'cop', + 'policeman', + 'popo', + 'prison', + 'handcuff', + 'jail', + 'justice', + 'emergency', + 'injury', + 'switch' + ]), + Emoji( + name: 'oncoming bus', + char: '\u{1F68D}', + shortName: 'oncoming_bus', + emojiGroup: EmojiGroup.travelPlaces, + emojiSubgroup: EmojiSubgroup.transportGround, + keywords: [ + 'bus', + 'oncoming', + 'uc6', + 'transportation', + 'bus', + 'travel', + 'buses' + ]), + Emoji( + name: 'oncoming automobile', + char: '\u{1F698}', + shortName: 'oncoming_automobile', + emojiGroup: EmojiGroup.travelPlaces, + emojiSubgroup: EmojiSubgroup.transportGround, + keywords: [ + 'automobile', + 'car', + 'oncoming', + 'uc6', + 'transportation', + 'car', + 'travel', + 'cars', + 'vehicle', + 'fast car', + 'drive', + 'driving', + 'auto' + ]), + Emoji( + name: 'oncoming taxi', + char: '\u{1F696}', + shortName: 'oncoming_taxi', + emojiGroup: EmojiGroup.travelPlaces, + emojiSubgroup: EmojiSubgroup.transportGround, + keywords: [ + 'oncoming', + 'taxi', + 'uc6', + 'transportation', + 'car', + 'travel', + 'cars', + 'vehicle', + 'fast car', + 'drive', + 'driving', + 'auto' + ]), + Emoji( + name: 'aerial tramway', + char: '\u{1F6A1}', + shortName: 'aerial_tramway', + emojiGroup: EmojiGroup.travelPlaces, + emojiSubgroup: EmojiSubgroup.transportAir, + keywords: [ + 'aerial', + 'cable', + 'car', + 'gondola', + 'tramway', + 'uc6', + 'transportation', + 'train', + 'travel', + 'disney', + 'trains', + 'cartoon' + ]), + Emoji( + name: 'mountain cableway', + char: '\u{1F6A0}', + shortName: 'mountain_cableway', + emojiGroup: EmojiGroup.travelPlaces, + emojiSubgroup: EmojiSubgroup.transportAir, + keywords: [ + 'cable', + 'gondola', + 'mountain', + 'uc6', + 'transportation', + 'train', + 'travel', + 'skiing', + 'snowboarding', + 'trains', + 'ski', + 'snow skiing', + 'ski boot', + 'snowboarder' + ]), + Emoji( + name: 'suspension railway', + char: '\u{1F69F}', + shortName: 'suspension_railway', + emojiGroup: EmojiGroup.travelPlaces, + emojiSubgroup: EmojiSubgroup.transportAir, + keywords: [ + 'railway', + 'suspension', + 'uc6', + 'transportation', + 'train', + 'travel', + 'trains' + ]), + Emoji( + name: 'railway car', + char: '\u{1F683}', + shortName: 'railway_car', + emojiGroup: EmojiGroup.travelPlaces, + emojiSubgroup: EmojiSubgroup.transportGround, + keywords: [ + 'car', + 'electric', + 'railway', + 'train', + 'tram', + 'trolleybus', + 'uc6', + 'transportation', + 'train', + 'travel', + 'trains' + ]), + Emoji( + name: 'tram car', + char: '\u{1F68B}', + shortName: 'train', + emojiGroup: EmojiGroup.travelPlaces, + emojiSubgroup: EmojiSubgroup.transportGround, + keywords: [ + 'car', + 'tram', + 'trolleybus', + 'uc6', + 'transportation', + 'train', + 'travel', + 'trains' + ]), + Emoji( + name: 'mountain railway', + char: '\u{1F69E}', + shortName: 'mountain_railway', + emojiGroup: EmojiGroup.travelPlaces, + emojiSubgroup: EmojiSubgroup.transportGround, + keywords: [ + 'car', + 'mountain', + 'railway', + 'uc6', + 'transportation', + 'train', + 'travel', + 'vacation', + 'mountain', + 'trains' + ]), + Emoji( + name: 'monorail', + char: '\u{1F69D}', + shortName: 'monorail', + emojiGroup: EmojiGroup.travelPlaces, + emojiSubgroup: EmojiSubgroup.transportGround, + keywords: [ + 'vehicle', + 'uc6', + 'transportation', + 'train', + 'travel', + 'vacation', + 'disney', + 'trains', + 'cartoon' + ]), + Emoji( + name: 'high-speed train', + char: '\u{1F684}', + shortName: 'bullettrain_side', + emojiGroup: EmojiGroup.travelPlaces, + emojiSubgroup: EmojiSubgroup.transportGround, + keywords: [ + 'railway', + 'shinkansen', + 'speed', + 'train', + 'uc6', + 'transportation', + 'train', + 'travel', + 'vacation', + 'trains' + ]), + Emoji( + name: 'bullet train', + char: '\u{1F685}', + shortName: 'bullettrain_front', + emojiGroup: EmojiGroup.travelPlaces, + emojiSubgroup: EmojiSubgroup.transportGround, + keywords: [ + 'bullet', + 'railway', + 'shinkansen', + 'speed', + 'train', + 'uc6', + 'transportation', + 'train', + 'travel', + 'vacation', + 'trains' + ]), + Emoji( + name: 'light rail', + char: '\u{1F688}', + shortName: 'light_rail', + emojiGroup: EmojiGroup.travelPlaces, + emojiSubgroup: EmojiSubgroup.transportGround, + keywords: [ + 'railway', + 'uc6', + 'transportation', + 'train', + 'travel', + 'trains' + ]), + Emoji( + name: 'locomotive', + char: '\u{1F682}', + shortName: 'steam_locomotive', + emojiGroup: EmojiGroup.travelPlaces, + emojiSubgroup: EmojiSubgroup.transportGround, + keywords: [ + 'engine', + 'railway', + 'steam', + 'train', + 'uc6', + 'transportation', + 'train', + 'travel', + 'steam', + 'disney', + 'trains', + 'steaming', + 'piping', + 'cartoon' + ]), + Emoji( + name: 'train', + char: '\u{1F686}', + shortName: 'train2', + emojiGroup: EmojiGroup.travelPlaces, + emojiSubgroup: EmojiSubgroup.transportGround, + keywords: [ + 'railway', + 'uc6', + 'transportation', + 'train', + 'travel', + 'trains' + ]), + Emoji( + name: 'metro', + char: '\u{1F687}', + shortName: 'metro', + emojiGroup: EmojiGroup.travelPlaces, + emojiSubgroup: EmojiSubgroup.transportGround, + keywords: [ + 'subway', + 'uc6', + 'transportation', + 'train', + 'travel', + 'trains' + ]), + Emoji( + name: 'tram', + char: '\u{1F68A}', + shortName: 'tram', + emojiGroup: EmojiGroup.travelPlaces, + emojiSubgroup: EmojiSubgroup.transportGround, + keywords: [ + 'trolleybus', + 'uc6', + 'transportation', + 'train', + 'travel', + 'trains' + ]), + Emoji( + name: 'station', + char: '\u{1F689}', + shortName: 'station', + emojiGroup: EmojiGroup.travelPlaces, + emojiSubgroup: EmojiSubgroup.transportGround, + keywords: [ + 'railway', + 'train', + 'uc6', + 'transportation', + 'train', + 'travel', + 'vacation', + 'trains' + ]), + Emoji( + name: 'airplane', + char: '\u{2708}\u{FE0F}', + shortName: 'airplane', + emojiGroup: EmojiGroup.travelPlaces, + emojiSubgroup: EmojiSubgroup.transportAir, + keywords: [ + 'aeroplane', + 'airplane', + 'uc1', + 'transportation', + 'plane', + 'fly', + 'travel', + 'vacation', + 'airplane', + 'planes', + 'flight', + 'flying', + 'flights', + 'avion', + 'airline', + 'aircraft', + 'airforce', + 'air force', + 'airport' + ]), + Emoji( + name: 'airplane departure', + char: '\u{1F6EB}', + shortName: 'airplane_departure', + emojiGroup: EmojiGroup.travelPlaces, + emojiSubgroup: EmojiSubgroup.transportAir, + keywords: [ + 'aeroplane', + 'airplane', + 'check-in', + 'departure', + 'departures', + 'uc7', + 'transportation', + 'plane', + 'fly', + 'travel', + 'vacation', + 'airplane', + 'planes', + 'flight', + 'flying', + 'flights', + 'avion', + 'airline', + 'aircraft', + 'airforce', + 'air force', + 'airport' + ]), + Emoji( + name: 'airplane arrival', + char: '\u{1F6EC}', + shortName: 'airplane_arriving', + emojiGroup: EmojiGroup.travelPlaces, + emojiSubgroup: EmojiSubgroup.transportAir, + keywords: [ + 'aeroplane', + 'airplane', + 'arrivals', + 'arriving', + 'landing', + 'uc7', + 'transportation', + 'plane', + 'fly', + 'travel', + 'vacation', + 'airplane', + 'planes', + 'flight', + 'flying', + 'flights', + 'avion', + 'airline', + 'aircraft', + 'airforce', + 'air force', + 'airport' + ]), + Emoji( + name: 'small airplane', + char: '\u{1F6E9}\u{FE0F}', + shortName: 'airplane_small', + emojiGroup: EmojiGroup.travelPlaces, + emojiSubgroup: EmojiSubgroup.transportAir, + keywords: [ + 'aeroplane', + 'airplane', + 'uc7', + 'transportation', + 'plane', + 'fly', + 'travel', + 'vacation', + 'airplane', + 'rich', + 'planes', + 'flight', + 'flying', + 'flights', + 'avion', + 'airline', + 'aircraft', + 'airforce', + 'air force', + 'airport', + 'grand', + 'expensive', + 'fancy' + ]), + Emoji( + name: 'seat', + char: '\u{1F4BA}', + shortName: 'seat', + emojiGroup: EmojiGroup.travelPlaces, + emojiSubgroup: EmojiSubgroup.transportAir, + keywords: [ + 'chair', + 'uc6', + 'transportation', + 'fly', + 'travel', + 'vacation', + 'seat', + 'flight', + 'flying', + 'flights', + 'avion', + 'bench', + 'sedia', + 'Stuhl', + 'chaise', + 'silla', + 'armchair' + ]), + Emoji( + name: 'satellite', + char: '\u{1F6F0}\u{FE0F}', + shortName: 'satellite_orbital', + emojiGroup: EmojiGroup.travelPlaces, + emojiSubgroup: EmojiSubgroup.transportAir, + keywords: [ + 'space', + 'uc7', + 'space', + 'drone', + 'outer space', + 'galaxy', + 'universe', + 'nasa', + 'spaceship' + ]), + Emoji( + name: 'rocket', + char: '\u{1F680}', + shortName: 'rocket', + emojiGroup: EmojiGroup.travelPlaces, + emojiSubgroup: EmojiSubgroup.transportAir, + keywords: [ + 'space', + 'uc6', + 'transportation', + 'fly', + 'space', + 'travel', + 'blast', + 'star wars', + 'flight', + 'flying', + 'flights', + 'avion', + 'outer space', + 'galaxy', + 'universe', + 'nasa', + 'spaceship', + 'boom' + ]), + Emoji( + name: 'flying saucer', + char: '\u{1F6F8}', + shortName: 'flying_saucer', + emojiGroup: EmojiGroup.travelPlaces, + emojiSubgroup: EmojiSubgroup.transportAir, + keywords: [ + 'UFO', + 'uc10', + 'transportation', + 'space', + 'travel', + 'alien', + 'outer space', + 'galaxy', + 'universe', + 'nasa', + 'spaceship', + 'ufo' + ]), + Emoji( + name: 'helicopter', + char: '\u{1F681}', + shortName: 'helicopter', + emojiGroup: EmojiGroup.travelPlaces, + emojiSubgroup: EmojiSubgroup.transportAir, + keywords: [ + 'vehicle', + 'uc6', + 'transportation', + 'plane', + 'fly', + 'travel', + 'vacation', + 'rich', + 'planes', + 'flight', + 'flying', + 'flights', + 'avion', + 'grand', + 'expensive', + 'fancy' + ]), + Emoji( + name: 'canoe', + char: '\u{1F6F6}', + shortName: 'canoe', + emojiGroup: EmojiGroup.travelPlaces, + emojiSubgroup: EmojiSubgroup.transportWater, + keywords: [ + 'boat', + 'canoe', + 'uc9', + 'transportation', + 'travel', + 'rowing', + 'rowboat', + 'canoe' + ]), + Emoji( + name: 'sailboat', + char: '\u{26F5}', + shortName: 'sailboat', + emojiGroup: EmojiGroup.travelPlaces, + emojiSubgroup: EmojiSubgroup.transportWater, + keywords: [ + 'boat', + 'resort', + 'sea', + 'yacht', + 'uc5', + 'transportation', + 'travel', + 'boat', + 'vacation', + 'pirate', + 'rich', + 'ocean', + 'boats', + 'boating', + 'grand', + 'expensive', + 'fancy', + 'sea' + ]), + Emoji( + name: 'speedboat', + char: '\u{1F6A4}', + shortName: 'speedboat', + emojiGroup: EmojiGroup.travelPlaces, + emojiSubgroup: EmojiSubgroup.transportWater, + keywords: [ + 'boat', + 'uc6', + 'transportation', + 'travel', + 'boat', + 'tropical', + 'vacation', + 'florida', + 'scuba', + 'boats', + 'boating', + 'snorkel' + ]), + Emoji( + name: 'motor boat', + char: '\u{1F6E5}\u{FE0F}', + shortName: 'motorboat', + emojiGroup: EmojiGroup.travelPlaces, + emojiSubgroup: EmojiSubgroup.transportWater, + keywords: [ + 'boat', + 'motorboat', + 'uc7', + 'transportation', + 'travel', + 'boat', + 'scuba', + 'rich', + 'boats', + 'boating', + 'snorkel', + 'grand', + 'expensive', + 'fancy' + ]), + Emoji( + name: 'passenger ship', + char: '\u{1F6F3}\u{FE0F}', + shortName: 'cruise_ship', + emojiGroup: EmojiGroup.travelPlaces, + emojiSubgroup: EmojiSubgroup.transportWater, + keywords: [ + 'passenger', + 'ship', + 'uc7', + 'transportation', + 'travel', + 'boat', + 'vacation', + 'disney', + 'florida', + 'fun', + 'ocean', + 'boats', + 'boating', + 'cartoon', + 'sea' + ]), + Emoji( + name: 'ferry', + char: '\u{26F4}\u{FE0F}', + shortName: 'ferry', + emojiGroup: EmojiGroup.travelPlaces, + emojiSubgroup: EmojiSubgroup.transportWater, + keywords: [ + 'boat', + 'passenger', + 'uc5', + 'transportation', + 'travel', + 'boat', + 'vacation', + 'ocean', + 'boats', + 'boating', + 'sea' + ]), + Emoji( + name: 'ship', + char: '\u{1F6A2}', + shortName: 'ship', + emojiGroup: EmojiGroup.travelPlaces, + emojiSubgroup: EmojiSubgroup.transportWater, + keywords: [ + 'boat', + 'passenger', + 'uc6', + 'transportation', + 'travel', + 'boat', + 'smoking', + 'vacation', + 'moving', + 'ocean', + 'boats', + 'boating', + 'smoke', + 'cigarette', + 'puff', + 'sea' + ]), + Emoji( + name: 'anchor', + char: '\u{2693}', + shortName: 'anchor', + emojiGroup: EmojiGroup.travelPlaces, + emojiSubgroup: EmojiSubgroup.transportWater, + keywords: [ + 'ship', + 'tool', + 'uc4', + 'boat', + 'vacation', + 'pirate', + 'boats', + 'boating' + ]), + Emoji( + name: 'fuel pump', + char: '\u{26FD}', + shortName: 'fuelpump', + emojiGroup: EmojiGroup.travelPlaces, + emojiSubgroup: EmojiSubgroup.transportGround, + keywords: [ + 'fuel', + 'fuelpump', + 'gas', + 'pump', + 'station', + 'uc5', + 'travel', + 'gas pump', + 'petrol' + ]), + Emoji( + name: 'construction', + char: '\u{1F6A7}', + shortName: 'construction', + emojiGroup: EmojiGroup.travelPlaces, + emojiSubgroup: EmojiSubgroup.transportGround, + keywords: ['barrier', 'uc6', 'construction']), + Emoji( + name: 'vertical traffic light', + char: '\u{1F6A6}', + shortName: 'vertical_traffic_light', + emojiGroup: EmojiGroup.travelPlaces, + emojiSubgroup: EmojiSubgroup.transportGround, + keywords: ['light', 'signal', 'traffic', 'uc6', 'stop light']), + Emoji( + name: 'horizontal traffic light', + char: '\u{1F6A5}', + shortName: 'traffic_light', + emojiGroup: EmojiGroup.travelPlaces, + emojiSubgroup: EmojiSubgroup.transportGround, + keywords: ['light', 'signal', 'traffic', 'uc6', 'stop light']), + Emoji( + name: 'bus stop', + char: '\u{1F68F}', + shortName: 'busstop', + emojiGroup: EmojiGroup.travelPlaces, + emojiSubgroup: EmojiSubgroup.transportGround, + keywords: ['bus', 'busstop', 'stop', 'uc6']), + Emoji( + name: 'world map', + char: '\u{1F5FA}\u{FE0F}', + shortName: 'map', + emojiGroup: EmojiGroup.travelPlaces, + emojiSubgroup: EmojiSubgroup.placeMap, + keywords: [ + 'map', + 'world', + 'uc7', + 'places', + 'travel', + 'map', + 'vacation', + 'pirate', + 'history', + 'minecraft', + 'navigate', + 'direction', + 'world', + 'maps', + 'location', + 'locate', + 'local', + 'lost', + 'ancient', + 'old' + ]), + Emoji( + name: 'moai', + char: '\u{1F5FF}', + shortName: 'moyai', + emojiGroup: EmojiGroup.objects, + emojiSubgroup: EmojiSubgroup.otherObject, + keywords: [ + 'face', + 'moyai', + 'statue', + 'uc6', + 'places', + 'travel', + 'japan', + 'vacation', + 'memorial', + 'japanese', + 'ninja' + ]), + Emoji( + name: 'Statue of Liberty', + char: '\u{1F5FD}', + shortName: 'statue_of_liberty', + emojiGroup: EmojiGroup.travelPlaces, + emojiSubgroup: EmojiSubgroup.placeBuilding, + keywords: [ + 'liberty', + 'statue', + 'uc6', + 'places', + 'america', + 'travel', + 'vacation', + 'statue of liberty', + 'free speech', + 'new york', + 'independence day', + 'memorial', + 'usa', + 'united states', + 'united states of america', + 'american', + 'statueofliberty', + 'freedom of speech', + '4th of july' + ]), + Emoji( + name: 'Tokyo tower', + char: '\u{1F5FC}', + shortName: 'tokyo_tower', + emojiGroup: EmojiGroup.travelPlaces, + emojiSubgroup: EmojiSubgroup.placeBuilding, + keywords: [ + 'Tokyo', + 'tower', + 'uc6', + 'building', + 'places', + 'travel', + 'japan', + 'vacation', + 'memorial', + 'buildings', + 'japanese', + 'ninja' + ]), + Emoji( + name: 'castle', + char: '\u{1F3F0}', + shortName: 'european_castle', + emojiGroup: EmojiGroup.travelPlaces, + emojiSubgroup: EmojiSubgroup.placeBuilding, + keywords: [ + 'European', + 'uc6', + 'building', + 'places', + 'travel', + 'vacation', + 'paris', + 'history', + 'irish', + 'scotland', + 'viking', + 'minecraft', + 'buildings', + 'french', + 'france', + 'ancient', + 'old', + 'saint patricks day', + 'st patricks day', + 'leprechaun', + 'scottish', + 'knight' + ]), + Emoji( + name: 'Japanese castle', + char: '\u{1F3EF}', + shortName: 'japanese_castle', + emojiGroup: EmojiGroup.travelPlaces, + emojiSubgroup: EmojiSubgroup.placeBuilding, + keywords: [ + 'Japanese', + 'castle', + 'uc6', + 'building', + 'places', + 'travel', + 'japan', + 'vacation', + 'buildings', + 'japanese', + 'ninja' + ]), + Emoji( + name: 'stadium', + char: '\u{1F3DF}\u{FE0F}', + shortName: 'stadium', + emojiGroup: EmojiGroup.travelPlaces, + emojiSubgroup: EmojiSubgroup.placeBuilding, + keywords: [ + 'stadium', + 'uc7', + 'building', + 'instruments', + 'places', + 'travel', + 'game', + 'vacation', + 'boys night', + 'buildings', + 'music', + 'instrument', + 'singing', + 'concert', + 'jaz', + 'listen', + 'singer', + 'song', + 'musique', + 'games', + 'gaming', + 'guys night' + ]), + Emoji( + name: 'ferris wheel', + char: '\u{1F3A1}', + shortName: 'ferris_wheel', + emojiGroup: EmojiGroup.travelPlaces, + emojiSubgroup: EmojiSubgroup.placeOther, + keywords: [ + 'amusement park', + 'ferris', + 'wheel', + 'uc6', + 'places', + 'travel', + 'vacation', + 'amusement park', + 'circus', + 'ferris wheel', + 'england', + 'disney', + 'fun', + 'summer', + 'independence day', + 'theme park', + 'circus tent', + 'clown', + 'clowns', + 'united kingdom', + 'london', + 'uk', + 'cartoon', + 'weekend', + '4th of july' + ]), + Emoji( + name: 'roller coaster', + char: '\u{1F3A2}', + shortName: 'roller_coaster', + emojiGroup: EmojiGroup.travelPlaces, + emojiSubgroup: EmojiSubgroup.placeOther, + keywords: [ + 'amusement park', + 'coaster', + 'roller', + 'uc6', + 'places', + 'travel', + 'vacation', + 'amusement park', + 'disney', + 'fun', + 'summer', + 'theme park', + 'cartoon', + 'weekend' + ]), + Emoji( + name: 'carousel horse', + char: '\u{1F3A0}', + shortName: 'carousel_horse', + emojiGroup: EmojiGroup.travelPlaces, + emojiSubgroup: EmojiSubgroup.placeOther, + keywords: [ + 'carousel', + 'horse', + 'uc6', + 'places', + 'vacation', + 'amusement park', + 'carousel', + 'donkey', + 'disney', + 'fun', + 'independence day', + 'theme park', + 'carousel horse', + 'poney', + 'cartoon', + '4th of july' + ]), + Emoji( + name: 'fountain', + char: '\u{26F2}', + shortName: 'fountain', + emojiGroup: EmojiGroup.travelPlaces, + emojiSubgroup: EmojiSubgroup.placeOther, + keywords: [ + 'fountain', + 'uc5', + 'places', + 'travel', + 'vacation', + 'rich', + 'memorial', + 'grand', + 'expensive', + 'fancy' + ]), + Emoji( + name: 'umbrella on ground', + char: '\u{26F1}\u{FE0F}', + shortName: 'beach_umbrella', + emojiGroup: EmojiGroup.travelPlaces, + emojiSubgroup: EmojiSubgroup.skyWeather, + keywords: [ + 'rain', + 'sun', + 'umbrella', + 'uc5', + 'travel', + 'tropical', + 'vacation', + 'umbrella', + 'hawaii', + 'california', + 'florida', + 'summer', + 'aloha', + 'kawaii', + 'maui', + 'moana', + 'weekend' + ]), + Emoji( + name: 'beach with umbrella', + char: '\u{1F3D6}\u{FE0F}', + shortName: 'beach', + emojiGroup: EmojiGroup.travelPlaces, + emojiSubgroup: EmojiSubgroup.placeGeographic, + keywords: [ + 'beach', + 'umbrella', + 'uc7', + 'places', + 'travel', + 'tropical', + 'vacation', + 'swim', + 'beach', + 'australia', + 'umbrella', + 'hawaii', + 'california', + 'florida', + 'fun', + 'summer', + 'swimming', + 'swimmer', + 'aloha', + 'kawaii', + 'maui', + 'moana', + 'weekend' + ]), + Emoji( + name: 'desert island', + char: '\u{1F3DD}\u{FE0F}', + shortName: 'island', + emojiGroup: EmojiGroup.travelPlaces, + emojiSubgroup: EmojiSubgroup.placeGeographic, + keywords: [ + 'desert', + 'island', + 'uc7', + 'places', + 'travel', + 'tropical', + 'vacation', + 'swim', + 'beach', + 'hawaii', + 'florida', + 'summer', + 'swimming', + 'swimmer', + 'aloha', + 'kawaii', + 'maui', + 'moana', + 'weekend' + ]), + Emoji( + name: 'desert', + char: '\u{1F3DC}\u{FE0F}', + shortName: 'desert', + emojiGroup: EmojiGroup.travelPlaces, + emojiSubgroup: EmojiSubgroup.placeGeographic, + keywords: [ + 'desert', + 'uc7', + 'places', + 'travel', + 'vacation', + 'hot', + 'australia', + 'california', + 'las vegas', + 'heat', + 'warm', + 'caliente', + 'chaud', + 'heiß', + 'vegas' + ]), + Emoji( + name: 'volcano', + char: '\u{1F30B}', + shortName: 'volcano', + emojiGroup: EmojiGroup.travelPlaces, + emojiSubgroup: EmojiSubgroup.placeGeographic, + keywords: [ + 'eruption', + 'mountain', + 'uc6', + 'places', + 'travel', + 'japan', + 'smoking', + 'tropical', + 'mountain', + 'explosion', + 'hawaii', + 'minecraft', + 'japanese', + 'ninja', + 'smoke', + 'cigarette', + 'puff', + 'explode', + 'aloha', + 'kawaii', + 'maui', + 'moana' + ]), + Emoji( + name: 'mountain', + char: '\u{26F0}\u{FE0F}', + shortName: 'mountain', + emojiGroup: EmojiGroup.travelPlaces, + emojiSubgroup: EmojiSubgroup.placeGeographic, + keywords: [ + 'mountain', + 'uc5', + 'places', + 'travel', + 'camp', + 'vacation', + 'mountain', + 'climb', + 'camping', + 'tent', + 'camper', + 'outdoor', + 'outside' + ]), + Emoji( + name: 'snow-capped mountain', + char: '\u{1F3D4}\u{FE0F}', + shortName: 'mountain_snow', + emojiGroup: EmojiGroup.travelPlaces, + emojiSubgroup: EmojiSubgroup.placeGeographic, + keywords: [ + 'cold', + 'mountain', + 'snow', + 'uc7', + 'places', + 'winter', + 'travel', + 'snow', + 'camp', + 'vacation', + 'cold', + 'snowboarding', + 'mountain', + 'paris', + 'polar bear', + 'freeze', + 'frozen', + 'frost', + 'ice cube', + 'camping', + 'tent', + 'camper', + 'outdoor', + 'outside', + 'chilly', + 'chilled', + 'brisk', + 'freezing', + 'frostbite', + 'icicles', + 'snowboarder', + 'french', + 'france' + ]), + Emoji( + name: 'mount fuji', + char: '\u{1F5FB}', + shortName: 'mount_fuji', + emojiGroup: EmojiGroup.travelPlaces, + emojiSubgroup: EmojiSubgroup.placeGeographic, + keywords: [ + 'fuji', + 'mountain', + 'uc6', + 'places', + 'travel', + 'japan', + 'camp', + 'vacation', + 'cold', + 'mountain', + 'japanese', + 'ninja', + 'camping', + 'tent', + 'camper', + 'outdoor', + 'outside', + 'chilly', + 'chilled', + 'brisk', + 'freezing', + 'frostbite', + 'icicles' + ]), + Emoji( + name: 'camping', + char: '\u{1F3D5}\u{FE0F}', + shortName: 'camping', + emojiGroup: EmojiGroup.travelPlaces, + emojiSubgroup: EmojiSubgroup.placeGeographic, + keywords: [ + 'camping', + 'uc7', + 'places', + 'travel', + 'camp', + 'vacation', + 'mountain', + 'fun', + 'parks', + 'camping', + 'tent', + 'camper', + 'outdoor', + 'outside', + 'regional park', + 'nature park', + 'natural park' + ]), + Emoji( + name: 'tent', + char: '\u{26FA}', + shortName: 'tent', + emojiGroup: EmojiGroup.travelPlaces, + emojiSubgroup: EmojiSubgroup.placeOther, + keywords: [ + 'camping', + 'uc5', + 'places', + 'travel', + 'camp', + 'vacation', + 'summer', + 'camping', + 'tent', + 'camper', + 'outdoor', + 'outside', + 'weekend' + ]), + Emoji( + name: 'house', + char: '\u{1F3E0}', + shortName: 'house', + emojiGroup: EmojiGroup.travelPlaces, + emojiSubgroup: EmojiSubgroup.placeBuilding, + keywords: [ + 'home', + 'house', + 'uc6', + 'building', + 'places', + 'house', + 'covid', + 'buildings', + 'houses', + 'apartment', + 'apartments', + 'casa', + 'maison', + 'home' + ]), + Emoji( + name: 'house with garden', + char: '\u{1F3E1}', + shortName: 'house_with_garden', + emojiGroup: EmojiGroup.travelPlaces, + emojiSubgroup: EmojiSubgroup.placeBuilding, + keywords: [ + 'garden', + 'home', + 'house', + 'uc6', + 'building', + 'places', + 'house', + 'buildings', + 'houses', + 'apartment', + 'apartments', + 'casa', + 'maison', + 'home' + ]), + Emoji( + name: 'houses', + char: '\u{1F3D8}\u{FE0F}', + shortName: 'homes', + emojiGroup: EmojiGroup.travelPlaces, + emojiSubgroup: EmojiSubgroup.placeBuilding, + keywords: [ + 'houses', + 'uc7', + 'building', + 'places', + 'house', + 'buildings', + 'houses', + 'apartment', + 'apartments', + 'casa', + 'maison', + 'home' + ]), + Emoji( + name: 'derelict house', + char: '\u{1F3DA}\u{FE0F}', + shortName: 'house_abandoned', + emojiGroup: EmojiGroup.travelPlaces, + emojiSubgroup: EmojiSubgroup.placeBuilding, + keywords: [ + 'derelict', + 'house', + 'uc7', + 'building', + 'places', + 'house', + 'halloween', + 'buildings', + 'houses', + 'apartment', + 'apartments', + 'casa', + 'maison', + 'home', + 'samhain' + ]), + Emoji( + name: 'hut', + char: '\u{1F6D6}', + shortName: 'hut', + emojiGroup: EmojiGroup.travelPlaces, + emojiSubgroup: EmojiSubgroup.placeBuilding, + keywords: ['uc13', 'places', 'star wars']), + Emoji( + name: 'building construction', + char: '\u{1F3D7}\u{FE0F}', + shortName: 'construction_site', + emojiGroup: EmojiGroup.travelPlaces, + emojiSubgroup: EmojiSubgroup.placeBuilding, + keywords: [ + 'construction', + 'uc7', + 'building', + 'crane', + 'build', + 'construction', + 'buildings' + ]), + Emoji( + name: 'factory', + char: '\u{1F3ED}', + shortName: 'factory', + emojiGroup: EmojiGroup.travelPlaces, + emojiSubgroup: EmojiSubgroup.placeBuilding, + keywords: [ + 'building', + 'uc6', + 'building', + 'places', + 'travel', + 'steam', + 'power', + 'poison', + 'buildings', + 'steaming', + 'piping', + 'toxic', + 'toxins' + ]), + Emoji( + name: 'office building', + char: '\u{1F3E2}', + shortName: 'office', + emojiGroup: EmojiGroup.travelPlaces, + emojiSubgroup: EmojiSubgroup.placeBuilding, + keywords: [ + 'building', + 'uc6', + 'building', + 'places', + 'classroom', + 'business', + 'work', + 'buildings', + 'school', + 'teach', + 'learn', + 'study', + 'college', + 'degree', + 'education', + 'homework', + 'student', + 'teacher', + 'university', + 'test', + 'learning', + 'office' + ]), + Emoji( + name: 'department store', + char: '\u{1F3EC}', + shortName: 'department_store', + emojiGroup: EmojiGroup.travelPlaces, + emojiSubgroup: EmojiSubgroup.placeBuilding, + keywords: [ + 'department', + 'store', + 'uc6', + 'building', + 'places', + 'disney', + 'buildings', + 'cartoon' + ]), + Emoji( + name: 'Japanese post office', + char: '\u{1F3E3}', + shortName: 'post_office', + emojiGroup: EmojiGroup.travelPlaces, + emojiSubgroup: EmojiSubgroup.placeBuilding, + keywords: [ + 'Japanese', + 'post', + 'uc6', + 'building', + 'places', + 'japan', + 'mail', + 'buildings', + 'japanese', + 'ninja', + 'email', + 'post', + 'post office' + ]), + Emoji( + name: 'post office', + char: '\u{1F3E4}', + shortName: 'european_post_office', + emojiGroup: EmojiGroup.travelPlaces, + emojiSubgroup: EmojiSubgroup.placeBuilding, + keywords: [ + 'European', + 'post', + 'uc6', + 'building', + 'places', + 'mail', + 'buildings', + 'email', + 'post', + 'post office' + ]), + Emoji( + name: 'hospital', + char: '\u{1F3E5}', + shortName: 'hospital', + emojiGroup: EmojiGroup.travelPlaces, + emojiSubgroup: EmojiSubgroup.placeBuilding, + keywords: [ + 'doctor', + 'medicine', + 'uc6', + 'building', + 'places', + 'health', + '911', + 'nurse', + 'covid', + 'buildings', + 'medicine', + 'doctor', + 'emergency', + 'injury' + ]), + Emoji( + name: 'bank', + char: '\u{1F3E6}', + shortName: 'bank', + emojiGroup: EmojiGroup.travelPlaces, + emojiSubgroup: EmojiSubgroup.placeBuilding, + keywords: [ + 'building', + 'uc6', + 'building', + 'places', + 'money', + 'rich', + 'buildings', + 'cash', + 'dollars', + 'dollar', + 'bucks', + 'currency', + 'funds', + 'payment', + 'money face', + 'reward', + 'thief', + 'bank', + 'benjamins', + 'argent', + 'dinero', + 'i soldi', + 'Geld', + 'grand', + 'expensive', + 'fancy' + ]), + Emoji( + name: 'hotel', + char: '\u{1F3E8}', + shortName: 'hotel', + emojiGroup: EmojiGroup.travelPlaces, + emojiSubgroup: EmojiSubgroup.placeBuilding, + keywords: [ + 'building', + 'uc6', + 'building', + 'places', + 'vacation', + 'las vegas', + 'hotel', + 'buildings', + 'vegas', + 'vacancy', + 'no vacancy' + ]), + Emoji( + name: 'convenience store', + char: '\u{1F3EA}', + shortName: 'convenience_store', + emojiGroup: EmojiGroup.travelPlaces, + emojiSubgroup: EmojiSubgroup.placeBuilding, + keywords: [ + 'convenience', + 'store', + 'uc6', + 'building', + 'places', + 'las vegas', + 'buildings', + 'vegas' + ]), + Emoji( + name: 'school', + char: '\u{1F3EB}', + shortName: 'school', + emojiGroup: EmojiGroup.travelPlaces, + emojiSubgroup: EmojiSubgroup.placeBuilding, + keywords: [ + 'building', + 'uc6', + 'building', + 'places', + 'classroom', + 'buildings', + 'school', + 'teach', + 'learn', + 'study', + 'college', + 'degree', + 'education', + 'homework', + 'student', + 'teacher', + 'university', + 'test', + 'learning' + ]), + Emoji( + name: 'love hotel', + char: '\u{1F3E9}', + shortName: 'love_hotel', + emojiGroup: EmojiGroup.travelPlaces, + emojiSubgroup: EmojiSubgroup.placeBuilding, + keywords: [ + 'hotel', + 'love', + 'uc6', + 'building', + 'places', + 'love', + 'japan', + 'vacation', + 'pink', + 'porn', + 'hotel', + 'buildings', + 'i love you', + 'te amo', + "je t'aime", + 'anniversary', + 'lovin', + 'amour', + 'aimer', + 'amor', + 'valentines day', + 'enamour', + 'lovey', + 'japanese', + 'ninja', + 'rose', + 'vacancy', + 'no vacancy' + ]), + Emoji( + name: 'wedding', + char: '\u{1F492}', + shortName: 'wedding', + emojiGroup: EmojiGroup.travelPlaces, + emojiSubgroup: EmojiSubgroup.placeBuilding, + keywords: [ + 'chapel', + 'romance', + 'uc6', + 'building', + 'places', + 'wedding', + 'love', + 'pink', + 'buildings', + 'weddings', + 'marriage', + 'newlywed', + 'bride', + 'groome', + 'groom', + 'married', + 'marry', + 'i love you', + 'te amo', + "je t'aime", + 'anniversary', + 'lovin', + 'amour', + 'aimer', + 'amor', + 'valentines day', + 'enamour', + 'lovey', + 'rose' + ]), + Emoji( + name: 'classical building', + char: '\u{1F3DB}\u{FE0F}', + shortName: 'classical_building', + emojiGroup: EmojiGroup.travelPlaces, + emojiSubgroup: EmojiSubgroup.placeBuilding, + keywords: [ + 'classical', + 'uc7', + 'building', + 'places', + 'travel', + 'vacation', + 'police', + 'history', + 'court', + 'memorial', + 'buildings', + 'cop', + 'policeman', + 'popo', + 'prison', + 'handcuff', + 'jail', + 'justice', + 'ancient', + 'old' + ]), + Emoji( + name: 'church', + char: '\u{26EA}', + shortName: 'church', + emojiGroup: EmojiGroup.travelPlaces, + emojiSubgroup: EmojiSubgroup.placeReligious, + keywords: [ + 'Christian', + 'cross', + 'religion', + 'uc5', + 'building', + 'places', + 'wedding', + 'religion', + 'travel', + 'christmas', + 'pray', + 'condolence', + 'jesus', + 'easter', + 'bible', + 'advent', + 'buildings', + 'weddings', + 'marriage', + 'newlywed', + 'bride', + 'groome', + 'groom', + 'married', + 'marry', + 'navidad', + 'xmas', + 'noel', + 'merry christmas', + 'prayer', + 'praying', + 'prayers', + 'grateful', + 'sorry', + 'heaven', + 'bless', + 'faith', + 'holy', + 'spirit', + 'hopeful', + 'blessed', + 'preach', + 'offering', + 'compassion' + ]), + Emoji( + name: 'mosque', + char: '\u{1F54C}', + shortName: 'mosque', + emojiGroup: EmojiGroup.travelPlaces, + emojiSubgroup: EmojiSubgroup.placeReligious, + keywords: [ + 'Muslim', + 'islam', + 'religion', + 'uc8', + 'building', + 'places', + 'religion', + 'vacation', + 'pray', + 'condolence', + 'islam', + 'buildings', + 'prayer', + 'praying', + 'prayers', + 'grateful', + 'sorry', + 'heaven', + 'bless', + 'faith', + 'holy', + 'spirit', + 'hopeful', + 'blessed', + 'preach', + 'offering', + 'compassion', + 'muslim', + 'arab' + ]), + Emoji( + name: 'synagogue', + char: '\u{1F54D}', + shortName: 'synagogue', + emojiGroup: EmojiGroup.travelPlaces, + emojiSubgroup: EmojiSubgroup.placeReligious, + keywords: [ + 'Jew', + 'Jewish', + 'religion', + 'temple', + 'uc8', + 'building', + 'places', + 'wedding', + 'religion', + 'vacation', + 'pray', + 'condolence', + 'jewish', + 'buildings', + 'weddings', + 'marriage', + 'newlywed', + 'bride', + 'groome', + 'groom', + 'married', + 'marry', + 'prayer', + 'praying', + 'prayers', + 'grateful', + 'sorry', + 'heaven', + 'bless', + 'faith', + 'holy', + 'spirit', + 'hopeful', + 'blessed', + 'preach', + 'offering', + 'compassion', + 'hannukah', + 'hanukkah', + 'israel' + ]), + Emoji( + name: 'hindu temple', + char: '\u{1F6D5}', + shortName: 'hindu_temple', + emojiGroup: EmojiGroup.travelPlaces, + emojiSubgroup: EmojiSubgroup.placeReligious, + keywords: [ + 'uc12', + 'building', + 'places', + 'travel', + 'pray', + 'dharmachakra', + 'buildings', + 'prayer', + 'praying', + 'prayers', + 'grateful', + 'sorry', + 'heaven', + 'bless', + 'faith', + 'holy', + 'spirit', + 'hopeful', + 'blessed', + 'preach', + 'offering', + 'jainism', + 'buddhism', + 'hinduism', + 'nirvana', + 'maintain', + 'keep', + 'law', + 'bueno', + 'dharma', + 'kama', + 'artha', + 'moksa', + 'karma' + ]), + Emoji( + name: 'kaaba', + char: '\u{1F54B}', + shortName: 'kaaba', + emojiGroup: EmojiGroup.travelPlaces, + emojiSubgroup: EmojiSubgroup.placeReligious, + keywords: [ + 'Muslim', + 'islam', + 'religion', + 'uc8', + 'building', + 'places', + 'religion', + 'pray', + 'condolence', + 'islam', + 'buildings', + 'prayer', + 'praying', + 'prayers', + 'grateful', + 'sorry', + 'heaven', + 'bless', + 'faith', + 'holy', + 'spirit', + 'hopeful', + 'blessed', + 'preach', + 'offering', + 'compassion', + 'muslim', + 'arab' + ]), + Emoji( + name: 'shinto shrine', + char: '\u{26E9}\u{FE0F}', + shortName: 'shinto_shrine', + emojiGroup: EmojiGroup.travelPlaces, + emojiSubgroup: EmojiSubgroup.placeReligious, + keywords: [ + 'religion', + 'shinto', + 'shrine', + 'uc5', + 'building', + 'places', + 'travel', + 'japan', + 'vacation', + 'buildings', + 'japanese', + 'ninja' + ]), + Emoji( + name: 'railway track', + char: '\u{1F6E4}\u{FE0F}', + shortName: 'railway_track', + emojiGroup: EmojiGroup.travelPlaces, + emojiSubgroup: EmojiSubgroup.transportGround, + keywords: [ + 'railway', + 'train', + 'uc7', + 'train', + 'travel', + 'vacation', + 'trains' + ]), + Emoji( + name: 'motorway', + char: '\u{1F6E3}\u{FE0F}', + shortName: 'motorway', + emojiGroup: EmojiGroup.travelPlaces, + emojiSubgroup: EmojiSubgroup.transportGround, + keywords: [ + 'highway', + 'road', + 'uc7', + 'travel', + 'camp', + 'vacation', + 'road', + 'camping', + 'tent', + 'camper', + 'outdoor', + 'outside', + 'route', + 'highway', + 'street' + ]), + Emoji( + name: 'map of Japan', + char: '\u{1F5FE}', + shortName: 'japan', + emojiGroup: EmojiGroup.travelPlaces, + emojiSubgroup: EmojiSubgroup.placeMap, + keywords: [ + 'Japan', + 'map', + 'uc6', + 'places', + 'travel', + 'japan', + 'map', + 'vacation', + 'japanese', + 'ninja', + 'maps', + 'location', + 'locate', + 'local', + 'lost' + ]), + Emoji( + name: 'moon viewing ceremony', + char: '\u{1F391}', + shortName: 'rice_scene', + emojiGroup: EmojiGroup.activities, + emojiSubgroup: EmojiSubgroup.event, + keywords: [ + 'celebration', + 'ceremony', + 'moon', + 'uc6', + 'places', + 'space', + 'sky', + 'travel', + 'japan', + 'outer space', + 'galaxy', + 'universe', + 'nasa', + 'spaceship', + 'japanese', + 'ninja' + ]), + Emoji( + name: 'national park', + char: '\u{1F3DE}\u{FE0F}', + shortName: 'park', + emojiGroup: EmojiGroup.travelPlaces, + emojiSubgroup: EmojiSubgroup.placeGeographic, + keywords: [ + 'park', + 'uc7', + 'places', + 'travel', + 'camp', + 'vacation', + 'summer', + 'river', + 'memorial', + 'parks', + 'camping', + 'tent', + 'camper', + 'outdoor', + 'outside', + 'weekend', + 'regional park', + 'nature park', + 'natural park' + ]), + Emoji( + name: 'sunrise', + char: '\u{1F305}', + shortName: 'sunrise', + emojiGroup: EmojiGroup.travelPlaces, + emojiSubgroup: EmojiSubgroup.placeOther, + keywords: [ + 'morning', + 'sun', + 'uc6', + 'places', + 'sun', + 'sky', + 'travel', + 'tropical', + 'vacation', + 'day', + 'hump day', + 'morning', + 'hawaii', + 'california', + 'florida', + 'scuba', + 'summer', + 'sunshine', + 'sunny', + 'eclipse', + 'solar', + 'solareclipse', + 'shiny', + 'good morning', + 'aloha', + 'kawaii', + 'maui', + 'moana', + 'snorkel', + 'weekend' + ]), + Emoji( + name: 'sunrise over mountains', + char: '\u{1F304}', + shortName: 'sunrise_over_mountains', + emojiGroup: EmojiGroup.travelPlaces, + emojiSubgroup: EmojiSubgroup.placeOther, + keywords: [ + 'morning', + 'mountain', + 'sun', + 'sunrise', + 'uc6', + 'places', + 'sun', + 'sky', + 'travel', + 'camp', + 'vacation', + 'day', + 'morning', + 'mountain', + 'california', + 'sunshine', + 'sunny', + 'eclipse', + 'solar', + 'solareclipse', + 'shiny', + 'camping', + 'tent', + 'camper', + 'outdoor', + 'outside', + 'good morning' + ]), + Emoji( + name: 'shooting star', + char: '\u{1F320}', + shortName: 'stars', + emojiGroup: EmojiGroup.travelPlaces, + emojiSubgroup: EmojiSubgroup.skyWeather, + keywords: [ + 'falling', + 'shooting', + 'star', + 'uc6', + 'space', + 'star wars', + 'fame', + 'sparkle', + 'outer space', + 'galaxy', + 'universe', + 'nasa', + 'spaceship', + 'famous', + 'celebrity', + 'bright', + 'shine', + 'twinkle' + ]), + Emoji( + name: 'sparkler', + char: '\u{1F387}', + shortName: 'sparkler', + emojiGroup: EmojiGroup.activities, + emojiSubgroup: EmojiSubgroup.event, + keywords: [ + 'celebration', + 'fireworks', + 'sparkle', + 'uc6', + 'holidays', + 'happy birthday', + 'firework', + 'explosion', + 'celebrate', + 'glitter', + 'disney', + 'sparkle', + 'holiday', + 'Bon anniversaire', + 'joyeux anniversaire', + 'buon compleanno', + 'feliz cumpleaños', + 'alles Gute zum Geburtstag', + 'feliz Aniversário', + 'Gratulerer med dagen', + 'fireworks', + 'firecracker', + 'explode', + 'celebration', + 'event', + 'celebrating', + 'festa', + 'parties', + 'events', + 'new years', + 'new year', + 'fiesta', + 'fete', + 'newyear', + 'party', + 'festive', + 'festival', + 'yolo', + 'festejar', + 'cartoon', + 'bright', + 'shine', + 'twinkle' + ]), + Emoji( + name: 'fireworks', + char: '\u{1F386}', + shortName: 'fireworks', + emojiGroup: EmojiGroup.activities, + emojiSubgroup: EmojiSubgroup.event, + keywords: [ + 'celebration', + 'uc6', + 'holidays', + 'firework', + 'explosion', + 'celebrate', + 'glitter', + 'disney', + 'sparkle', + 'independence day', + 'holiday', + 'fireworks', + 'firecracker', + 'explode', + 'celebration', + 'event', + 'celebrating', + 'festa', + 'parties', + 'events', + 'new years', + 'new year', + 'fiesta', + 'fete', + 'newyear', + 'party', + 'festive', + 'festival', + 'yolo', + 'festejar', + 'cartoon', + 'bright', + 'shine', + 'twinkle', + '4th of july' + ]), + Emoji( + name: 'sunset', + char: '\u{1F307}', + shortName: 'city_sunset', + emojiGroup: EmojiGroup.travelPlaces, + emojiSubgroup: EmojiSubgroup.placeOther, + keywords: [ + 'dusk', + 'sun', + 'uc6', + 'building', + 'places', + 'sky', + 'vacation', + 'buildings' + ]), + Emoji( + name: 'cityscape at dusk', + char: '\u{1F306}', + shortName: 'city_dusk', + emojiGroup: EmojiGroup.travelPlaces, + emojiSubgroup: EmojiSubgroup.placeOther, + keywords: [ + 'city', + 'dusk', + 'evening', + 'landscape', + 'sun', + 'sunset', + 'uc6', + 'building', + 'places', + 'buildings' + ]), + Emoji( + name: 'cityscape', + char: '\u{1F3D9}\u{FE0F}', + shortName: 'cityscape', + emojiGroup: EmojiGroup.travelPlaces, + emojiSubgroup: EmojiSubgroup.placeOther, + keywords: [ + 'city', + 'uc7', + 'building', + 'places', + 'vacation', + 'new york', + 'england', + 'donald trump', + 'rich', + 'buildings', + 'united kingdom', + 'london', + 'uk', + 'trump', + 'grand', + 'expensive', + 'fancy' + ]), + Emoji( + name: 'night with stars', + char: '\u{1F303}', + shortName: 'night_with_stars', + emojiGroup: EmojiGroup.travelPlaces, + emojiSubgroup: EmojiSubgroup.placeOther, + keywords: [ + 'night', + 'star', + 'uc6', + 'building', + 'places', + 'halloween', + 'sky', + 'vacation', + 'goodnight', + 'buildings', + 'samhain' + ]), + Emoji( + name: 'milky way', + char: '\u{1F30C}', + shortName: 'milky_way', + emojiGroup: EmojiGroup.travelPlaces, + emojiSubgroup: EmojiSubgroup.skyWeather, + keywords: [ + 'space', + 'uc6', + 'places', + 'space', + 'sky', + 'travel', + 'star', + 'vacation', + 'goodnight', + 'star wars', + 'dream', + 'fantasy', + 'outer space', + 'galaxy', + 'universe', + 'nasa', + 'spaceship', + 'stars', + 'dreams' + ]), + Emoji( + name: 'bridge at night', + char: '\u{1F309}', + shortName: 'bridge_at_night', + emojiGroup: EmojiGroup.travelPlaces, + emojiSubgroup: EmojiSubgroup.placeOther, + keywords: [ + 'bridge', + 'night', + 'uc6', + 'places', + 'travel', + 'vacation', + 'goodnight', + 'england', + 'california', + 'united kingdom', + 'london', + 'uk' + ]), + Emoji( + name: 'foggy', + char: '\u{1F301}', + shortName: 'foggy', + emojiGroup: EmojiGroup.travelPlaces, + emojiSubgroup: EmojiSubgroup.placeOther, + keywords: [ + 'fog', + 'uc6', + 'building', + 'places', + 'sky', + 'travel', + 'vacation', + 'england', + 'buildings', + 'united kingdom', + 'london', + 'uk' + ]), + Emoji( + name: 'watch', + char: '\u{231A}', + shortName: 'watch', + emojiGroup: EmojiGroup.travelPlaces, + emojiSubgroup: EmojiSubgroup.time, + keywords: [ + 'clock', + 'uc1', + 'electronics', + 'time', + 'accessories', + 'bling', + 'wait', + 'clocks', + 'clock', + 'jewels', + 'gems', + 'jewel', + 'jewelry', + 'treasure', + 'hours' + ]), + Emoji( + name: 'mobile phone', + char: '\u{1F4F1}', + shortName: 'mobile_phone', + emojiGroup: EmojiGroup.objects, + emojiSubgroup: EmojiSubgroup.phone, + keywords: [ + 'cell', + 'mobile', + 'phone', + 'telephone', + 'uc6', + 'electronics', + 'phone', + 'talk', + 'selfie', + 'technology', + 'laptop', + 'instagram', + 'telephone', + 'iphone', + 'smartphone', + 'text', + 'talking', + 'speech', + 'social', + 'chat', + 'voice', + 'speechless', + 'speak', + 'computer', + 'online', + 'wifi', + 'website', + 'zoom', + 'ipad', + 'tablet' + ]), + Emoji( + name: 'mobile phone with arrow', + char: '\u{1F4F2}', + shortName: 'calling', + emojiGroup: EmojiGroup.objects, + emojiSubgroup: EmojiSubgroup.phone, + keywords: [ + 'arrow', + 'call', + 'cell', + 'mobile', + 'phone', + 'receive', + 'telephone', + 'uc6', + 'electronics', + 'phone', + 'selfie', + 'technology', + 'download', + 'telephone', + 'iphone', + 'smartphone', + 'text' + ]), + Emoji( + name: 'laptop', + char: '\u{1F4BB}', + shortName: 'computer', + emojiGroup: EmojiGroup.objects, + emojiSubgroup: EmojiSubgroup.computer, + keywords: [ + 'computer', + 'pc', + 'personal', + 'uc6', + 'electronics', + 'classroom', + 'internet', + 'technology', + 'laptop', + 'download', + 'instagram', + 'youtube', + 'work', + 'school', + 'teach', + 'learn', + 'study', + 'college', + 'degree', + 'education', + 'homework', + 'student', + 'teacher', + 'university', + 'test', + 'learning', + 'computer', + 'online', + 'wifi', + 'website', + 'zoom', + 'ipad', + 'tablet', + 'vlog', + 'office' + ]), + Emoji( + name: 'keyboard', + char: '\u{2328}\u{FE0F}', + shortName: 'keyboard', + emojiGroup: EmojiGroup.objects, + emojiSubgroup: EmojiSubgroup.computer, + keywords: [ + 'computer', + 'uc1', + 'electronics', + 'classroom', + 'technology', + 'laptop', + 'work', + 'school', + 'teach', + 'learn', + 'study', + 'college', + 'degree', + 'education', + 'homework', + 'student', + 'teacher', + 'university', + 'test', + 'learning', + 'computer', + 'online', + 'wifi', + 'website', + 'zoom', + 'ipad', + 'tablet', + 'office' + ]), + Emoji( + name: 'desktop computer', + char: '\u{1F5A5}\u{FE0F}', + shortName: 'desktop', + emojiGroup: EmojiGroup.objects, + emojiSubgroup: EmojiSubgroup.computer, + keywords: [ + 'computer', + 'desktop', + 'uc7', + 'electronics', + 'classroom', + 'internet', + 'technology', + 'laptop', + 'download', + 'instagram', + 'youtube', + 'work', + 'school', + 'teach', + 'learn', + 'study', + 'college', + 'degree', + 'education', + 'homework', + 'student', + 'teacher', + 'university', + 'test', + 'learning', + 'computer', + 'online', + 'wifi', + 'website', + 'zoom', + 'ipad', + 'tablet', + 'vlog', + 'office' + ]), + Emoji( + name: 'printer', + char: '\u{1F5A8}\u{FE0F}', + shortName: 'printer', + emojiGroup: EmojiGroup.objects, + emojiSubgroup: EmojiSubgroup.computer, + keywords: [ + 'computer', + 'uc7', + 'electronics', + 'classroom', + 'technology', + 'work', + 'school', + 'teach', + 'learn', + 'study', + 'college', + 'degree', + 'education', + 'homework', + 'student', + 'teacher', + 'university', + 'test', + 'learning', + 'office' + ]), + Emoji( + name: 'computer mouse', + char: '\u{1F5B1}\u{FE0F}', + shortName: 'mouse_three_button', + emojiGroup: EmojiGroup.objects, + emojiSubgroup: EmojiSubgroup.computer, + keywords: [ + 'computer', + 'uc7', + 'electronics', + 'classroom', + 'game', + 'technology', + 'laptop', + 'click', + 'work', + 'school', + 'teach', + 'learn', + 'study', + 'college', + 'degree', + 'education', + 'homework', + 'student', + 'teacher', + 'university', + 'test', + 'learning', + 'games', + 'gaming', + 'computer', + 'online', + 'wifi', + 'website', + 'zoom', + 'ipad', + 'tablet', + 'office' + ]), + Emoji( + name: 'trackball', + char: '\u{1F5B2}\u{FE0F}', + shortName: 'trackball', + emojiGroup: EmojiGroup.objects, + emojiSubgroup: EmojiSubgroup.computer, + keywords: [ + 'computer', + 'uc7', + 'electronics', + 'classroom', + 'game', + 'technology', + 'laptop', + 'click', + 'work', + 'school', + 'teach', + 'learn', + 'study', + 'college', + 'degree', + 'education', + 'homework', + 'student', + 'teacher', + 'university', + 'test', + 'learning', + 'games', + 'gaming', + 'computer', + 'online', + 'wifi', + 'website', + 'zoom', + 'ipad', + 'tablet', + 'office' + ]), + Emoji( + name: 'joystick', + char: '\u{1F579}\u{FE0F}', + shortName: 'joystick', + emojiGroup: EmojiGroup.activities, + emojiSubgroup: EmojiSubgroup.game, + keywords: [ + 'game', + 'video game', + 'uc7', + 'electronics', + 'game', + 'boys night', + 'technology', + 'controller', + 'pacman', + 'games', + 'gaming', + 'guys night', + 'remote', + 'pac man' + ]), + Emoji( + name: 'clamp', + char: '\u{1F5DC}\u{FE0F}', + shortName: 'compression', + emojiGroup: EmojiGroup.objects, + emojiSubgroup: EmojiSubgroup.tool, + keywords: [ + 'compress', + 'tool', + 'vice', + 'uc7', + 'tool', + 'download', + 'steel', + 'tools', + 'metal' + ]), + Emoji( + name: 'computer disk', + char: '\u{1F4BD}', + shortName: 'minidisc', + emojiGroup: EmojiGroup.objects, + emojiSubgroup: EmojiSubgroup.computer, + keywords: [ + 'computer', + 'disk', + 'minidisk', + 'optical', + 'uc6', + 'instruments', + 'electronics', + 'classroom', + 'laptop', + 'download', + 'work', + 'music', + 'instrument', + 'singing', + 'concert', + 'jaz', + 'listen', + 'singer', + 'song', + 'musique', + 'school', + 'teach', + 'learn', + 'study', + 'college', + 'degree', + 'education', + 'homework', + 'student', + 'teacher', + 'university', + 'test', + 'learning', + 'computer', + 'online', + 'wifi', + 'website', + 'zoom', + 'ipad', + 'tablet', + 'office' + ]), + Emoji( + name: 'floppy disk', + char: '\u{1F4BE}', + shortName: 'floppy_disk', + emojiGroup: EmojiGroup.objects, + emojiSubgroup: EmojiSubgroup.computer, + keywords: [ + 'computer', + 'disk', + 'floppy', + 'uc6', + 'electronics', + 'classroom', + 'laptop', + 'download', + 'work', + 'school', + 'teach', + 'learn', + 'study', + 'college', + 'degree', + 'education', + 'homework', + 'student', + 'teacher', + 'university', + 'test', + 'learning', + 'computer', + 'online', + 'wifi', + 'website', + 'zoom', + 'ipad', + 'tablet', + 'office' + ]), + Emoji( + name: 'optical disk', + char: '\u{1F4BF}', + shortName: 'cd', + emojiGroup: EmojiGroup.objects, + emojiSubgroup: EmojiSubgroup.computer, + keywords: [ + 'cd', + 'computer', + 'disk', + 'optical', + 'uc6', + 'instruments', + 'electronics', + 'download', + 'music', + 'instrument', + 'singing', + 'concert', + 'jaz', + 'listen', + 'singer', + 'song', + 'musique' + ]), + Emoji( + name: 'dvd', + char: '\u{1F4C0}', + shortName: 'dvd', + emojiGroup: EmojiGroup.objects, + emojiSubgroup: EmojiSubgroup.computer, + keywords: [ + 'blu-ray', + 'computer', + 'disk', + 'dvd', + 'optical', + 'uc6', + 'electronics' + ]), + Emoji( + name: 'videocassette', + char: '\u{1F4FC}', + shortName: 'vhs', + emojiGroup: EmojiGroup.objects, + emojiSubgroup: EmojiSubgroup.lightVideo, + keywords: [ + 'tape', + 'vhs', + 'video', + 'uc6', + 'electronics', + 'history', + 'ancient', + 'old' + ]), + Emoji( + name: 'camera', + char: '\u{1F4F7}', + shortName: 'camera', + emojiGroup: EmojiGroup.objects, + emojiSubgroup: EmojiSubgroup.lightVideo, + keywords: [ + 'video', + 'uc6', + 'electronics', + 'selfie', + 'technology', + 'detective', + 'instagram', + 'youtube', + 'vlog' + ]), + Emoji( + name: 'camera with flash', + char: '\u{1F4F8}', + shortName: 'camera_with_flash', + emojiGroup: EmojiGroup.objects, + emojiSubgroup: EmojiSubgroup.lightVideo, + keywords: [ + 'camera', + 'flash', + 'video', + 'uc7', + 'electronics', + 'technology', + 'instagram' + ]), + Emoji( + name: 'video camera', + char: '\u{1F4F9}', + shortName: 'video_camera', + emojiGroup: EmojiGroup.objects, + emojiSubgroup: EmojiSubgroup.lightVideo, + keywords: [ + 'camera', + 'video', + 'uc6', + 'electronics', + 'movie', + 'technology', + 'porn', + 'youtube', + 'movies', + 'cinema', + 'film', + 'films', + 'video', + 'videos', + 'vlog' + ]), + Emoji( + name: 'movie camera', + char: '\u{1F3A5}', + shortName: 'movie_camera', + emojiGroup: EmojiGroup.objects, + emojiSubgroup: EmojiSubgroup.lightVideo, + keywords: [ + 'camera', + 'cinema', + 'movie', + 'uc6', + 'movie', + 'movies', + 'cinema', + 'film', + 'films', + 'video', + 'videos' + ]), + Emoji( + name: 'film projector', + char: '\u{1F4FD}\u{FE0F}', + shortName: 'projector', + emojiGroup: EmojiGroup.objects, + emojiSubgroup: EmojiSubgroup.lightVideo, + keywords: [ + 'cinema', + 'film', + 'movie', + 'projector', + 'video', + 'uc7', + 'movie', + 'disney', + 'california', + 'fame', + 'movies', + 'cinema', + 'film', + 'films', + 'video', + 'videos', + 'cartoon', + 'famous', + 'celebrity' + ]), + Emoji( + name: 'film frames', + char: '\u{1F39E}\u{FE0F}', + shortName: 'film_frames', + emojiGroup: EmojiGroup.objects, + emojiSubgroup: EmojiSubgroup.lightVideo, + keywords: [ + 'cinema', + 'film', + 'frames', + 'movie', + 'uc7', + 'movie', + 'disney', + 'california', + 'movies', + 'cinema', + 'film', + 'films', + 'video', + 'videos', + 'cartoon' + ]), + Emoji( + name: 'telephone receiver', + char: '\u{1F4DE}', + shortName: 'telephone_receiver', + emojiGroup: EmojiGroup.objects, + emojiSubgroup: EmojiSubgroup.phone, + keywords: [ + 'phone', + 'receiver', + 'telephone', + 'uc6', + 'electronics', + 'phone', + 'talk', + 'telephone', + 'iphone', + 'smartphone', + 'text', + 'talking', + 'speech', + 'social', + 'chat', + 'voice', + 'speechless', + 'speak' + ]), + Emoji( + name: 'telephone', + char: '\u{260E}\u{FE0F}', + shortName: 'telephone', + emojiGroup: EmojiGroup.objects, + emojiSubgroup: EmojiSubgroup.phone, + keywords: [ + 'phone', + 'uc1', + 'electronics', + 'phone', + 'talk', + 'history', + 'hotel', + 'telephone', + 'iphone', + 'smartphone', + 'text', + 'talking', + 'speech', + 'social', + 'chat', + 'voice', + 'speechless', + 'speak', + 'ancient', + 'old', + 'vacancy', + 'no vacancy' + ]), + Emoji( + name: 'pager', + char: '\u{1F4DF}', + shortName: 'pager', + emojiGroup: EmojiGroup.objects, + emojiSubgroup: EmojiSubgroup.phone, + keywords: [ + 'pager', + 'uc6', + 'electronics', + 'technology', + 'history', + 'work', + 'ancient', + 'old', + 'office' + ]), + Emoji( + name: 'fax machine', + char: '\u{1F4E0}', + shortName: 'fax', + emojiGroup: EmojiGroup.objects, + emojiSubgroup: EmojiSubgroup.phone, + keywords: [ + 'fax', + 'uc6', + 'electronics', + 'classroom', + 'technology', + 'work', + 'school', + 'teach', + 'learn', + 'study', + 'college', + 'degree', + 'education', + 'homework', + 'student', + 'teacher', + 'university', + 'test', + 'learning', + 'office' + ]), + Emoji( + name: 'television', + char: '\u{1F4FA}', + shortName: 'tv', + emojiGroup: EmojiGroup.objects, + emojiSubgroup: EmojiSubgroup.lightVideo, + keywords: [ + 'tv', + 'video', + 'uc6', + 'electronics', + 'classroom', + 'technology', + 'news', + 'fame', + 'history', + 'youtube', + 'household', + 'school', + 'teach', + 'learn', + 'study', + 'college', + 'degree', + 'education', + 'homework', + 'student', + 'teacher', + 'university', + 'test', + 'learning', + 'article', + 'famous', + 'celebrity', + 'ancient', + 'old', + 'vlog' + ]), + Emoji( + name: 'radio', + char: '\u{1F4FB}', + shortName: 'radio', + emojiGroup: EmojiGroup.objects, + emojiSubgroup: EmojiSubgroup.music, + keywords: [ + 'video', + 'uc6', + 'instruments', + 'electronics', + 'news', + 'history', + 'sound', + 'household', + 'music', + 'instrument', + 'singing', + 'concert', + 'jaz', + 'listen', + 'singer', + 'song', + 'musique', + 'article', + 'ancient', + 'old', + 'volume', + 'speaker', + 'loud', + 'mic', + 'audio', + 'hear' + ]), + Emoji( + name: 'studio microphone', + char: '\u{1F399}\u{FE0F}', + shortName: 'microphone2', + emojiGroup: EmojiGroup.objects, + emojiSubgroup: EmojiSubgroup.music, + keywords: [ + 'mic', + 'microphone', + 'music', + 'studio', + 'uc7', + 'instruments', + 'electronics', + 'news', + 'sound', + 'music', + 'instrument', + 'singing', + 'concert', + 'jaz', + 'listen', + 'singer', + 'song', + 'musique', + 'article', + 'volume', + 'speaker', + 'loud', + 'mic', + 'audio', + 'hear' + ]), + Emoji( + name: 'level slider', + char: '\u{1F39A}\u{FE0F}', + shortName: 'level_slider', + emojiGroup: EmojiGroup.objects, + emojiSubgroup: EmojiSubgroup.music, + keywords: [ + 'level', + 'music', + 'slider', + 'uc7', + 'instruments', + 'electronics', + 'sound', + 'music', + 'instrument', + 'singing', + 'concert', + 'jaz', + 'listen', + 'singer', + 'song', + 'musique', + 'volume', + 'speaker', + 'loud', + 'mic', + 'audio', + 'hear' + ]), + Emoji( + name: 'control knobs', + char: '\u{1F39B}\u{FE0F}', + shortName: 'control_knobs', + emojiGroup: EmojiGroup.objects, + emojiSubgroup: EmojiSubgroup.music, + keywords: [ + 'control', + 'knobs', + 'music', + 'uc7', + 'instruments', + 'electronics', + 'power', + 'bake', + 'sound', + 'music', + 'instrument', + 'singing', + 'concert', + 'jaz', + 'listen', + 'singer', + 'song', + 'musique', + 'baking', + 'volume', + 'speaker', + 'loud', + 'mic', + 'audio', + 'hear' + ]), + Emoji( + name: 'compass', + char: '\u{1F9ED}', + shortName: 'compass', + emojiGroup: EmojiGroup.travelPlaces, + emojiSubgroup: EmojiSubgroup.placeMap, + keywords: [ + 'uc11', + 'travel', + 'camp', + 'science', + 'map', + 'globe', + 'navigate', + 'direction', + 'camping', + 'tent', + 'camper', + 'outdoor', + 'outside', + 'lab', + 'maps', + 'location', + 'locate', + 'local', + 'lost', + 'globes', + 'planet', + 'earth', + 'earthquake' + ]), + Emoji( + name: 'stopwatch', + char: '\u{23F1}\u{FE0F}', + shortName: 'stopwatch', + emojiGroup: EmojiGroup.travelPlaces, + emojiSubgroup: EmojiSubgroup.time, + keywords: [ + 'clock', + 'uc6', + 'electronics', + 'time', + 'wait', + 'clocks', + 'clock', + 'hours' + ]), + Emoji( + name: 'timer clock', + char: '\u{23F2}\u{FE0F}', + shortName: 'timer', + emojiGroup: EmojiGroup.travelPlaces, + emojiSubgroup: EmojiSubgroup.time, + keywords: [ + 'clock', + 'timer', + 'uc6', + 'time', + 'bake', + 'wait', + 'measure', + 'clocks', + 'clock', + 'baking', + 'hours' + ]), + Emoji( + name: 'alarm clock', + char: '\u{23F0}', + shortName: 'alarm_clock', + emojiGroup: EmojiGroup.travelPlaces, + emojiSubgroup: EmojiSubgroup.time, + keywords: [ + 'alarm', + 'clock', + 'uc6', + 'time', + 'alarm', + 'wait', + 'clocks', + 'clock', + 'alarms', + 'announce', + 'hours' + ]), + Emoji( + name: 'mantelpiece clock', + char: '\u{1F570}\u{FE0F}', + shortName: 'clock', + emojiGroup: EmojiGroup.travelPlaces, + emojiSubgroup: EmojiSubgroup.time, + keywords: [ + 'clock', + 'uc7', + 'time', + 'vintage', + 'wait', + 'household', + 'clocks', + 'clock', + 'hours' + ]), + Emoji( + name: 'hourglass done', + char: '\u{231B}', + shortName: 'hourglass', + emojiGroup: EmojiGroup.travelPlaces, + emojiSubgroup: EmojiSubgroup.time, + keywords: [ + 'sand', + 'timer', + 'uc1', + 'time', + 'empty', + 'percent', + 'wait', + 'measure', + 'clocks', + 'clock', + 'hours' + ]), + Emoji( + name: 'hourglass not done', + char: '\u{23F3}', + shortName: 'hourglass_flowing_sand', + emojiGroup: EmojiGroup.travelPlaces, + emojiSubgroup: EmojiSubgroup.time, + keywords: [ + 'hourglass', + 'sand', + 'timer', + 'uc6', + 'time', + 'infinity', + 'history', + 'percent', + 'wait', + 'measure', + 'clocks', + 'clock', + 'infini', + 'forever', + 'ancient', + 'old', + 'hours' + ]), + Emoji( + name: 'satellite antenna', + char: '\u{1F4E1}', + shortName: 'satellite', + emojiGroup: EmojiGroup.objects, + emojiSubgroup: EmojiSubgroup.science, + keywords: ['antenna', 'dish', 'satellite', 'uc6', 'technology', 'power']), + Emoji( + name: 'battery', + char: '\u{1F50B}', + shortName: 'battery', + emojiGroup: EmojiGroup.objects, + emojiSubgroup: EmojiSubgroup.computer, + keywords: ['battery', 'uc6', 'science', 'power', 'energy', 'lab']), + Emoji( + name: 'electric plug', + char: '\u{1F50C}', + shortName: 'electric_plug', + emojiGroup: EmojiGroup.objects, + emojiSubgroup: EmojiSubgroup.computer, + keywords: [ + 'electric', + 'electricity', + 'plug', + 'uc6', + 'electronics', + 'electric', + 'power', + 'household', + 'energy' + ]), + Emoji( + name: 'light bulb', + char: '\u{1F4A1}', + shortName: 'bulb', + emojiGroup: EmojiGroup.objects, + emojiSubgroup: EmojiSubgroup.lightVideo, + keywords: [ + 'bulb', + 'comic', + 'electric', + 'idea', + 'light', + 'uc6', + 'science', + 'electric', + 'light', + 'power', + 'idea', + 'sparkle', + 'innovate', + 'household', + 'energy', + 'lab', + 'lamp', + 'light bulb', + 'flashlight', + 'spotlight', + 'illuminate', + 'lightbulb', + 'lighting', + 'luce', + 'licht', + 'lumière', + 'luz', + 'bright', + 'shine', + 'twinkle', + 'innovation', + 'inquire' + ]), + Emoji( + name: 'flashlight', + char: '\u{1F526}', + shortName: 'flashlight', + emojiGroup: EmojiGroup.objects, + emojiSubgroup: EmojiSubgroup.lightVideo, + keywords: [ + 'electric', + 'light', + 'tool', + 'torch', + 'uc6', + 'electronics', + 'tool', + 'light', + 'star wars', + 'search', + 'detective', + 'sparkle', + 'household', + 'energy', + 'tools', + 'lamp', + 'light bulb', + 'flashlight', + 'spotlight', + 'illuminate', + 'lightbulb', + 'lighting', + 'luce', + 'licht', + 'lumière', + 'luz', + 'look', + 'find', + 'looking', + 'see', + 'bright', + 'shine', + 'twinkle' + ]), + Emoji( + name: 'candle', + char: '\u{1F56F}\u{FE0F}', + shortName: 'candle', + emojiGroup: EmojiGroup.objects, + emojiSubgroup: EmojiSubgroup.lightVideo, + keywords: [ + 'light', + 'uc7', + 'religion', + 'halloween', + 'birthday', + 'christmas', + 'light', + 'jewish', + 'samhain', + 'birth', + 'cumpleaños', + 'anniversaire', + 'bday', + 'navidad', + 'xmas', + 'noel', + 'merry christmas', + 'lamp', + 'light bulb', + 'flashlight', + 'spotlight', + 'illuminate', + 'lightbulb', + 'lighting', + 'luce', + 'licht', + 'lumière', + 'luz', + 'hannukah', + 'hanukkah', + 'israel' + ]), + Emoji( + name: 'diya lamp', + char: '\u{1FA94}', + shortName: 'diya_lamp', + emojiGroup: EmojiGroup.objects, + emojiSubgroup: EmojiSubgroup.lightVideo, + keywords: [ + 'uc12', + 'light', + 'jealous', + 'dharmachakra', + 'diya', + 'greed', + 'soul', + 'lamp', + 'light bulb', + 'flashlight', + 'spotlight', + 'illuminate', + 'lightbulb', + 'lighting', + 'luce', + 'licht', + 'lumière', + 'luz', + 'jainism', + 'buddhism', + 'hinduism', + 'nirvana', + 'maintain', + 'keep', + 'law', + 'bueno', + 'dharma', + 'kama', + 'artha', + 'moksa', + 'karma', + 'diyo', + 'deya', + 'divaa', + 'deepa', + 'deepam', + 'deepak', + 'diwali', + 'oil lamp', + 'selfish' + ]), + Emoji( + name: 'fire extinguisher', + char: '\u{1F9EF}', + shortName: 'fire_extinguisher', + emojiGroup: EmojiGroup.objects, + emojiSubgroup: EmojiSubgroup.household, + keywords: [ + 'uc11', + 'alarm', + 'science', + 'danger', + 'household', + 'fires', + 'alarms', + 'announce', + 'lab', + 'warn', + 'attention', + 'caution', + 'alert', + 'error', + 'panic', + 'restricted', + "don't", + 'dont', + 'dangerous' + ]), + Emoji( + name: 'oil drum', + char: '\u{1F6E2}\u{FE0F}', + shortName: 'oil', + emojiGroup: EmojiGroup.travelPlaces, + emojiSubgroup: EmojiSubgroup.transportGround, + keywords: [ + 'drum', + 'oil', + 'uc7', + 'jewish', + 'hannukah', + 'hanukkah', + 'israel' + ]), + Emoji( + name: 'money with wings', + char: '\u{1F4B8}', + shortName: 'money_with_wings', + emojiGroup: EmojiGroup.objects, + emojiSubgroup: EmojiSubgroup.money, + keywords: [ + 'bank', + 'banknote', + 'bill', + 'dollar', + 'fly', + 'money', + 'note', + 'wings', + 'uc6', + 'money', + 'vacation', + 'boys night', + 'coins', + 'las vegas', + 'rich', + 'purchase', + 'cash', + 'dollars', + 'dollar', + 'bucks', + 'currency', + 'funds', + 'payment', + 'money face', + 'reward', + 'thief', + 'bank', + 'benjamins', + 'argent', + 'dinero', + 'i soldi', + 'Geld', + 'guys night', + 'vegas', + 'grand', + 'expensive', + 'fancy', + 'buy', + 'shop', + 'spend' + ]), + Emoji( + name: 'dollar banknote', + char: '\u{1F4B5}', + shortName: 'dollar', + emojiGroup: EmojiGroup.objects, + emojiSubgroup: EmojiSubgroup.money, + keywords: [ + 'bank', + 'banknote', + 'bill', + 'currency', + 'dollar', + 'money', + 'note', + 'uc6', + 'money', + 'coins', + 'rich', + 'purchase', + 'cash', + 'dollars', + 'dollar', + 'bucks', + 'currency', + 'funds', + 'payment', + 'money face', + 'reward', + 'thief', + 'bank', + 'benjamins', + 'argent', + 'dinero', + 'i soldi', + 'Geld', + 'grand', + 'expensive', + 'fancy', + 'buy', + 'shop', + 'spend' + ]), + Emoji( + name: 'yen banknote', + char: '\u{1F4B4}', + shortName: 'yen', + emojiGroup: EmojiGroup.objects, + emojiSubgroup: EmojiSubgroup.money, + keywords: [ + 'bank', + 'banknote', + 'bill', + 'currency', + 'money', + 'note', + 'yen', + 'uc6', + 'money', + 'coins', + 'rich', + 'purchase', + 'cash', + 'dollars', + 'dollar', + 'bucks', + 'currency', + 'funds', + 'payment', + 'money face', + 'reward', + 'thief', + 'bank', + 'benjamins', + 'argent', + 'dinero', + 'i soldi', + 'Geld', + 'grand', + 'expensive', + 'fancy', + 'buy', + 'shop', + 'spend' + ]), + Emoji( + name: 'euro banknote', + char: '\u{1F4B6}', + shortName: 'euro', + emojiGroup: EmojiGroup.objects, + emojiSubgroup: EmojiSubgroup.money, + keywords: [ + 'bank', + 'banknote', + 'bill', + 'currency', + 'euro', + 'money', + 'note', + 'uc6', + 'money', + 'coins', + 'rich', + 'purchase', + 'cash', + 'dollars', + 'dollar', + 'bucks', + 'currency', + 'funds', + 'payment', + 'money face', + 'reward', + 'thief', + 'bank', + 'benjamins', + 'argent', + 'dinero', + 'i soldi', + 'Geld', + 'grand', + 'expensive', + 'fancy', + 'buy', + 'shop', + 'spend' + ]), + Emoji( + name: 'pound banknote', + char: '\u{1F4B7}', + shortName: 'pound', + emojiGroup: EmojiGroup.objects, + emojiSubgroup: EmojiSubgroup.money, + keywords: [ + 'bank', + 'banknote', + 'bill', + 'currency', + 'money', + 'note', + 'pound', + 'uc6', + 'money', + 'coins', + 'rich', + 'purchase', + 'cash', + 'dollars', + 'dollar', + 'bucks', + 'currency', + 'funds', + 'payment', + 'money face', + 'reward', + 'thief', + 'bank', + 'benjamins', + 'argent', + 'dinero', + 'i soldi', + 'Geld', + 'grand', + 'expensive', + 'fancy', + 'buy', + 'shop', + 'spend' + ]), + Emoji( + name: 'coin', + char: '\u{1FA99}', + shortName: 'coin', + emojiGroup: EmojiGroup.objects, + emojiSubgroup: EmojiSubgroup.money, + keywords: [ + 'uc13', + 'money', + 'coins', + 'purchase', + 'cash', + 'dollars', + 'dollar', + 'bucks', + 'currency', + 'funds', + 'payment', + 'money face', + 'reward', + 'thief', + 'bank', + 'benjamins', + 'argent', + 'dinero', + 'i soldi', + 'Geld', + 'buy', + 'shop', + 'spend' + ]), + Emoji( + name: 'money bag', + char: '\u{1F4B0}', + shortName: 'moneybag', + emojiGroup: EmojiGroup.objects, + emojiSubgroup: EmojiSubgroup.money, + keywords: [ + 'bag', + 'dollar', + 'money', + 'moneybag', + 'uc6', + 'wedding', + 'bag', + 'money', + 'award', + 'pirate', + 'bling', + 'coins', + 'donald trump', + 'rich', + 'weddings', + 'marriage', + 'newlywed', + 'bride', + 'groome', + 'groom', + 'married', + 'marry', + 'swag', + 'cash', + 'dollars', + 'dollar', + 'bucks', + 'currency', + 'funds', + 'payment', + 'money face', + 'reward', + 'thief', + 'bank', + 'benjamins', + 'argent', + 'dinero', + 'i soldi', + 'Geld', + 'awards', + 'prize', + 'prizes', + 'trophy', + 'trophies', + 'spot', + 'best', + 'champion', + 'hero', + 'jewels', + 'gems', + 'jewel', + 'jewelry', + 'treasure', + 'trump', + 'grand', + 'expensive', + 'fancy' + ]), + Emoji( + name: 'credit card', + char: '\u{1F4B3}', + shortName: 'credit_card', + emojiGroup: EmojiGroup.objects, + emojiSubgroup: EmojiSubgroup.money, + keywords: [ + 'bank', + 'card', + 'credit', + 'money', + 'uc6', + 'money', + 'vacation', + 'boys night', + 'rich', + 'purchase', + 'cash', + 'dollars', + 'dollar', + 'bucks', + 'currency', + 'funds', + 'payment', + 'money face', + 'reward', + 'thief', + 'bank', + 'benjamins', + 'argent', + 'dinero', + 'i soldi', + 'Geld', + 'guys night', + 'grand', + 'expensive', + 'fancy', + 'buy', + 'shop', + 'spend' + ]), + Emoji( + name: 'gem stone', + char: '\u{1F48E}', + shortName: 'gem', + emojiGroup: EmojiGroup.objects, + emojiSubgroup: EmojiSubgroup.clothing, + keywords: [ + 'diamond', + 'gem', + 'jewel', + 'uc6', + 'bling', + 'minecraft', + 'diamond', + 'rich', + 'sparkle', + 'jewels', + 'gems', + 'jewel', + 'jewelry', + 'treasure', + 'grand', + 'expensive', + 'fancy', + 'bright', + 'shine', + 'twinkle' + ]), + Emoji( + name: 'balance scale', + char: '\u{2696}\u{FE0F}', + shortName: 'scales', + emojiGroup: EmojiGroup.objects, + emojiSubgroup: EmojiSubgroup.tool, + keywords: [ + 'Libra', + 'balance', + 'justice', + 'scales', + 'tool', + 'weight', + 'zodiac', + 'uc4', + 'tool', + 'science', + 'poison', + 'measure', + 'tools', + 'lab', + 'toxic', + 'toxins' + ]), + Emoji( + name: 'ladder', + char: '\u{1FA9C}', + shortName: 'ladder', + emojiGroup: EmojiGroup.objects, + emojiSubgroup: EmojiSubgroup.tool, + keywords: ['uc13', 'tool', 'household', 'climb', 'tools']), + Emoji( + name: 'toolbox', + char: '\u{1F9F0}', + shortName: 'toolbox', + emojiGroup: EmojiGroup.objects, + emojiSubgroup: EmojiSubgroup.tool, + keywords: ['uc11', 'tool', 'household', 'build', 'tools']), + Emoji( + name: 'screwdriver', + char: '\u{1FA9B}', + shortName: 'screwdriver', + emojiGroup: EmojiGroup.objects, + emojiSubgroup: EmojiSubgroup.tool, + keywords: [ + 'uc13', + 'tool', + 'household', + 'build', + 'phillips', + 'tools', + 'flat tip', + 'flat head', + 'spiral ratchet', + 'ratchet', + 'slot head', + 'torx', + 'star head', + 'hex key' + ]), + Emoji( + name: 'wrench', + char: '\u{1F527}', + shortName: 'wrench', + emojiGroup: EmojiGroup.objects, + emojiSubgroup: EmojiSubgroup.tool, + keywords: [ + 'spanner', + 'tool', + 'wrench', + 'uc6', + 'tool', + 'steel', + 'build', + 'tools', + 'metal' + ]), + Emoji( + name: 'hammer', + char: '\u{1F528}', + shortName: 'hammer', + emojiGroup: EmojiGroup.objects, + emojiSubgroup: EmojiSubgroup.tool, + keywords: [ + 'tool', + 'uc6', + 'tool', + 'weapon', + 'steel', + 'household', + 'build', + 'tools', + 'weapons', + 'metal' + ]), + Emoji( + name: 'hammer and pick', + char: '\u{2692}\u{FE0F}', + shortName: 'hammer_pick', + emojiGroup: EmojiGroup.objects, + emojiSubgroup: EmojiSubgroup.tool, + keywords: [ + 'hammer', + 'pick', + 'tool', + 'uc4', + 'tool', + 'weapon', + 'minecraft', + 'steel', + 'build', + 'chop', + 'tools', + 'weapons', + 'metal' + ]), + Emoji( + name: 'hammer and wrench', + char: '\u{1F6E0}\u{FE0F}', + shortName: 'tools', + emojiGroup: EmojiGroup.objects, + emojiSubgroup: EmojiSubgroup.tool, + keywords: [ + 'hammer', + 'spanner', + 'tool', + 'wrench', + 'uc7', + 'tool', + 'steel', + 'build', + 'tools', + 'metal' + ]), + Emoji( + name: 'pick', + char: '\u{26CF}\u{FE0F}', + shortName: 'pick', + emojiGroup: EmojiGroup.objects, + emojiSubgroup: EmojiSubgroup.tool, + keywords: [ + 'mining', + 'tool', + 'uc5', + 'tool', + 'weapon', + 'farm', + 'viking', + 'minecraft', + 'killer', + 'steel', + 'build', + 'chop', + 'shinobi', + 'tools', + 'weapons', + 'knight', + 'savage', + 'scary clown', + 'metal', + 'samurai' + ]), + Emoji( + name: 'nut and bolt', + char: '\u{1F529}', + shortName: 'nut_and_bolt', + emojiGroup: EmojiGroup.objects, + emojiSubgroup: EmojiSubgroup.tool, + keywords: [ + 'bolt', + 'nut', + 'tool', + 'uc6', + 'tool', + 'nutcase', + 'steel', + 'build', + 'tools', + 'metal' + ]), + Emoji( + name: 'gear', + char: '\u{2699}\u{FE0F}', + shortName: 'gear', + emojiGroup: EmojiGroup.objects, + emojiSubgroup: EmojiSubgroup.tool, + keywords: ['tool', 'uc4', 'tool', 'steel', 'tools', 'metal']), + Emoji( + name: 'brick', + char: '\u{1F9F1}', + shortName: 'bricks', + emojiGroup: EmojiGroup.travelPlaces, + emojiSubgroup: EmojiSubgroup.placeBuilding, + keywords: [ + 'uc11', + 'house', + 'trap', + 'donald trump', + 'private', + 'household', + 'build', + 'block', + 'houses', + 'apartment', + 'apartments', + 'casa', + 'maison', + 'home', + 'trump', + 'прив', + 'privé', + 'privado', + 'reserved' + ]), + Emoji( + name: 'chains', + char: '\u{26D3}\u{FE0F}', + shortName: 'chains', + emojiGroup: EmojiGroup.objects, + emojiSubgroup: EmojiSubgroup.tool, + keywords: [ + 'chain', + 'uc5', + 'tool', + 'halloween', + 'steel', + 'shinobi', + 'tools', + 'samhain', + 'metal', + 'samurai' + ]), + Emoji( + name: 'hook', + char: '\u{1FA9D}', + shortName: 'hook', + emojiGroup: EmojiGroup.objects, + emojiSubgroup: EmojiSubgroup.tool, + keywords: ['uc13', 'tool', 'steel', 'tools', 'metal']), + Emoji( + name: 'knot', + char: '\u{1FAA2}', + shortName: 'knot', + emojiGroup: EmojiGroup.activities, + emojiSubgroup: EmojiSubgroup.artsCrafts, + keywords: [ + 'uc13', + 'boat', + 'rock climbing', + 'build', + 'rope', + 'tie', + 'boats', + 'boating', + 'climber', + 'cordage', + 'hitches', + 'bends', + 'splices', + 'loop' + ]), + Emoji( + name: 'magnet', + char: '\u{1F9F2}', + shortName: 'magnet', + emojiGroup: EmojiGroup.objects, + emojiSubgroup: EmojiSubgroup.tool, + keywords: ['uc11', 'science', 'magnet', 'household', 'lab']), + Emoji( + name: 'pistol', + char: '\u{1F52B}', + shortName: 'gun', + emojiGroup: EmojiGroup.objects, + emojiSubgroup: EmojiSubgroup.tool, + keywords: [ + 'gun', + 'handgun', + 'revolver', + 'tool', + 'weapon', + 'uc6', + 'weapon', + 'angry', + 'dead', + 'gun', + 'sarcastic', + 'deadpool', + 'danger', + 'soldier', + 'texas', + 'summer', + 'killer', + 'hunt', + 'shot', + 'war', + 'independence day', + 'weapons', + 'upset', + 'pissed', + 'pissed off', + 'unhappy', + 'frustrated', + 'anger', + 'rage', + 'frustration', + 'furious', + 'mad', + 'death', + 'die', + 'dying', + 'fart', + 'goth', + 'grave', + 'headstone', + 'horror', + 'hurt', + 'kill', + 'murder', + 'tomb', + 'toot', + 'died', + 'guns', + 'trigger', + 'sarcasm', + 'warn', + 'attention', + 'caution', + 'alert', + 'error', + 'panic', + 'restricted', + "don't", + 'dont', + 'dangerous', + 'weekend', + 'savage', + 'scary clown', + '4th of july' + ]), + Emoji( + name: 'bomb', + char: '\u{1F4A3}', + shortName: 'bomb', + emojiGroup: EmojiGroup.smileysEmotion, + emojiSubgroup: EmojiSubgroup.emotion, + keywords: [ + 'comic', + 'uc6', + 'weapon', + 'dead', + 'blast', + 'explosion', + 'deadpool', + 'power', + 'danger', + 'minecraft', + 'throw', + 'killer', + 'war', + 'weapons', + 'death', + 'die', + 'dying', + 'fart', + 'goth', + 'grave', + 'headstone', + 'horror', + 'hurt', + 'kill', + 'murder', + 'tomb', + 'toot', + 'died', + 'boom', + 'explode', + 'warn', + 'attention', + 'caution', + 'alert', + 'error', + 'panic', + 'restricted', + "don't", + 'dont', + 'dangerous', + 'savage', + 'scary clown' + ]), + Emoji( + name: 'firecracker', + char: '\u{1F9E8}', + shortName: 'firecracker', + emojiGroup: EmojiGroup.activities, + emojiSubgroup: EmojiSubgroup.event, + keywords: [ + 'uc11', + 'weapon', + 'blast', + 'danger', + 'chinese', + 'throw', + 'war', + 'independence day', + 'shinobi', + 'weapons', + 'boom', + 'warn', + 'attention', + 'caution', + 'alert', + 'error', + 'panic', + 'restricted', + "don't", + 'dont', + 'dangerous', + 'chinois', + 'asian', + 'chine', + '4th of july', + 'samurai' + ]), + Emoji( + name: 'axe', + char: '\u{1FA93}', + shortName: 'axe', + emojiGroup: EmojiGroup.objects, + emojiSubgroup: EmojiSubgroup.tool, + keywords: [ + 'uc12', + 'tool', + 'weapon', + 'halloween', + 'danger', + 'killer', + 'steel', + 'chopper', + 'knives', + 'chop', + 'tools', + 'weapons', + 'samhain', + 'warn', + 'attention', + 'caution', + 'alert', + 'error', + 'panic', + 'restricted', + "don't", + 'dont', + 'dangerous', + 'savage', + 'scary clown', + 'metal', + 'hatchet', + 'adz', + 'tomahawk' + ]), + Emoji( + name: 'carpentry saw', + char: '\u{1FA9A}', + shortName: 'carpentry_saw', + emojiGroup: EmojiGroup.objects, + emojiSubgroup: EmojiSubgroup.tool, + keywords: [ + 'uc13', + 'tool', + 'weapon', + 'steel', + 'chop', + 'tools', + 'weapons', + 'metal' + ]), + Emoji( + name: 'kitchen knife', + char: '\u{1F52A}', + shortName: 'knife', + emojiGroup: EmojiGroup.foodDrink, + emojiSubgroup: EmojiSubgroup.dishware, + keywords: [ + 'cooking', + 'hocho', + 'knife', + 'tool', + 'weapon', + 'uc6', + 'tool', + 'weapon', + 'blood', + 'danger', + 'cutlery', + 'minecraft', + 'crazy', + 'killer', + 'hunt', + 'steel', + 'utensils', + 'knives', + 'chop', + 'tools', + 'weapons', + 'sangre', + 'sang', + 'blut', + 'sangue', + 'warn', + 'attention', + 'caution', + 'alert', + 'error', + 'panic', + 'restricted', + "don't", + 'dont', + 'dangerous', + 'dish', + 'weird', + 'awkward', + 'insane', + 'wild', + 'savage', + 'scary clown', + 'metal' + ]), + Emoji( + name: 'dagger', + char: '\u{1F5E1}\u{FE0F}', + shortName: 'dagger', + emojiGroup: EmojiGroup.objects, + emojiSubgroup: EmojiSubgroup.tool, + keywords: [ + 'knife', + 'weapon', + 'uc7', + 'weapon', + 'halloween', + 'blood', + 'viking', + 'killer', + 'hunt', + 'steel', + 'knives', + 'weapons', + 'samhain', + 'sangre', + 'sang', + 'blut', + 'sangue', + 'knight', + 'savage', + 'scary clown', + 'metal' + ]), + Emoji( + name: 'crossed swords', + char: '\u{2694}\u{FE0F}', + shortName: 'crossed_swords', + emojiGroup: EmojiGroup.objects, + emojiSubgroup: EmojiSubgroup.tool, + keywords: [ + 'crossed', + 'swords', + 'weapon', + 'uc4', + 'weapon', + 'japan', + 'dead', + 'deadpool', + 'danger', + 'viking', + 'minecraft', + 'killer', + 'steel', + 'war', + 'knives', + 'shinobi', + 'weapons', + 'japanese', + 'ninja', + 'death', + 'die', + 'dying', + 'fart', + 'goth', + 'grave', + 'headstone', + 'horror', + 'hurt', + 'kill', + 'murder', + 'tomb', + 'toot', + 'died', + 'warn', + 'attention', + 'caution', + 'alert', + 'error', + 'panic', + 'restricted', + "don't", + 'dont', + 'dangerous', + 'knight', + 'savage', + 'scary clown', + 'metal', + 'samurai' + ]), + Emoji( + name: 'shield', + char: '\u{1F6E1}\u{FE0F}', + shortName: 'shield', + emojiGroup: EmojiGroup.objects, + emojiSubgroup: EmojiSubgroup.tool, + keywords: [ + 'weapon', + 'uc7', + 'harry potter', + 'viking', + 'minecraft', + 'knight' + ]), + Emoji( + name: 'cigarette', + char: '\u{1F6AC}', + shortName: 'smoking', + emojiGroup: EmojiGroup.objects, + emojiSubgroup: EmojiSubgroup.otherObject, + keywords: [ + 'smoking', + 'uc6', + 'drugs', + 'smoking', + 'danger', + 'poison', + 'killer', + 'drug', + 'narcotics', + 'smoke', + 'cigarette', + 'puff', + 'warn', + 'attention', + 'caution', + 'alert', + 'error', + 'panic', + 'restricted', + "don't", + 'dont', + 'dangerous', + 'toxic', + 'toxins', + 'savage', + 'scary clown' + ]), + Emoji( + name: 'coffin', + char: '\u{26B0}\u{FE0F}', + shortName: 'coffin', + emojiGroup: EmojiGroup.objects, + emojiSubgroup: EmojiSubgroup.otherObject, + keywords: [ + 'death', + 'uc4', + 'halloween', + 'dead', + 'rip', + 'condolence', + 'killer', + 'war', + 'covid', + 'samhain', + 'death', + 'die', + 'dying', + 'fart', + 'goth', + 'grave', + 'headstone', + 'horror', + 'hurt', + 'kill', + 'murder', + 'tomb', + 'toot', + 'died', + 'rest in peace', + 'compassion', + 'savage', + 'scary clown' + ]), + Emoji( + name: 'headstone', + char: '\u{1FAA6}', + shortName: 'headstone', + emojiGroup: EmojiGroup.objects, + emojiSubgroup: EmojiSubgroup.otherObject, + keywords: [ + 'uc13', + 'halloween', + 'dead', + 'killer', + 'war', + 'memorial', + 'covid', + 'tombstone', + 'samhain', + 'death', + 'die', + 'dying', + 'fart', + 'goth', + 'grave', + 'headstone', + 'horror', + 'hurt', + 'kill', + 'murder', + 'tomb', + 'toot', + 'died', + 'savage', + 'scary clown' + ]), + Emoji( + name: 'funeral urn', + char: '\u{26B1}\u{FE0F}', + shortName: 'urn', + emojiGroup: EmojiGroup.objects, + emojiSubgroup: EmojiSubgroup.otherObject, + keywords: [ + 'ashes', + 'death', + 'funeral', + 'urn', + 'uc4', + 'halloween', + 'dead', + 'rip', + 'condolence', + 'covid', + 'samhain', + 'death', + 'die', + 'dying', + 'fart', + 'goth', + 'grave', + 'headstone', + 'horror', + 'hurt', + 'kill', + 'murder', + 'tomb', + 'toot', + 'died', + 'rest in peace', + 'compassion' + ]), + Emoji( + name: 'amphora', + char: '\u{1F3FA}', + shortName: 'amphora', + emojiGroup: EmojiGroup.foodDrink, + emojiSubgroup: EmojiSubgroup.dishware, + keywords: [ + 'Aquarius', + 'cooking', + 'drink', + 'jug', + 'tool', + 'weapon', + 'zodiac', + 'uc8', + 'bling', + 'jewels', + 'gems', + 'jewel', + 'jewelry', + 'treasure' + ]), + Emoji( + name: 'magic wand', + char: '\u{1FA84}', + shortName: 'magic_wand', + emojiGroup: EmojiGroup.activities, + emojiSubgroup: EmojiSubgroup.game, + keywords: [ + 'uc13', + 'harry potter', + 'disney', + 'wizard', + 'cartoon', + 'Sorcerer', + 'Sorceress', + 'witch' + ]), + Emoji( + name: 'crystal ball', + char: '\u{1F52E}', + shortName: 'crystal_ball', + emojiGroup: EmojiGroup.activities, + emojiSubgroup: EmojiSubgroup.game, + keywords: [ + 'ball', + 'crystal', + 'fairy tale', + 'fantasy', + 'fortune', + 'tool', + 'uc6', + 'halloween', + 'ball', + 'harry potter', + 'magic', + 'disney', + 'bling', + 'mirror', + 'future', + 'mystery', + 'wizard', + 'fantasy', + 'energy', + 'snow white', + 'samhain', + 'balls', + 'ballon', + 'spell', + 'genie', + 'magical', + 'cartoon', + 'jewels', + 'gems', + 'jewel', + 'jewelry', + 'treasure', + 'Sorcerer', + 'Sorceress', + 'witch' + ]), + Emoji( + name: 'prayer beads', + char: '\u{1F4FF}', + shortName: 'prayer_beads', + emojiGroup: EmojiGroup.objects, + emojiSubgroup: EmojiSubgroup.clothing, + keywords: [ + 'beads', + 'clothing', + 'necklace', + 'prayer', + 'religion', + 'uc8', + 'religion', + 'rosary', + 'pray', + 'jesus', + 'bible', + 'prayer', + 'praying', + 'prayers', + 'grateful', + 'sorry', + 'heaven', + 'bless', + 'faith', + 'holy', + 'spirit', + 'hopeful', + 'blessed', + 'preach', + 'offering' + ]), + Emoji( + name: 'nazar amulet', + char: '\u{1F9FF}', + shortName: 'nazar_amulet', + emojiGroup: EmojiGroup.activities, + emojiSubgroup: EmojiSubgroup.game, + keywords: [ + 'uc11', + 'game', + 'eyes', + 'luck', + 'magic', + 'evil', + 'fantasy', + 'eye bead', + 'games', + 'gaming', + 'eye', + 'eyebrow', + 'good luck', + 'lucky', + 'spell', + 'genie', + 'magical', + 'imp', + 'demon', + 'devil', + 'naughty', + 'devilish', + 'diablo', + 'diable', + 'satan', + 'Nazar Boncuğu', + 'Munçuk', + 'turkish' + ]), + Emoji( + name: 'barber pole', + char: '\u{1F488}', + shortName: 'barber', + emojiGroup: EmojiGroup.travelPlaces, + emojiSubgroup: EmojiSubgroup.placeOther, + keywords: ['barber', 'haircut', 'pole', 'uc6']), + Emoji( + name: 'alembic', + char: '\u{2697}\u{FE0F}', + shortName: 'alembic', + emojiGroup: EmojiGroup.objects, + emojiSubgroup: EmojiSubgroup.science, + keywords: [ + 'chemistry', + 'tool', + 'uc4', + 'classroom', + 'science', + 'poison', + 'minecraft', + 'measure', + 'school', + 'teach', + 'learn', + 'study', + 'college', + 'degree', + 'education', + 'homework', + 'student', + 'teacher', + 'university', + 'test', + 'learning', + 'lab', + 'toxic', + 'toxins' + ]), + Emoji( + name: 'telescope', + char: '\u{1F52D}', + shortName: 'telescope', + emojiGroup: EmojiGroup.objects, + emojiSubgroup: EmojiSubgroup.science, + keywords: [ + 'science', + 'tool', + 'uc6', + 'space', + 'star', + 'science', + 'search', + 'outer space', + 'galaxy', + 'universe', + 'nasa', + 'spaceship', + 'stars', + 'lab', + 'look', + 'find', + 'looking', + 'see' + ]), + Emoji( + name: 'microscope', + char: '\u{1F52C}', + shortName: 'microscope', + emojiGroup: EmojiGroup.objects, + emojiSubgroup: EmojiSubgroup.science, + keywords: [ + 'science', + 'tool', + 'uc6', + 'classroom', + 'science', + 'school', + 'teach', + 'learn', + 'study', + 'college', + 'degree', + 'education', + 'homework', + 'student', + 'teacher', + 'university', + 'test', + 'learning', + 'lab' + ]), + Emoji( + name: 'hole', + char: '\u{1F573}\u{FE0F}', + shortName: 'hole', + emojiGroup: EmojiGroup.smileysEmotion, + emojiSubgroup: EmojiSubgroup.emotion, + keywords: ['hole', 'uc7', 'trap']), + Emoji( + name: 'window', + char: '\u{1FA9F}', + shortName: 'window', + emojiGroup: EmojiGroup.objects, + emojiSubgroup: EmojiSubgroup.household, + keywords: [ + 'uc13', + 'house', + 'sky', + 'day', + 'household', + 'daydream', + 'houses', + 'apartment', + 'apartments', + 'casa', + 'maison', + 'home' + ]), + Emoji( + name: 'adhesive bandage', + char: '\u{1FA79}', + shortName: 'adhesive_bandage', + emojiGroup: EmojiGroup.objects, + emojiSubgroup: EmojiSubgroup.medical, + keywords: [ + 'uc12', + 'health', + '911', + 'nurse', + 'bandaid', + 'medical', + 'medicine', + 'doctor', + 'emergency', + 'injury' + ]), + Emoji( + name: 'stethoscope', + char: '\u{1FA7A}', + shortName: 'stethoscope', + emojiGroup: EmojiGroup.objects, + emojiSubgroup: EmojiSubgroup.medical, + keywords: [ + 'uc12', + 'health', + '911', + 'nurse', + 'heart', + 'auscultation', + 'covid', + 'medical', + 'medicine', + 'doctor', + 'emergency', + 'injury', + 'hearts', + 'serce', + 'corazón', + 'coração', + 'coeur' + ]), + Emoji( + name: 'pill', + char: '\u{1F48A}', + shortName: 'pill', + emojiGroup: EmojiGroup.objects, + emojiSubgroup: EmojiSubgroup.medical, + keywords: [ + 'doctor', + 'medicine', + 'sick', + 'uc6', + 'drugs', + 'health', + 'nurse', + 'poison', + 'killer', + 'medical', + 'drug', + 'narcotics', + 'medicine', + 'doctor', + 'toxic', + 'toxins', + 'savage', + 'scary clown' + ]), + Emoji( + name: 'syringe', + char: '\u{1F489}', + shortName: 'syringe', + emojiGroup: EmojiGroup.objects, + emojiSubgroup: EmojiSubgroup.medical, + keywords: [ + 'doctor', + 'medicine', + 'needle', + 'shot', + 'sick', + 'tool', + 'uc6', + 'weapon', + 'drugs', + 'dead', + 'health', + '911', + 'blood', + 'nurse', + 'poison', + 'killer', + 'shot', + 'needle', + 'bleed', + 'covid', + 'medical', + 'weapons', + 'drug', + 'narcotics', + 'death', + 'die', + 'dying', + 'fart', + 'goth', + 'grave', + 'headstone', + 'horror', + 'hurt', + 'kill', + 'murder', + 'tomb', + 'toot', + 'died', + 'medicine', + 'doctor', + 'emergency', + 'injury', + 'sangre', + 'sang', + 'blut', + 'sangue', + 'toxic', + 'toxins', + 'savage', + 'scary clown', + 'donation', + 'menstruation' + ]), + Emoji( + name: 'drop of blood', + char: '\u{1FA78}', + shortName: 'drop_of_blood', + emojiGroup: EmojiGroup.objects, + emojiSubgroup: EmojiSubgroup.medical, + keywords: [ + 'uc12', + 'body', + 'science', + 'health', + '911', + 'blood', + 'vampire', + 'shot', + 'bleed', + 'medical', + 'body part', + 'anatomy', + 'lab', + 'medicine', + 'doctor', + 'emergency', + 'injury', + 'sangre', + 'sang', + 'blut', + 'sangue', + 'dracula', + 'donation', + 'menstruation' + ]), + Emoji( + name: 'dna', + char: '\u{1F9EC}', + shortName: 'dna', + emojiGroup: EmojiGroup.objects, + emojiSubgroup: EmojiSubgroup.science, + keywords: [ + 'uc11', + 'family', + 'body', + 'science', + 'blood', + 'history', + 'future', + 'deoxyribonucleic acid', + 'medical', + 'families', + 'group', + 'brother', + 'sister', + 'daughter', + 'son', + 'together', + 'sibling', + 'twins', + 'brothers', + 'sisters', + 'body part', + 'anatomy', + 'lab', + 'sangre', + 'sang', + 'blut', + 'sangue', + 'ancient', + 'old', + 'gene', + 'genetic code', + 'RNA', + 'chromosome', + 'heredity', + 'nucleic acid' + ]), + Emoji( + name: 'microbe', + char: '\u{1F9A0}', + shortName: 'microbe', + emojiGroup: EmojiGroup.animalsNature, + emojiSubgroup: EmojiSubgroup.animalBug, + keywords: [ + 'uc11', + 'body', + 'science', + 'stinky', + 'bacteria', + 'booger', + 'virus', + 'covid', + 'medical', + 'body part', + 'anatomy', + 'lab', + 'smell', + 'stink', + 'odor', + 'microorganism', + 'bacterium', + 'corona' + ]), + Emoji( + name: 'petri dish', + char: '\u{1F9EB}', + shortName: 'petri_dish', + emojiGroup: EmojiGroup.objects, + emojiSubgroup: EmojiSubgroup.science, + keywords: [ + 'uc11', + 'classroom', + 'science', + 'bacteria', + 'petrie dish', + 'mushroom', + 'virus', + 'covid', + 'medical', + 'school', + 'teach', + 'learn', + 'study', + 'college', + 'degree', + 'education', + 'homework', + 'student', + 'teacher', + 'university', + 'test', + 'learning', + 'lab', + 'microorganism', + 'bacterium', + 'petri plate', + 'cell culture dish', + 'moss', + 'corona' + ]), + Emoji( + name: 'test tube', + char: '\u{1F9EA}', + shortName: 'test_tube', + emojiGroup: EmojiGroup.objects, + emojiSubgroup: EmojiSubgroup.science, + keywords: [ + 'uc11', + 'science', + 'poison', + 'test-tube', + 'measure', + 'medical', + 'lab', + 'toxic', + 'toxins', + 'culture tube', + 'sample tube' + ]), + Emoji( + name: 'thermometer', + char: '\u{1F321}\u{FE0F}', + shortName: 'thermometer', + emojiGroup: EmojiGroup.travelPlaces, + emojiSubgroup: EmojiSubgroup.skyWeather, + keywords: [ + 'weather', + 'uc7', + 'science', + 'health', + 'hot', + 'virus', + 'measure', + 'medical', + 'lab', + 'medicine', + 'doctor', + 'heat', + 'warm', + 'caliente', + 'chaud', + 'heiß', + 'corona' + ]), + Emoji( + name: 'mouse trap', + char: '\u{1FAA4}', + shortName: 'mouse_trap', + emojiGroup: EmojiGroup.objects, + emojiSubgroup: EmojiSubgroup.household, + keywords: ['uc13', 'trap', 'household', 'rodent']), + Emoji( + name: 'broom', + char: '\u{1F9F9}', + shortName: 'broom', + emojiGroup: EmojiGroup.objects, + emojiSubgroup: EmojiSubgroup.household, + keywords: ['uc11', 'clean', 'sweep', 'household', 'dust', 'mop']), + Emoji( + name: 'basket', + char: '\u{1F9FA}', + shortName: 'basket', + emojiGroup: EmojiGroup.objects, + emojiSubgroup: EmojiSubgroup.household, + keywords: [ + 'uc11', + 'household', + 'sew', + 'knit', + 'embroider', + 'stitch', + 'repair', + 'crochet', + 'alter', + 'seamstress', + 'fix' + ]), + Emoji( + name: 'sewing needle', + char: '\u{1FAA1}', + shortName: 'sewing_needle', + emojiGroup: EmojiGroup.activities, + emojiSubgroup: EmojiSubgroup.artsCrafts, + keywords: [ + 'uc13', + 'bathroom', + 'needle', + 'household', + 'sew', + 'knit', + 'embroider', + 'stitch', + 'repair', + 'crochet', + 'alter', + 'seamstress', + 'fix' + ]), + Emoji( + name: 'roll of paper', + char: '\u{1F9FB}', + shortName: 'roll_of_paper', + emojiGroup: EmojiGroup.objects, + emojiSubgroup: EmojiSubgroup.household, + keywords: [ + 'uc11', + 'bathroom', + 'diarrhea', + 'shit', + 'clean', + 'household', + 'shits', + 'the shits', + 'poop', + 'turd', + 'feces', + 'pile', + 'merde', + 'butthole', + 'caca', + 'crap', + 'dirty', + 'pooo', + 'mess', + 'brown', + 'poopoo' + ]), + Emoji( + name: 'toilet', + char: '\u{1F6BD}', + shortName: 'toilet', + emojiGroup: EmojiGroup.objects, + emojiSubgroup: EmojiSubgroup.household, + keywords: [ + 'toilet', + 'uc6', + 'bathroom', + 'sick', + 'diarrhea', + 'shit', + 'private', + 'household', + 'throne', + 'barf', + 'vomit', + 'throw up', + 'puke', + 'get well', + 'cough', + 'puking', + 'barfing', + 'malade', + 'spew', + 'shits', + 'the shits', + 'poop', + 'turd', + 'feces', + 'pile', + 'merde', + 'butthole', + 'caca', + 'crap', + 'dirty', + 'pooo', + 'mess', + 'brown', + 'poopoo', + 'прив', + 'privé', + 'privado', + 'reserved' + ]), + Emoji( + name: 'plunger', + char: '\u{1FAA0}', + shortName: 'plunger', + emojiGroup: EmojiGroup.objects, + emojiSubgroup: EmojiSubgroup.household, + keywords: [ + 'uc13', + 'bathroom', + 'shit', + 'household', + 'poop', + 'turd', + 'feces', + 'pile', + 'merde', + 'butthole', + 'caca', + 'crap', + 'dirty', + 'pooo', + 'mess', + 'brown', + 'poopoo' + ]), + Emoji( + name: 'bucket', + char: '\u{1FAA3}', + shortName: 'bucket', + emojiGroup: EmojiGroup.objects, + emojiSubgroup: EmojiSubgroup.household, + keywords: ['uc13', 'household', 'pail', 'vessel']), + Emoji( + name: 'potable water', + char: '\u{1F6B0}', + shortName: 'potable_water', + emojiGroup: EmojiGroup.symbols, + emojiSubgroup: EmojiSubgroup.transportSign, + keywords: [ + 'drinking', + 'potable', + 'water', + 'uc6', + 'drip', + 'water', + 'household', + 'water drop' + ]), + Emoji( + name: 'shower', + char: '\u{1F6BF}', + shortName: 'shower', + emojiGroup: EmojiGroup.objects, + emojiSubgroup: EmojiSubgroup.household, + keywords: [ + 'water', + 'uc6', + 'bathroom', + 'clean', + 'wash', + 'shower', + 'bathe', + 'bathing', + 'washing' + ]), + Emoji( + name: 'bathtub', + char: '\u{1F6C1}', + shortName: 'bathtub', + emojiGroup: EmojiGroup.objects, + emojiSubgroup: EmojiSubgroup.household, + keywords: [ + 'bath', + 'uc6', + 'bathroom', + 'steam', + 'clean', + 'wash', + 'steaming', + 'piping', + 'shower', + 'bathe', + 'bathing', + 'washing' + ]), + Emoji( + name: 'person taking bath', + char: '\u{1F6C0}', + shortName: 'bath', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personResting, + keywords: [ + 'bath', + 'bathtub', + 'uc6', + 'diversity', + 'bathroom', + 'steam', + 'clean', + 'wash', + 'spa', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'steaming', + 'piping', + 'shower', + 'bathe', + 'bathing', + 'washing', + 'relax', + 'sauna' + ]), + Emoji( + name: 'person taking bath: light skin tone', + char: '\u{1F6C0}\u{1F3FB}', + shortName: 'bath_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personResting, + keywords: [ + 'bath', + 'bathtub', + 'light skin tone', + 'uc8', + 'diversity', + 'bathroom', + 'steam', + 'clean', + 'wash', + 'spa', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'steaming', + 'piping', + 'shower', + 'bathe', + 'bathing', + 'washing', + 'relax', + 'sauna' + ], + modifiable: true), + Emoji( + name: 'person taking bath: medium-light skin tone', + char: '\u{1F6C0}\u{1F3FC}', + shortName: 'bath_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personResting, + keywords: [ + 'bath', + 'bathtub', + 'medium-light skin tone', + 'uc8', + 'diversity', + 'bathroom', + 'steam', + 'clean', + 'wash', + 'spa', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'steaming', + 'piping', + 'shower', + 'bathe', + 'bathing', + 'washing', + 'relax', + 'sauna' + ], + modifiable: true), + Emoji( + name: 'person taking bath: medium skin tone', + char: '\u{1F6C0}\u{1F3FD}', + shortName: 'bath_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personResting, + keywords: [ + 'bath', + 'bathtub', + 'medium skin tone', + 'uc8', + 'diversity', + 'bathroom', + 'steam', + 'clean', + 'wash', + 'spa', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'steaming', + 'piping', + 'shower', + 'bathe', + 'bathing', + 'washing', + 'relax', + 'sauna' + ], + modifiable: true), + Emoji( + name: 'person taking bath: medium-dark skin tone', + char: '\u{1F6C0}\u{1F3FE}', + shortName: 'bath_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personResting, + keywords: [ + 'bath', + 'bathtub', + 'medium-dark skin tone', + 'uc8', + 'diversity', + 'bathroom', + 'steam', + 'clean', + 'wash', + 'spa', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'steaming', + 'piping', + 'shower', + 'bathe', + 'bathing', + 'washing', + 'relax', + 'sauna' + ], + modifiable: true), + Emoji( + name: 'person taking bath: dark skin tone', + char: '\u{1F6C0}\u{1F3FF}', + shortName: 'bath_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personResting, + keywords: [ + 'bath', + 'bathtub', + 'dark skin tone', + 'uc8', + 'diversity', + 'bathroom', + 'steam', + 'clean', + 'wash', + 'spa', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'steaming', + 'piping', + 'shower', + 'bathe', + 'bathing', + 'washing', + 'relax', + 'sauna' + ], + modifiable: true), + Emoji( + name: 'toothbrush', + char: '\u{1FAA5}', + shortName: 'toothbrush', + emojiGroup: EmojiGroup.objects, + emojiSubgroup: EmojiSubgroup.household, + keywords: ['uc13', 'bathroom']), + Emoji( + name: 'soap', + char: '\u{1F9FC}', + shortName: 'soap', + emojiGroup: EmojiGroup.objects, + emojiSubgroup: EmojiSubgroup.household, + keywords: [ + 'uc11', + 'bathroom', + 'health', + 'pink', + 'clean', + 'wash', + 'household', + 'dishes', + 'savon', + 'covid', + 'medicine', + 'doctor', + 'rose', + 'shower', + 'bathe', + 'bathing', + 'washing' + ]), + Emoji( + name: 'razor', + char: '\u{1FA92}', + shortName: 'razor', + emojiGroup: EmojiGroup.objects, + emojiSubgroup: EmojiSubgroup.household, + keywords: [ + 'uc12', + 'weapon', + 'mustache', + 'beard', + 'blade', + 'weapons', + 'shave', + 'trim' + ]), + Emoji( + name: 'sponge', + char: '\u{1F9FD}', + shortName: 'sponge', + emojiGroup: EmojiGroup.objects, + emojiSubgroup: EmojiSubgroup.household, + keywords: [ + 'uc11', + 'bathroom', + 'clean', + 'wash', + 'household', + 'dishes', + 'shower', + 'bathe', + 'bathing', + 'washing' + ]), + Emoji( + name: 'lotion bottle', + char: '\u{1F9F4}', + shortName: 'squeeze_bottle', + emojiGroup: EmojiGroup.objects, + emojiSubgroup: EmojiSubgroup.household, + keywords: [ + 'uc11', + 'bathroom', + 'beach', + 'clean', + 'wash', + 'picnic', + 'household', + 'dishes', + 'savon', + 'covid', + 'shower', + 'bathe', + 'bathing', + 'washing' + ]), + Emoji( + name: 'bellhop bell', + char: '\u{1F6CE}\u{FE0F}', + shortName: 'bellhop', + emojiGroup: EmojiGroup.travelPlaces, + emojiSubgroup: EmojiSubgroup.hotel, + keywords: [ + 'bell', + 'bellhop', + 'hotel', + 'uc7', + 'vacation', + 'help', + 'suitcase', + 'hotel', + 'carry-on', + 'vacancy', + 'no vacancy' + ]), + Emoji( + name: 'key', + char: '\u{1F511}', + shortName: 'key', + emojiGroup: EmojiGroup.objects, + emojiSubgroup: EmojiSubgroup.lock, + keywords: [ + 'lock', + 'password', + 'uc6', + 'lock', + 'household', + 'locks', + 'key', + 'keys' + ]), + Emoji( + name: 'old key', + char: '\u{1F5DD}\u{FE0F}', + shortName: 'key2', + emojiGroup: EmojiGroup.objects, + emojiSubgroup: EmojiSubgroup.lock, + keywords: [ + 'clue', + 'key', + 'lock', + 'old', + 'uc7', + 'lock', + 'harry potter', + 'household', + 'locks', + 'key', + 'keys' + ]), + Emoji( + name: 'door', + char: '\u{1F6AA}', + shortName: 'door', + emojiGroup: EmojiGroup.objects, + emojiSubgroup: EmojiSubgroup.household, + keywords: [ + 'door', + 'uc6', + 'minecraft', + 'hotel', + 'household', + 'vacancy', + 'no vacancy' + ]), + Emoji( + name: 'chair', + char: '\u{1FA91}', + shortName: 'chair', + emojiGroup: EmojiGroup.objects, + emojiSubgroup: EmojiSubgroup.household, + keywords: [ + 'uc12', + 'household', + 'sit', + 'seat', + 'throne', + 'sitting', + 'kneel', + 'kneeling', + 'bench', + 'sedia', + 'Stuhl', + 'chaise', + 'silla', + 'armchair' + ]), + Emoji( + name: 'mirror', + char: '\u{1FA9E}', + shortName: 'mirror', + emojiGroup: EmojiGroup.objects, + emojiSubgroup: EmojiSubgroup.household, + keywords: [ + 'uc13', + 'bathroom', + 'beautiful', + 'disney', + 'mirror', + 'household', + 'snow white', + 'cute', + 'pretty', + 'adorable', + 'adore', + 'beauty', + 'cutie', + 'babe', + 'lovely', + 'cartoon' + ]), + Emoji( + name: 'couch and lamp', + char: '\u{1F6CB}\u{FE0F}', + shortName: 'couch', + emojiGroup: EmojiGroup.objects, + emojiSubgroup: EmojiSubgroup.household, + keywords: [ + 'couch', + 'hotel', + 'lamp', + 'uc7', + 'tired', + 'light', + 'sofa', + 'hotel', + 'household', + 'seat', + 'sleepy', + 'sleep', + 'dormi', + 'pillow', + 'blanket', + 'exhausted', + 'lamp', + 'light bulb', + 'flashlight', + 'spotlight', + 'illuminate', + 'lightbulb', + 'lighting', + 'luce', + 'licht', + 'lumière', + 'luz', + 'vacancy', + 'no vacancy', + 'bench', + 'sedia', + 'Stuhl', + 'chaise', + 'silla', + 'armchair' + ]), + Emoji( + name: 'bed', + char: '\u{1F6CF}\u{FE0F}', + shortName: 'bed', + emojiGroup: EmojiGroup.objects, + emojiSubgroup: EmojiSubgroup.household, + keywords: [ + 'hotel', + 'sleep', + 'uc7', + 'tired', + 'hotel', + 'household', + 'sleepy', + 'sleep', + 'dormi', + 'pillow', + 'blanket', + 'exhausted', + 'vacancy', + 'no vacancy' + ]), + Emoji( + name: 'person in bed', + char: '\u{1F6CC}', + shortName: 'sleeping_accommodation', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personResting, + keywords: [ + 'hotel', + 'sleep', + 'uc7', + 'diversity', + 'tired', + 'lazy', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'sleepy', + 'sleep', + 'dormi', + 'pillow', + 'blanket', + 'exhausted' + ]), + Emoji( + name: 'person in bed: light skin tone', + char: '\u{1F6CC}\u{1F3FB}', + shortName: 'person_in_bed_tone1', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personResting, + keywords: [ + 'hotel', + 'light skin tone', + 'sleep', + 'uc8', + 'diversity', + 'tired', + 'lazy', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'sleepy', + 'sleep', + 'dormi', + 'pillow', + 'blanket', + 'exhausted' + ], + modifiable: true), + Emoji( + name: 'person in bed: medium-light skin tone', + char: '\u{1F6CC}\u{1F3FC}', + shortName: 'person_in_bed_tone2', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personResting, + keywords: [ + 'hotel', + 'medium-light skin tone', + 'sleep', + 'uc8', + 'diversity', + 'tired', + 'lazy', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'sleepy', + 'sleep', + 'dormi', + 'pillow', + 'blanket', + 'exhausted' + ], + modifiable: true), + Emoji( + name: 'person in bed: medium skin tone', + char: '\u{1F6CC}\u{1F3FD}', + shortName: 'person_in_bed_tone3', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personResting, + keywords: [ + 'hotel', + 'medium skin tone', + 'sleep', + 'uc8', + 'diversity', + 'tired', + 'lazy', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'sleepy', + 'sleep', + 'dormi', + 'pillow', + 'blanket', + 'exhausted' + ], + modifiable: true), + Emoji( + name: 'person in bed: medium-dark skin tone', + char: '\u{1F6CC}\u{1F3FE}', + shortName: 'person_in_bed_tone4', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personResting, + keywords: [ + 'hotel', + 'medium-dark skin tone', + 'sleep', + 'uc8', + 'diversity', + 'tired', + 'lazy', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'sleepy', + 'sleep', + 'dormi', + 'pillow', + 'blanket', + 'exhausted' + ], + modifiable: true), + Emoji( + name: 'person in bed: dark skin tone', + char: '\u{1F6CC}\u{1F3FF}', + shortName: 'person_in_bed_tone5', + emojiGroup: EmojiGroup.peopleBody, + emojiSubgroup: EmojiSubgroup.personResting, + keywords: [ + 'dark skin tone', + 'hotel', + 'sleep', + 'uc8', + 'diversity', + 'tired', + 'lazy', + 'diverse', + 'modifier', + 'modifiers', + 'equality', + 'sleepy', + 'sleep', + 'dormi', + 'pillow', + 'blanket', + 'exhausted' + ], + modifiable: true), + Emoji( + name: 'teddy bear', + char: '\u{1F9F8}', + shortName: 'teddy_bear', + emojiGroup: EmojiGroup.activities, + emojiSubgroup: EmojiSubgroup.game, + keywords: [ + 'uc11', + 'animal', + 'baby', + 'play', + 'gummy', + 'household', + 'stuffed animal', + 'toy', + 'animals', + 'animal kingdom', + 'kid', + 'babies', + 'infant', + 'infants', + 'crying kid', + 'bebe', + 'little', + 'petite', + 'bambino', + 'doudou' + ]), + Emoji( + name: 'framed picture', + char: '\u{1F5BC}\u{FE0F}', + shortName: 'frame_photo', + emojiGroup: EmojiGroup.activities, + emojiSubgroup: EmojiSubgroup.artsCrafts, + keywords: [ + 'art', + 'frame', + 'museum', + 'painting', + 'picture', + 'uc7', + 'theatre', + 'travel', + 'vacation', + 'painting', + 'image', + 'instagram', + 'household', + 'theater', + 'craft', + 'drama', + 'monet', + 'painter', + 'arts' + ]), + Emoji( + name: 'shopping bags', + char: '\u{1F6CD}\u{FE0F}', + shortName: 'shopping_bags', + emojiGroup: EmojiGroup.objects, + emojiSubgroup: EmojiSubgroup.clothing, + keywords: [ + 'bag', + 'hotel', + 'shopping', + 'uc7', + 'bag', + 'gift', + 'birthday', + 'happy birthday', + 'celebrate', + 'rich', + 'purchase', + 'swag', + 'present', + 'cadeau', + 'bows', + 'presents', + 'birth', + 'cumpleaños', + 'anniversaire', + 'bday', + 'Bon anniversaire', + 'joyeux anniversaire', + 'buon compleanno', + 'feliz cumpleaños', + 'alles Gute zum Geburtstag', + 'feliz Aniversário', + 'Gratulerer med dagen', + 'celebration', + 'event', + 'celebrating', + 'festa', + 'parties', + 'events', + 'new years', + 'new year', + 'fiesta', + 'fete', + 'newyear', + 'party', + 'festive', + 'festival', + 'yolo', + 'festejar', + 'grand', + 'expensive', + 'fancy', + 'buy', + 'shop', + 'spend' + ]), + Emoji( + name: 'shopping cart', + char: '\u{1F6D2}', + shortName: 'shopping_cart', + emojiGroup: EmojiGroup.objects, + emojiSubgroup: EmojiSubgroup.household, + keywords: [ + 'cart', + 'shopping', + 'trolley', + 'uc9', + 'food', + 'purchase', + 'foods', + 'eat', + 'meal', + 'comida', + 'nourriture', + 'eats', + 'groceries', + 'grocery', + 'hungry', + 'tasty', + 'mmm', + 'yummy', + 'feed', + 'hunger', + 'buy', + 'shop', + 'spend' + ]), + Emoji( + name: 'wrapped gift', + char: '\u{1F381}', + shortName: 'gift', + emojiGroup: EmojiGroup.activities, + emojiSubgroup: EmojiSubgroup.event, + keywords: [ + 'box', + 'celebration', + 'gift', + 'present', + 'wrapped', + 'uc6', + 'holidays', + 'love', + 'gift', + 'birthday', + 'christmas', + 'happy birthday', + 'celebrate', + 'holiday', + 'i love you', + 'te amo', + "je t'aime", + 'anniversary', + 'lovin', + 'amour', + 'aimer', + 'amor', + 'valentines day', + 'enamour', + 'lovey', + 'present', + 'cadeau', + 'bows', + 'presents', + 'birth', + 'cumpleaños', + 'anniversaire', + 'bday', + 'navidad', + 'xmas', + 'noel', + 'merry christmas', + 'Bon anniversaire', + 'joyeux anniversaire', + 'buon compleanno', + 'feliz cumpleaños', + 'alles Gute zum Geburtstag', + 'feliz Aniversário', + 'Gratulerer med dagen', + 'celebration', + 'event', + 'celebrating', + 'festa', + 'parties', + 'events', + 'new years', + 'new year', + 'fiesta', + 'fete', + 'newyear', + 'party', + 'festive', + 'festival', + 'yolo', + 'festejar' + ]), + Emoji( + name: 'balloon', + char: '\u{1F388}', + shortName: 'balloon', + emojiGroup: EmojiGroup.activities, + emojiSubgroup: EmojiSubgroup.event, + keywords: [ + 'celebration', + 'uc6', + 'holidays', + 'baby', + 'birthday', + 'good', + 'balloons', + 'happy birthday', + 'celebrate', + 'independence day', + 'sperm', + 'toy', + 'holiday', + 'kid', + 'babies', + 'infant', + 'infants', + 'crying kid', + 'bebe', + 'little', + 'petite', + 'bambino', + 'birth', + 'cumpleaños', + 'anniversaire', + 'bday', + 'good job', + 'nice', + 'well done', + 'bravo', + 'congratulations', + 'congrats', + 'Bon anniversaire', + 'joyeux anniversaire', + 'buon compleanno', + 'feliz cumpleaños', + 'alles Gute zum Geburtstag', + 'feliz Aniversário', + 'Gratulerer med dagen', + 'celebration', + 'event', + 'celebrating', + 'festa', + 'parties', + 'events', + 'new years', + 'new year', + 'fiesta', + 'fete', + 'newyear', + 'party', + 'festive', + 'festival', + 'yolo', + 'festejar', + '4th of july' + ]), + Emoji( + name: 'carp streamer', + char: '\u{1F38F}', + shortName: 'flags', + emojiGroup: EmojiGroup.activities, + emojiSubgroup: EmojiSubgroup.event, + keywords: [ + 'carp', + 'celebration', + 'streamer', + 'uc6', + 'japan', + 'japanese', + 'ninja' + ]), + Emoji( + name: 'ribbon', + char: '\u{1F380}', + shortName: 'ribbon', + emojiGroup: EmojiGroup.activities, + emojiSubgroup: EmojiSubgroup.event, + keywords: [ + 'celebration', + 'uc6', + 'holidays', + 'love', + 'gift', + 'birthday', + 'accessories', + 'happy birthday', + 'celebrate', + 'rich', + 'holiday', + 'i love you', + 'te amo', + "je t'aime", + 'anniversary', + 'lovin', + 'amour', + 'aimer', + 'amor', + 'valentines day', + 'enamour', + 'lovey', + 'present', + 'cadeau', + 'bows', + 'presents', + 'birth', + 'cumpleaños', + 'anniversaire', + 'bday', + 'Bon anniversaire', + 'joyeux anniversaire', + 'buon compleanno', + 'feliz cumpleaños', + 'alles Gute zum Geburtstag', + 'feliz Aniversário', + 'Gratulerer med dagen', + 'celebration', + 'event', + 'celebrating', + 'festa', + 'parties', + 'events', + 'new years', + 'new year', + 'fiesta', + 'fete', + 'newyear', + 'party', + 'festive', + 'festival', + 'yolo', + 'festejar', + 'grand', + 'expensive', + 'fancy' + ]), + Emoji( + name: 'confetti ball', + char: '\u{1F38A}', + shortName: 'confetti_ball', + emojiGroup: EmojiGroup.activities, + emojiSubgroup: EmojiSubgroup.event, + keywords: [ + 'ball', + 'celebration', + 'confetti', + 'uc6', + 'happy', + 'birthday', + 'cheers', + 'girls night', + 'boys night', + 'happy birthday', + 'confetti', + 'celebrate', + 'glitter', + 'hooray', + 'cheek', + 'cheeky', + 'excited', + 'feliz', + 'heureux', + 'cheerful', + 'delighted', + 'ecstatic', + 'elated', + 'glad', + 'joy', + 'merry', + 'birth', + 'cumpleaños', + 'anniversaire', + 'bday', + 'gān bēi', + 'Na zdravi', + 'Proost', + 'Prost', + 'Sláinte', + 'Cin cin', + 'Kanpai', + 'Na zdrowie', + 'Saúde', + 'На здоровье', + 'Salud', + 'Skål', + 'Sei gesund', + 'santé', + 'ladies night', + 'girls only', + 'girlfriend', + 'guys night', + 'Bon anniversaire', + 'joyeux anniversaire', + 'buon compleanno', + 'feliz cumpleaños', + 'alles Gute zum Geburtstag', + 'feliz Aniversário', + 'Gratulerer med dagen', + 'celebration', + 'event', + 'celebrating', + 'festa', + 'parties', + 'events', + 'new years', + 'new year', + 'fiesta', + 'fete', + 'newyear', + 'party', + 'festive', + 'festival', + 'yolo', + 'festejar' + ]), + Emoji( + name: 'party popper', + char: '\u{1F389}', + shortName: 'tada', + emojiGroup: EmojiGroup.activities, + emojiSubgroup: EmojiSubgroup.event, + keywords: [ + 'celebration', + 'party', + 'popper', + 'tada', + 'uc6', + 'holidays', + 'happy', + 'birthday', + 'cheers', + 'good', + 'girls night', + 'boys night', + 'happy birthday', + 'confetti', + 'celebrate', + 'glitter', + 'bingo', + 'fame', + 'fun', + 'independence day', + 'holiday', + 'hooray', + 'cheek', + 'cheeky', + 'excited', + 'feliz', + 'heureux', + 'cheerful', + 'delighted', + 'ecstatic', + 'elated', + 'glad', + 'joy', + 'merry', + 'birth', + 'cumpleaños', + 'anniversaire', + 'bday', + 'gān bēi', + 'Na zdravi', + 'Proost', + 'Prost', + 'Sláinte', + 'Cin cin', + 'Kanpai', + 'Na zdrowie', + 'Saúde', + 'На здоровье', + 'Salud', + 'Skål', + 'Sei gesund', + 'santé', + 'good job', + 'nice', + 'well done', + 'bravo', + 'congratulations', + 'congrats', + 'ladies night', + 'girls only', + 'girlfriend', + 'guys night', + 'Bon anniversaire', + 'joyeux anniversaire', + 'buon compleanno', + 'feliz cumpleaños', + 'alles Gute zum Geburtstag', + 'feliz Aniversário', + 'Gratulerer med dagen', + 'celebration', + 'event', + 'celebrating', + 'festa', + 'parties', + 'events', + 'new years', + 'new year', + 'fiesta', + 'fete', + 'newyear', + 'party', + 'festive', + 'festival', + 'yolo', + 'festejar', + 'famous', + 'celebrity', + '4th of july' + ]), + Emoji( + name: 'piñata', + char: '\u{1FA85}', + shortName: 'piñata', + emojiGroup: EmojiGroup.activities, + emojiSubgroup: EmojiSubgroup.game, + keywords: [ + 'uc13', + 'mexican', + 'birthday', + 'happy birthday', + 'celebrate', + 'pinata', + 'mexico', + 'cinco de mayo', + 'español', + 'birth', + 'cumpleaños', + 'anniversaire', + 'bday', + 'Bon anniversaire', + 'joyeux anniversaire', + 'buon compleanno', + 'feliz cumpleaños', + 'alles Gute zum Geburtstag', + 'feliz Aniversário', + 'Gratulerer med dagen', + 'celebration', + 'event', + 'celebrating', + 'festa', + 'parties', + 'events', + 'new years', + 'new year', + 'fiesta', + 'fete', + 'newyear', + 'party', + 'festive', + 'festival', + 'yolo', + 'festejar', + 'papier-mâché', + 'paper mache', + 'pignatta', + 'dahi handi', + 'fer cagar el tió', + 'suikawari', + 'pukpok-palayok', + 'cartonería' + ]), + Emoji( + name: 'nesting dolls', + char: '\u{1FA86}', + shortName: 'nesting_dolls', + emojiGroup: EmojiGroup.activities, + emojiSubgroup: EmojiSubgroup.game, + keywords: [ + 'uc13', + 'russian', + 'toy', + 'matryoshka dolls', + 'babushka dolls', + 'stacking dolls', + 'russian dolls' + ]), + Emoji( + name: 'Japanese dolls', + char: '\u{1F38E}', + shortName: 'dolls', + emojiGroup: EmojiGroup.activities, + emojiSubgroup: EmojiSubgroup.event, + keywords: [ + 'Japanese', + 'celebration', + 'doll', + 'festival', + 'uc6', + 'japan', + 'japanese', + 'ninja' + ]), + Emoji( + name: 'red paper lantern', + char: '\u{1F3EE}', + shortName: 'izakaya_lantern', + emojiGroup: EmojiGroup.objects, + emojiSubgroup: EmojiSubgroup.lightVideo, + keywords: [ + 'bar', + 'lantern', + 'light', + 'red', + 'uc6', + 'japan', + 'japanese', + 'ninja' + ]), + Emoji( + name: 'wind chime', + char: '\u{1F390}', + shortName: 'wind_chime', + emojiGroup: EmojiGroup.activities, + emojiSubgroup: EmojiSubgroup.event, + keywords: [ + 'bell', + 'celebration', + 'chime', + 'wind', + 'uc6', + 'japan', + 'japanese', + 'ninja' + ]), + Emoji( + name: 'red envelope', + char: '\u{1F9E7}', + shortName: 'red_envelope', + emojiGroup: EmojiGroup.activities, + emojiSubgroup: EmojiSubgroup.event, + keywords: [ + 'uc11', + 'gift', + 'chinese', + 'present', + 'cadeau', + 'bows', + 'presents', + 'chinois', + 'asian', + 'chine' + ]), + Emoji( + name: 'envelope', + char: '\u{2709}\u{FE0F}', + shortName: 'envelope', + emojiGroup: EmojiGroup.objects, + emojiSubgroup: EmojiSubgroup.mail, + keywords: [ + 'email', + 'letter', + 'uc1', + 'write', + 'mail', + 'envelope', + 'work', + 'writing', + 'email', + 'post', + 'post office', + 'letter', + 'message', + 'offer', + 'office' + ]), + Emoji( + name: 'envelope with arrow', + char: '\u{1F4E9}', + shortName: 'envelope_with_arrow', + emojiGroup: EmojiGroup.objects, + emojiSubgroup: EmojiSubgroup.mail, + keywords: [ + 'arrow', + 'down', + 'e-mail', + 'email', + 'envelope', + 'letter', + 'mail', + 'outgoing', + 'sent', + 'uc6', + 'mail', + 'envelope', + 'download', + 'work', + 'email', + 'post', + 'post office', + 'letter', + 'message', + 'offer', + 'office' + ]), + Emoji( + name: 'incoming envelope', + char: '\u{1F4E8}', + shortName: 'incoming_envelope', + emojiGroup: EmojiGroup.objects, + emojiSubgroup: EmojiSubgroup.mail, + keywords: [ + 'e-mail', + 'email', + 'envelope', + 'incoming', + 'letter', + 'mail', + 'receive', + 'uc6', + 'mail', + 'envelope', + 'work', + 'email', + 'post', + 'post office', + 'letter', + 'message', + 'offer', + 'office' + ]), + Emoji( + name: 'e-mail', + char: '\u{1F4E7}', + shortName: 'e-mail', + emojiGroup: EmojiGroup.objects, + emojiSubgroup: EmojiSubgroup.mail, + keywords: [ + 'email', + 'letter', + 'mail', + 'uc6', + 'classroom', + 'mail', + 'business', + 'envelope', + 'work', + 'school', + 'teach', + 'learn', + 'study', + 'college', + 'degree', + 'education', + 'homework', + 'student', + 'teacher', + 'university', + 'test', + 'learning', + 'email', + 'post', + 'post office', + 'letter', + 'message', + 'offer', + 'office' + ]), + Emoji( + name: 'love letter', + char: '\u{1F48C}', + shortName: 'love_letter', + emojiGroup: EmojiGroup.smileysEmotion, + emojiSubgroup: EmojiSubgroup.emotion, + keywords: [ + 'heart', + 'letter', + 'love', + 'mail', + 'uc6', + 'love', + 'mail', + 'envelope', + 'pink', + 'i love you', + 'te amo', + "je t'aime", + 'anniversary', + 'lovin', + 'amour', + 'aimer', + 'amor', + 'valentines day', + 'enamour', + 'lovey', + 'email', + 'post', + 'post office', + 'letter', + 'message', + 'offer', + 'rose' + ]), + Emoji( + name: 'inbox tray', + char: '\u{1F4E5}', + shortName: 'inbox_tray', + emojiGroup: EmojiGroup.objects, + emojiSubgroup: EmojiSubgroup.mail, + keywords: [ + 'box', + 'inbox', + 'letter', + 'mail', + 'receive', + 'tray', + 'uc6', + 'business', + 'envelope', + 'work', + 'letter', + 'message', + 'offer', + 'office' + ]), + Emoji( + name: 'outbox tray', + char: '\u{1F4E4}', + shortName: 'outbox_tray', + emojiGroup: EmojiGroup.objects, + emojiSubgroup: EmojiSubgroup.mail, + keywords: [ + 'box', + 'letter', + 'mail', + 'outbox', + 'sent', + 'tray', + 'uc6', + 'business', + 'work', + 'office' + ]), + Emoji( + name: 'package', + char: '\u{1F4E6}', + shortName: 'package', + emojiGroup: EmojiGroup.objects, + emojiSubgroup: EmojiSubgroup.mail, + keywords: [ + 'box', + 'parcel', + 'uc6', + 'classroom', + 'gift', + 'mail', + 'moving', + 'work', + 'school', + 'teach', + 'learn', + 'study', + 'college', + 'degree', + 'education', + 'homework', + 'student', + 'teacher', + 'university', + 'test', + 'learning', + 'present', + 'cadeau', + 'bows', + 'presents', + 'email', + 'post', + 'post office', + 'office' + ]), + Emoji( + name: 'label', + char: '\u{1F3F7}\u{FE0F}', + shortName: 'label', + emojiGroup: EmojiGroup.objects, + emojiSubgroup: EmojiSubgroup.bookPaper, + keywords: ['label', 'uc7', 'discount', 'price', 'sale', 'bargain']), + Emoji( + name: 'closed mailbox with lowered flag', + char: '\u{1F4EA}', + shortName: 'mailbox_closed', + emojiGroup: EmojiGroup.objects, + emojiSubgroup: EmojiSubgroup.mail, + keywords: [ + 'closed', + 'lowered', + 'mail', + 'mailbox', + 'postbox', + 'uc6', + 'mail', + 'envelope', + 'household', + 'email', + 'post', + 'post office', + 'letter', + 'message', + 'offer' + ]), + Emoji( + name: 'closed mailbox with raised flag', + char: '\u{1F4EB}', + shortName: 'mailbox', + emojiGroup: EmojiGroup.objects, + emojiSubgroup: EmojiSubgroup.mail, + keywords: [ + 'closed', + 'mail', + 'mailbox', + 'postbox', + 'uc6', + 'mail', + 'envelope', + 'email', + 'post', + 'post office', + 'letter', + 'message', + 'offer' + ]), + Emoji( + name: 'open mailbox with raised flag', + char: '\u{1F4EC}', + shortName: 'mailbox_with_mail', + emojiGroup: EmojiGroup.objects, + emojiSubgroup: EmojiSubgroup.mail, + keywords: [ + 'mail', + 'mailbox', + 'open', + 'postbox', + 'uc6', + 'mail', + 'envelope', + 'household', + 'email', + 'post', + 'post office', + 'letter', + 'message', + 'offer' + ]), + Emoji( + name: 'open mailbox with lowered flag', + char: '\u{1F4ED}', + shortName: 'mailbox_with_no_mail', + emojiGroup: EmojiGroup.objects, + emojiSubgroup: EmojiSubgroup.mail, + keywords: [ + 'lowered', + 'mail', + 'mailbox', + 'open', + 'postbox', + 'uc6', + 'mail', + 'envelope', + 'empty', + 'email', + 'post', + 'post office', + 'letter', + 'message', + 'offer' + ]), + Emoji( + name: 'postbox', + char: '\u{1F4EE}', + shortName: 'postbox', + emojiGroup: EmojiGroup.objects, + emojiSubgroup: EmojiSubgroup.mail, + keywords: [ + 'mail', + 'mailbox', + 'uc6', + 'mail', + 'envelope', + 'email', + 'post', + 'post office', + 'letter', + 'message', + 'offer' + ]), + Emoji( + name: 'postal horn', + char: '\u{1F4EF}', + shortName: 'postal_horn', + emojiGroup: EmojiGroup.objects, + emojiSubgroup: EmojiSubgroup.sound, + keywords: [ + 'horn', + 'post', + 'postal', + 'uc6', + 'instruments', + 'music', + 'instrument', + 'singing', + 'concert', + 'jaz', + 'listen', + 'singer', + 'song', + 'musique' + ]), + Emoji( + name: 'placard', + char: '\u{1FAA7}', + shortName: 'placard', + emojiGroup: EmojiGroup.objects, + emojiSubgroup: EmojiSubgroup.otherObject, + keywords: [ + 'uc13', + 'peace', + 'protest', + 'peace out', + 'peace sign', + 'blm', + 'demonstration' + ]), + Emoji( + name: 'scroll', + char: '\u{1F4DC}', + shortName: 'scroll', + emojiGroup: EmojiGroup.objects, + emojiSubgroup: EmojiSubgroup.bookPaper, + keywords: [ + 'paper', + 'uc6', + 'classroom', + 'harry potter', + 'document', + 'scroll', + 'history', + 'work', + 'school', + 'teach', + 'learn', + 'study', + 'college', + 'degree', + 'education', + 'homework', + 'student', + 'teacher', + 'university', + 'test', + 'learning', + 'documents', + 'ancient', + 'old', + 'office' + ]), + Emoji( + name: 'page with curl', + char: '\u{1F4C3}', + shortName: 'page_with_curl', + emojiGroup: EmojiGroup.objects, + emojiSubgroup: EmojiSubgroup.bookPaper, + keywords: [ + 'curl', + 'document', + 'page', + 'uc6', + 'classroom', + 'write', + 'document', + 'envelope', + 'work', + 'school', + 'teach', + 'learn', + 'study', + 'college', + 'degree', + 'education', + 'homework', + 'student', + 'teacher', + 'university', + 'test', + 'learning', + 'writing', + 'documents', + 'letter', + 'message', + 'offer', + 'office' + ]), + Emoji( + name: 'page facing up', + char: '\u{1F4C4}', + shortName: 'page_facing_up', + emojiGroup: EmojiGroup.objects, + emojiSubgroup: EmojiSubgroup.bookPaper, + keywords: [ + 'document', + 'page', + 'uc6', + 'classroom', + 'write', + 'document', + 'work', + 'school', + 'teach', + 'learn', + 'study', + 'college', + 'degree', + 'education', + 'homework', + 'student', + 'teacher', + 'university', + 'test', + 'learning', + 'writing', + 'documents', + 'office' + ]), + Emoji( + name: 'bookmark tabs', + char: '\u{1F4D1}', + shortName: 'bookmark_tabs', + emojiGroup: EmojiGroup.objects, + emojiSubgroup: EmojiSubgroup.bookPaper, + keywords: [ + 'bookmark', + 'mark', + 'marker', + 'tabs', + 'uc6', + 'classroom', + 'write', + 'document', + 'work', + 'school', + 'teach', + 'learn', + 'study', + 'college', + 'degree', + 'education', + 'homework', + 'student', + 'teacher', + 'university', + 'test', + 'learning', + 'writing', + 'documents', + 'office' + ]), + Emoji( + name: 'receipt', + char: '\u{1F9FE}', + shortName: 'receipt', + emojiGroup: EmojiGroup.objects, + emojiSubgroup: EmojiSubgroup.money, + keywords: [ + 'uc11', + 'document', + 'business', + 'discount', + 'history', + 'price', + 'rich', + 'purchase', + 'household', + 'restaurant', + 'invoice', + 'documents', + 'sale', + 'bargain', + 'ancient', + 'old', + 'grand', + 'expensive', + 'fancy', + 'buy', + 'shop', + 'spend' + ]), + Emoji( + name: 'bar chart', + char: '\u{1F4CA}', + shortName: 'bar_chart', + emojiGroup: EmojiGroup.objects, + emojiSubgroup: EmojiSubgroup.office, + keywords: [ + 'bar', + 'chart', + 'graph', + 'uc6', + 'classroom', + 'business', + 'data', + 'measure', + 'work', + 'school', + 'teach', + 'learn', + 'study', + 'college', + 'degree', + 'education', + 'homework', + 'student', + 'teacher', + 'university', + 'test', + 'learning', + 'graph', + 'office' + ]), + Emoji( + name: 'chart increasing', + char: '\u{1F4C8}', + shortName: 'chart_with_upwards_trend', + emojiGroup: EmojiGroup.objects, + emojiSubgroup: EmojiSubgroup.office, + keywords: [ + 'chart', + 'graph', + 'growth', + 'trend', + 'upward', + 'uc6', + 'classroom', + 'business', + 'data', + 'measure', + 'work', + 'school', + 'teach', + 'learn', + 'study', + 'college', + 'degree', + 'education', + 'homework', + 'student', + 'teacher', + 'university', + 'test', + 'learning', + 'graph', + 'office' + ]), + Emoji( + name: 'chart decreasing', + char: '\u{1F4C9}', + shortName: 'chart_with_downwards_trend', + emojiGroup: EmojiGroup.objects, + emojiSubgroup: EmojiSubgroup.office, + keywords: [ + 'chart', + 'down', + 'graph', + 'trend', + 'uc6', + 'classroom', + 'business', + 'data', + 'measure', + 'work', + 'school', + 'teach', + 'learn', + 'study', + 'college', + 'degree', + 'education', + 'homework', + 'student', + 'teacher', + 'university', + 'test', + 'learning', + 'graph', + 'office' + ]), + Emoji( + name: 'spiral notepad', + char: '\u{1F5D2}\u{FE0F}', + shortName: 'notepad_spiral', + emojiGroup: EmojiGroup.objects, + emojiSubgroup: EmojiSubgroup.office, + keywords: [ + 'note', + 'pad', + 'spiral', + 'uc7', + 'classroom', + 'write', + 'business', + 'envelope', + 'work', + 'school', + 'teach', + 'learn', + 'study', + 'college', + 'degree', + 'education', + 'homework', + 'student', + 'teacher', + 'university', + 'test', + 'learning', + 'writing', + 'letter', + 'message', + 'offer', + 'office' + ]), + Emoji( + name: 'spiral calendar', + char: '\u{1F5D3}\u{FE0F}', + shortName: 'calendar_spiral', + emojiGroup: EmojiGroup.objects, + emojiSubgroup: EmojiSubgroup.office, + keywords: [ + 'calendar', + 'pad', + 'spiral', + 'uc7', + 'classroom', + 'calendar', + 'advent', + 'schedule', + 'work', + 'school', + 'teach', + 'learn', + 'study', + 'college', + 'degree', + 'education', + 'homework', + 'student', + 'teacher', + 'university', + 'test', + 'learning', + 'date', + 'sunday', + 'monday', + 'tuesday', + 'wednesday', + 'thursday', + 'friday', + 'saturday', + 'month', + 'agenda', + 'mois', + 'year', + 'today', + 'jour', + 'week', + 'when', + 'office' + ]), + Emoji( + name: 'tear-off calendar', + char: '\u{1F4C6}', + shortName: 'calendar', + emojiGroup: EmojiGroup.objects, + emojiSubgroup: EmojiSubgroup.office, + keywords: [ + 'calendar', + 'uc6', + 'classroom', + 'day', + 'calendar', + 'business', + 'schedule', + 'work', + 'school', + 'teach', + 'learn', + 'study', + 'college', + 'degree', + 'education', + 'homework', + 'student', + 'teacher', + 'university', + 'test', + 'learning', + 'date', + 'sunday', + 'monday', + 'tuesday', + 'wednesday', + 'thursday', + 'friday', + 'saturday', + 'month', + 'agenda', + 'mois', + 'year', + 'today', + 'jour', + 'week', + 'when', + 'office' + ]), + Emoji( + name: 'calendar', + char: '\u{1F4C5}', + shortName: 'date', + emojiGroup: EmojiGroup.objects, + emojiSubgroup: EmojiSubgroup.office, + keywords: [ + 'date', + 'uc6', + 'classroom', + 'calendar', + 'advent', + 'schedule', + 'work', + 'school', + 'teach', + 'learn', + 'study', + 'college', + 'degree', + 'education', + 'homework', + 'student', + 'teacher', + 'university', + 'test', + 'learning', + 'date', + 'sunday', + 'monday', + 'tuesday', + 'wednesday', + 'thursday', + 'friday', + 'saturday', + 'month', + 'agenda', + 'mois', + 'year', + 'today', + 'jour', + 'week', + 'when', + 'office' + ]), + Emoji( + name: 'wastebasket', + char: '\u{1F5D1}\u{FE0F}', + shortName: 'wastebasket', + emojiGroup: EmojiGroup.objects, + emojiSubgroup: EmojiSubgroup.office, + keywords: [ + 'wastebasket', + 'uc7', + 'classroom', + 'business', + 'trash', + 'clean', + 'empty', + 'delete', + 'household', + 'work', + 'school', + 'teach', + 'learn', + 'study', + 'college', + 'degree', + 'education', + 'homework', + 'student', + 'teacher', + 'university', + 'test', + 'learning', + 'litter', + 'trash can', + 'garbage', + 'rubbish', + 'poubelle', + 'basura', + 'office' + ]), + Emoji( + name: 'card index', + char: '\u{1F4C7}', + shortName: 'card_index', + emojiGroup: EmojiGroup.objects, + emojiSubgroup: EmojiSubgroup.office, + keywords: [ + 'card', + 'index', + 'rolodex', + 'uc6', + 'classroom', + 'business', + 'work', + 'school', + 'teach', + 'learn', + 'study', + 'college', + 'degree', + 'education', + 'homework', + 'student', + 'teacher', + 'university', + 'test', + 'learning', + 'office' + ]), + Emoji( + name: 'card file box', + char: '\u{1F5C3}\u{FE0F}', + shortName: 'card_box', + emojiGroup: EmojiGroup.objects, + emojiSubgroup: EmojiSubgroup.office, + keywords: [ + 'box', + 'card', + 'file', + 'uc7', + 'classroom', + 'business', + 'work', + 'school', + 'teach', + 'learn', + 'study', + 'college', + 'degree', + 'education', + 'homework', + 'student', + 'teacher', + 'university', + 'test', + 'learning', + 'office' + ]), + Emoji( + name: 'ballot box with ballot', + char: '\u{1F5F3}\u{FE0F}', + shortName: 'ballot_box', + emojiGroup: EmojiGroup.objects, + emojiSubgroup: EmojiSubgroup.mail, + keywords: [ + 'ballot', + 'box', + 'uc7', + 'classroom', + 'vote', + 'school', + 'teach', + 'learn', + 'study', + 'college', + 'degree', + 'education', + 'homework', + 'student', + 'teacher', + 'university', + 'test', + 'learning' + ]), + Emoji( + name: 'file cabinet', + char: '\u{1F5C4}\u{FE0F}', + shortName: 'file_cabinet', + emojiGroup: EmojiGroup.objects, + emojiSubgroup: EmojiSubgroup.office, + keywords: [ + 'cabinet', + 'file', + 'filing', + 'uc7', + 'classroom', + 'business', + 'work', + 'school', + 'teach', + 'learn', + 'study', + 'college', + 'degree', + 'education', + 'homework', + 'student', + 'teacher', + 'university', + 'test', + 'learning', + 'office' + ]), + Emoji( + name: 'clipboard', + char: '\u{1F4CB}', + shortName: 'clipboard', + emojiGroup: EmojiGroup.objects, + emojiSubgroup: EmojiSubgroup.office, + keywords: [ + 'clipboard', + 'uc6', + 'classroom', + 'write', + 'business', + 'data', + 'work', + 'school', + 'teach', + 'learn', + 'study', + 'college', + 'degree', + 'education', + 'homework', + 'student', + 'teacher', + 'university', + 'test', + 'learning', + 'writing', + 'graph', + 'office' + ]), + Emoji( + name: 'file folder', + char: '\u{1F4C1}', + shortName: 'file_folder', + emojiGroup: EmojiGroup.objects, + emojiSubgroup: EmojiSubgroup.office, + keywords: [ + 'file', + 'folder', + 'uc6', + 'classroom', + 'work', + 'school', + 'teach', + 'learn', + 'study', + 'college', + 'degree', + 'education', + 'homework', + 'student', + 'teacher', + 'university', + 'test', + 'learning', + 'office' + ]), + Emoji( + name: 'open file folder', + char: '\u{1F4C2}', + shortName: 'open_file_folder', + emojiGroup: EmojiGroup.objects, + emojiSubgroup: EmojiSubgroup.office, + keywords: [ + 'file', + 'folder', + 'open', + 'uc6', + 'classroom', + 'work', + 'school', + 'teach', + 'learn', + 'study', + 'college', + 'degree', + 'education', + 'homework', + 'student', + 'teacher', + 'university', + 'test', + 'learning', + 'office' + ]), + Emoji( + name: 'card index dividers', + char: '\u{1F5C2}\u{FE0F}', + shortName: 'dividers', + emojiGroup: EmojiGroup.objects, + emojiSubgroup: EmojiSubgroup.office, + keywords: [ + 'card', + 'dividers', + 'index', + 'uc7', + 'classroom', + 'work', + 'school', + 'teach', + 'learn', + 'study', + 'college', + 'degree', + 'education', + 'homework', + 'student', + 'teacher', + 'university', + 'test', + 'learning', + 'office' + ]), + Emoji( + name: 'rolled-up newspaper', + char: '\u{1F5DE}\u{FE0F}', + shortName: 'newspaper2', + emojiGroup: EmojiGroup.objects, + emojiSubgroup: EmojiSubgroup.bookPaper, + keywords: [ + 'news', + 'newspaper', + 'paper', + 'rolled', + 'uc7', + 'classroom', + 'write', + 'news', + 'history', + 'household', + 'work', + 'school', + 'teach', + 'learn', + 'study', + 'college', + 'degree', + 'education', + 'homework', + 'student', + 'teacher', + 'university', + 'test', + 'learning', + 'writing', + 'article', + 'ancient', + 'old', + 'office' + ]), + Emoji( + name: 'newspaper', + char: '\u{1F4F0}', + shortName: 'newspaper', + emojiGroup: EmojiGroup.objects, + emojiSubgroup: EmojiSubgroup.bookPaper, + keywords: [ + 'news', + 'paper', + 'uc6', + 'classroom', + 'write', + 'news', + 'history', + 'household', + 'work', + 'covid', + 'school', + 'teach', + 'learn', + 'study', + 'college', + 'degree', + 'education', + 'homework', + 'student', + 'teacher', + 'university', + 'test', + 'learning', + 'writing', + 'article', + 'ancient', + 'old', + 'office' + ]), + Emoji( + name: 'notebook', + char: '\u{1F4D3}', + shortName: 'notebook', + emojiGroup: EmojiGroup.objects, + emojiSubgroup: EmojiSubgroup.bookPaper, + keywords: [ + 'notebook', + 'uc6', + 'book', + 'classroom', + 'write', + 'work', + 'journal', + 'books', + 'read', + 'reading', + 'cahier', + 'livre', + 'cuaderno', + 'diary', + 'dictionary', + 'encyclopedia', + 'school', + 'teach', + 'learn', + 'study', + 'college', + 'degree', + 'education', + 'homework', + 'student', + 'teacher', + 'university', + 'test', + 'learning', + 'writing', + 'office' + ]), + Emoji( + name: 'notebook with decorative cover', + char: '\u{1F4D4}', + shortName: 'notebook_with_decorative_cover', + emojiGroup: EmojiGroup.objects, + emojiSubgroup: EmojiSubgroup.bookPaper, + keywords: [ + 'book', + 'cover', + 'decorated', + 'notebook', + 'uc6', + 'book', + 'classroom', + 'write', + 'work', + 'journal', + 'books', + 'read', + 'reading', + 'cahier', + 'livre', + 'cuaderno', + 'diary', + 'dictionary', + 'encyclopedia', + 'school', + 'teach', + 'learn', + 'study', + 'college', + 'degree', + 'education', + 'homework', + 'student', + 'teacher', + 'university', + 'test', + 'learning', + 'writing', + 'office' + ]), + Emoji( + name: 'ledger', + char: '\u{1F4D2}', + shortName: 'ledger', + emojiGroup: EmojiGroup.objects, + emojiSubgroup: EmojiSubgroup.bookPaper, + keywords: [ + 'notebook', + 'uc6', + 'classroom', + 'write', + 'work', + 'school', + 'teach', + 'learn', + 'study', + 'college', + 'degree', + 'education', + 'homework', + 'student', + 'teacher', + 'university', + 'test', + 'learning', + 'writing', + 'office' + ]), + Emoji( + name: 'closed book', + char: '\u{1F4D5}', + shortName: 'closed_book', + emojiGroup: EmojiGroup.objects, + emojiSubgroup: EmojiSubgroup.bookPaper, + keywords: [ + 'book', + 'closed', + 'uc6', + 'book', + 'classroom', + 'write', + 'bible', + 'history', + 'work', + 'books', + 'read', + 'reading', + 'cahier', + 'livre', + 'cuaderno', + 'diary', + 'dictionary', + 'encyclopedia', + 'school', + 'teach', + 'learn', + 'study', + 'college', + 'degree', + 'education', + 'homework', + 'student', + 'teacher', + 'university', + 'test', + 'learning', + 'writing', + 'ancient', + 'old', + 'office' + ]), + Emoji( + name: 'green book', + char: '\u{1F4D7}', + shortName: 'green_book', + emojiGroup: EmojiGroup.objects, + emojiSubgroup: EmojiSubgroup.bookPaper, + keywords: [ + 'book', + 'green', + 'uc6', + 'book', + 'classroom', + 'bible', + 'history', + 'work', + 'books', + 'read', + 'reading', + 'cahier', + 'livre', + 'cuaderno', + 'diary', + 'dictionary', + 'encyclopedia', + 'school', + 'teach', + 'learn', + 'study', + 'college', + 'degree', + 'education', + 'homework', + 'student', + 'teacher', + 'university', + 'test', + 'learning', + 'ancient', + 'old', + 'office' + ]), + Emoji( + name: 'blue book', + char: '\u{1F4D8}', + shortName: 'blue_book', + emojiGroup: EmojiGroup.objects, + emojiSubgroup: EmojiSubgroup.bookPaper, + keywords: [ + 'blue', + 'book', + 'uc6', + 'book', + 'classroom', + 'write', + 'bible', + 'history', + 'work', + 'books', + 'read', + 'reading', + 'cahier', + 'livre', + 'cuaderno', + 'diary', + 'dictionary', + 'encyclopedia', + 'school', + 'teach', + 'learn', + 'study', + 'college', + 'degree', + 'education', + 'homework', + 'student', + 'teacher', + 'university', + 'test', + 'learning', + 'writing', + 'ancient', + 'old', + 'office' + ]), + Emoji( + name: 'orange book', + char: '\u{1F4D9}', + shortName: 'orange_book', + emojiGroup: EmojiGroup.objects, + emojiSubgroup: EmojiSubgroup.bookPaper, + keywords: [ + 'book', + 'orange', + 'uc6', + 'book', + 'classroom', + 'write', + 'bible', + 'history', + 'work', + 'books', + 'read', + 'reading', + 'cahier', + 'livre', + 'cuaderno', + 'diary', + 'dictionary', + 'encyclopedia', + 'school', + 'teach', + 'learn', + 'study', + 'college', + 'degree', + 'education', + 'homework', + 'student', + 'teacher', + 'university', + 'test', + 'learning', + 'writing', + 'ancient', + 'old', + 'office' + ]), + Emoji( + name: 'books', + char: '\u{1F4DA}', + shortName: 'books', + emojiGroup: EmojiGroup.objects, + emojiSubgroup: EmojiSubgroup.bookPaper, + keywords: [ + 'book', + 'uc6', + 'book', + 'classroom', + 'write', + 'harry potter', + 'nerd', + 'history', + 'work', + 'books', + 'read', + 'reading', + 'cahier', + 'livre', + 'cuaderno', + 'diary', + 'dictionary', + 'encyclopedia', + 'school', + 'teach', + 'learn', + 'study', + 'college', + 'degree', + 'education', + 'homework', + 'student', + 'teacher', + 'university', + 'test', + 'learning', + 'writing', + 'smart', + 'geek', + 'serious', + 'ancient', + 'old', + 'office' + ]), + Emoji( + name: 'open book', + char: '\u{1F4D6}', + shortName: 'book', + emojiGroup: EmojiGroup.objects, + emojiSubgroup: EmojiSubgroup.bookPaper, + keywords: [ + 'book', + 'open', + 'uc6', + 'book', + 'classroom', + 'write', + 'harry potter', + 'history', + 'schedule', + 'work', + 'journal', + 'books', + 'read', + 'reading', + 'cahier', + 'livre', + 'cuaderno', + 'diary', + 'dictionary', + 'encyclopedia', + 'school', + 'teach', + 'learn', + 'study', + 'college', + 'degree', + 'education', + 'homework', + 'student', + 'teacher', + 'university', + 'test', + 'learning', + 'writing', + 'ancient', + 'old', + 'office' + ]), + Emoji( + name: 'bookmark', + char: '\u{1F516}', + shortName: 'bookmark', + emojiGroup: EmojiGroup.objects, + emojiSubgroup: EmojiSubgroup.bookPaper, + keywords: [ + 'mark', + 'uc6', + 'book', + 'discount', + 'price', + 'household', + 'books', + 'read', + 'reading', + 'cahier', + 'livre', + 'cuaderno', + 'diary', + 'dictionary', + 'encyclopedia', + 'sale', + 'bargain' + ]), + Emoji( + name: 'safety pin', + char: '\u{1F9F7}', + shortName: 'safety_pin', + emojiGroup: EmojiGroup.objects, + emojiSubgroup: EmojiSubgroup.household, + keywords: [ + 'uc11', + 'household', + 'sew', + 'work', + 'knit', + 'embroider', + 'stitch', + 'repair', + 'crochet', + 'alter', + 'seamstress', + 'fix', + 'office' + ]), + Emoji( + name: 'link', + char: '\u{1F517}', + shortName: 'link', + emojiGroup: EmojiGroup.objects, + emojiSubgroup: EmojiSubgroup.tool, + keywords: [ + 'link', + 'uc6', + 'classroom', + 'steel', + 'school', + 'teach', + 'learn', + 'study', + 'college', + 'degree', + 'education', + 'homework', + 'student', + 'teacher', + 'university', + 'test', + 'learning', + 'metal' + ]), + Emoji( + name: 'paperclip', + char: '\u{1F4CE}', + shortName: 'paperclip', + emojiGroup: EmojiGroup.objects, + emojiSubgroup: EmojiSubgroup.office, + keywords: [ + 'paperclip', + 'uc6', + 'classroom', + 'business', + 'household', + 'work', + 'school', + 'teach', + 'learn', + 'study', + 'college', + 'degree', + 'education', + 'homework', + 'student', + 'teacher', + 'university', + 'test', + 'learning', + 'office' + ]), + Emoji( + name: 'linked paperclips', + char: '\u{1F587}\u{FE0F}', + shortName: 'paperclips', + emojiGroup: EmojiGroup.objects, + emojiSubgroup: EmojiSubgroup.office, + keywords: [ + 'link', + 'paperclip', + 'uc7', + 'classroom', + 'business', + 'work', + 'school', + 'teach', + 'learn', + 'study', + 'college', + 'degree', + 'education', + 'homework', + 'student', + 'teacher', + 'university', + 'test', + 'learning', + 'office' + ]), + Emoji( + name: 'triangular ruler', + char: '\u{1F4D0}', + shortName: 'triangular_ruler', + emojiGroup: EmojiGroup.objects, + emojiSubgroup: EmojiSubgroup.office, + keywords: [ + 'ruler', + 'set', + 'triangle', + 'uc6', + 'tool', + 'classroom', + 'triangle', + 'measure', + 'work', + 'tools', + 'school', + 'teach', + 'learn', + 'study', + 'college', + 'degree', + 'education', + 'homework', + 'student', + 'teacher', + 'university', + 'test', + 'learning', + 'triangles', + 'office' + ]), + Emoji( + name: 'straight ruler', + char: '\u{1F4CF}', + shortName: 'straight_ruler', + emojiGroup: EmojiGroup.objects, + emojiSubgroup: EmojiSubgroup.office, + keywords: [ + 'ruler', + 'straight edge', + 'uc6', + 'theatre', + 'tool', + 'classroom', + 'household', + 'measure', + 'work', + 'theater', + 'craft', + 'drama', + 'monet', + 'tools', + 'school', + 'teach', + 'learn', + 'study', + 'college', + 'degree', + 'education', + 'homework', + 'student', + 'teacher', + 'university', + 'test', + 'learning', + 'office' + ]), + Emoji( + name: 'abacus', + char: '\u{1F9EE}', + shortName: 'abacus', + emojiGroup: EmojiGroup.objects, + emojiSubgroup: EmojiSubgroup.computer, + keywords: [ + 'uc11', + 'math', + 'science', + 'vintage', + 'history', + 'calculator', + 'toy', + 'work', + 'decimal', + 'percentage', + 'fraction', + 'lab', + 'ancient', + 'old', + 'count', + 'add', + 'office' + ]), + Emoji( + name: 'pushpin', + char: '\u{1F4CC}', + shortName: 'pushpin', + emojiGroup: EmojiGroup.objects, + emojiSubgroup: EmojiSubgroup.office, + keywords: [ + 'pin', + 'uc6', + 'classroom', + 'map', + 'business', + 'household', + 'work', + 'school', + 'teach', + 'learn', + 'study', + 'college', + 'degree', + 'education', + 'homework', + 'student', + 'teacher', + 'university', + 'test', + 'learning', + 'maps', + 'location', + 'locate', + 'local', + 'lost', + 'office' + ]), + Emoji( + name: 'round pushpin', + char: '\u{1F4CD}', + shortName: 'round_pushpin', + emojiGroup: EmojiGroup.objects, + emojiSubgroup: EmojiSubgroup.office, + keywords: [ + 'pin', + 'pushpin', + 'uc6', + 'classroom', + 'map', + 'business', + 'household', + 'work', + 'school', + 'teach', + 'learn', + 'study', + 'college', + 'degree', + 'education', + 'homework', + 'student', + 'teacher', + 'university', + 'test', + 'learning', + 'maps', + 'location', + 'locate', + 'local', + 'lost', + 'office' + ]), + Emoji( + name: 'scissors', + char: '\u{2702}\u{FE0F}', + shortName: 'scissors', + emojiGroup: EmojiGroup.objects, + emojiSubgroup: EmojiSubgroup.office, + keywords: [ + 'cutting', + 'tool', + 'uc1', + 'theatre', + 'tool', + 'weapon', + 'classroom', + 'steel', + 'household', + 'sew', + 'work', + 'theater', + 'craft', + 'drama', + 'monet', + 'tools', + 'weapons', + 'school', + 'teach', + 'learn', + 'study', + 'college', + 'degree', + 'education', + 'homework', + 'student', + 'teacher', + 'university', + 'test', + 'learning', + 'metal', + 'knit', + 'embroider', + 'stitch', + 'repair', + 'crochet', + 'alter', + 'seamstress', + 'fix', + 'office' + ]), + Emoji( + name: 'pen', + char: '\u{1F58A}\u{FE0F}', + shortName: 'pen_ballpoint', + emojiGroup: EmojiGroup.objects, + emojiSubgroup: EmojiSubgroup.writing, + keywords: [ + 'ballpoint', + 'uc7', + 'tool', + 'classroom', + 'write', + 'business', + 'color', + 'correct', + 'detective', + 'household', + 'work', + 'journal', + 'tools', + 'school', + 'teach', + 'learn', + 'study', + 'college', + 'degree', + 'education', + 'homework', + 'student', + 'teacher', + 'university', + 'test', + 'learning', + 'writing', + 'colour', + 'coloring', + 'colouring', + 'drawing', + 'marker', + 'sketch', + 'passing grade', + 'office' + ]), + Emoji( + name: 'fountain pen', + char: '\u{1F58B}\u{FE0F}', + shortName: 'pen_fountain', + emojiGroup: EmojiGroup.objects, + emojiSubgroup: EmojiSubgroup.writing, + keywords: [ + 'fountain', + 'pen', + 'uc7', + 'tool', + 'classroom', + 'write', + 'color', + 'correct', + 'work', + 'tools', + 'school', + 'teach', + 'learn', + 'study', + 'college', + 'degree', + 'education', + 'homework', + 'student', + 'teacher', + 'university', + 'test', + 'learning', + 'writing', + 'colour', + 'coloring', + 'colouring', + 'drawing', + 'marker', + 'sketch', + 'passing grade', + 'office' + ]), + Emoji( + name: 'black nib', + char: '\u{2712}\u{FE0F}', + shortName: 'black_nib', + emojiGroup: EmojiGroup.objects, + emojiSubgroup: EmojiSubgroup.writing, + keywords: [ + 'nib', + 'pen', + 'uc1', + 'classroom', + 'write', + 'correct', + 'work', + 'school', + 'teach', + 'learn', + 'study', + 'college', + 'degree', + 'education', + 'homework', + 'student', + 'teacher', + 'university', + 'test', + 'learning', + 'writing', + 'passing grade', + 'office' + ]), + Emoji( + name: 'paintbrush', + char: '\u{1F58C}\u{FE0F}', + shortName: 'paintbrush', + emojiGroup: EmojiGroup.objects, + emojiSubgroup: EmojiSubgroup.writing, + keywords: [ + 'painting', + 'uc7', + 'theatre', + 'classroom', + 'write', + 'painting', + 'color', + 'theater', + 'craft', + 'drama', + 'monet', + 'school', + 'teach', + 'learn', + 'study', + 'college', + 'degree', + 'education', + 'homework', + 'student', + 'teacher', + 'university', + 'test', + 'learning', + 'writing', + 'painter', + 'arts', + 'colour', + 'coloring', + 'colouring', + 'drawing', + 'marker', + 'sketch' + ]), + Emoji( + name: 'crayon', + char: '\u{1F58D}\u{FE0F}', + shortName: 'crayon', + emojiGroup: EmojiGroup.objects, + emojiSubgroup: EmojiSubgroup.writing, + keywords: [ + 'crayon', + 'uc7', + 'theatre', + 'classroom', + 'write', + 'color', + 'household', + 'theater', + 'craft', + 'drama', + 'monet', + 'school', + 'teach', + 'learn', + 'study', + 'college', + 'degree', + 'education', + 'homework', + 'student', + 'teacher', + 'university', + 'test', + 'learning', + 'writing', + 'colour', + 'coloring', + 'colouring', + 'drawing', + 'marker', + 'sketch' + ]), + Emoji( + name: 'memo', + char: '\u{1F4DD}', + shortName: 'pencil', + emojiGroup: EmojiGroup.objects, + emojiSubgroup: EmojiSubgroup.writing, + keywords: [ + 'pencil', + 'uc6', + 'classroom', + 'write', + 'document', + 'envelope', + 'color', + 'correct', + 'work', + 'school', + 'teach', + 'learn', + 'study', + 'college', + 'degree', + 'education', + 'homework', + 'student', + 'teacher', + 'university', + 'test', + 'learning', + 'writing', + 'documents', + 'letter', + 'message', + 'offer', + 'colour', + 'coloring', + 'colouring', + 'drawing', + 'marker', + 'sketch', + 'passing grade', + 'office' + ]), + Emoji( + name: 'pencil', + char: '\u{270F}\u{FE0F}', + shortName: 'pencil2', + emojiGroup: EmojiGroup.objects, + emojiSubgroup: EmojiSubgroup.writing, + keywords: [ + 'pencil', + 'uc1', + 'theatre', + 'tool', + 'classroom', + 'write', + 'color', + 'correct', + 'household', + 'work', + 'theater', + 'craft', + 'drama', + 'monet', + 'tools', + 'school', + 'teach', + 'learn', + 'study', + 'college', + 'degree', + 'education', + 'homework', + 'student', + 'teacher', + 'university', + 'test', + 'learning', + 'writing', + 'colour', + 'coloring', + 'colouring', + 'drawing', + 'marker', + 'sketch', + 'passing grade', + 'office' + ]), + Emoji( + name: 'magnifying glass tilted left', + char: '\u{1F50D}', + shortName: 'mag', + emojiGroup: EmojiGroup.objects, + emojiSubgroup: EmojiSubgroup.lightVideo, + keywords: [ + 'glass', + 'magnifying', + 'search', + 'tool', + 'uc6', + 'google', + 'search', + 'detective', + 'household', + 'look', + 'find', + 'looking', + 'see' + ]), + Emoji( + name: 'magnifying glass tilted right', + char: '\u{1F50E}', + shortName: 'mag_right', + emojiGroup: EmojiGroup.objects, + emojiSubgroup: EmojiSubgroup.lightVideo, + keywords: [ + 'glass', + 'magnifying', + 'search', + 'tool', + 'uc6', + 'google', + 'search', + 'detective', + 'look', + 'find', + 'looking', + 'see' + ]), + Emoji( + name: 'locked with pen', + char: '\u{1F50F}', + shortName: 'lock_with_ink_pen', + emojiGroup: EmojiGroup.objects, + emojiSubgroup: EmojiSubgroup.lock, + keywords: [ + 'ink', + 'lock', + 'nib', + 'pen', + 'privacy', + 'uc6', + 'lock', + 'locks', + 'key', + 'keys' + ]), + Emoji( + name: 'locked with key', + char: '\u{1F510}', + shortName: 'closed_lock_with_key', + emojiGroup: EmojiGroup.objects, + emojiSubgroup: EmojiSubgroup.lock, + keywords: [ + 'closed', + 'key', + 'lock', + 'secure', + 'uc6', + 'lock', + 'household', + 'locks', + 'key', + 'keys' + ]), + Emoji( + name: 'locked', + char: '\u{1F512}', + shortName: 'lock', + emojiGroup: EmojiGroup.objects, + emojiSubgroup: EmojiSubgroup.lock, + keywords: ['closed', 'uc6', 'lock', 'locks', 'key', 'keys']), + Emoji( + name: 'unlocked', + char: '\u{1F513}', + shortName: 'unlock', + emojiGroup: EmojiGroup.objects, + emojiSubgroup: EmojiSubgroup.lock, + keywords: [ + 'lock', + 'open', + 'unlock', + 'uc6', + 'lock', + 'locks', + 'key', + 'keys' + ]), + Emoji( + name: 'red heart', + char: '\u{2764}\u{FE0F}', + shortName: 'heart', + emojiGroup: EmojiGroup.smileysEmotion, + emojiSubgroup: EmojiSubgroup.emotion, + keywords: [ + 'heart', + 'uc1', + 'shapes', + 'love', + 'rainbow', + 'red heart', + 'heart', + 'i love you', + 'te amo', + "je t'aime", + 'anniversary', + 'lovin', + 'amour', + 'aimer', + 'amor', + 'valentines day', + 'enamour', + 'lovey', + 'hearts', + 'serce', + 'corazón', + 'coração', + 'coeur', + '<3' + ]), + Emoji( + name: 'orange heart', + char: '\u{1F9E1}', + shortName: 'orange_heart', + emojiGroup: EmojiGroup.smileysEmotion, + emojiSubgroup: EmojiSubgroup.emotion, + keywords: [ + 'orange', + 'uc10', + 'shapes', + 'love', + 'rainbow', + 'orange', + 'heart', + 'i love you', + 'te amo', + "je t'aime", + 'anniversary', + 'lovin', + 'amour', + 'aimer', + 'amor', + 'valentines day', + 'enamour', + 'lovey', + 'hearts', + 'serce', + 'corazón', + 'coração', + 'coeur' + ]), + Emoji( + name: 'yellow heart', + char: '\u{1F49B}', + shortName: 'yellow_heart', + emojiGroup: EmojiGroup.smileysEmotion, + emojiSubgroup: EmojiSubgroup.emotion, + keywords: [ + 'yellow', + 'uc6', + 'shapes', + 'love', + 'rainbow', + 'friend', + 'heart', + 'i love you', + 'te amo', + "je t'aime", + 'anniversary', + 'lovin', + 'amour', + 'aimer', + 'amor', + 'valentines day', + 'enamour', + 'lovey', + 'friends', + 'friendship', + 'best friends', + 'bestfriends', + 'ami', + 'amiga', + 'amigo', + 'hearts', + 'serce', + 'corazón', + 'coração', + 'coeur' + ]), + Emoji( + name: 'green heart', + char: '\u{1F49A}', + shortName: 'green_heart', + emojiGroup: EmojiGroup.smileysEmotion, + emojiSubgroup: EmojiSubgroup.emotion, + keywords: [ + 'green', + 'uc6', + 'shapes', + 'halloween', + 'love', + 'rainbow', + 'irish', + 'jealous', + 'heart', + 'samhain', + 'i love you', + 'te amo', + "je t'aime", + 'anniversary', + 'lovin', + 'amour', + 'aimer', + 'amor', + 'valentines day', + 'enamour', + 'lovey', + 'saint patricks day', + 'st patricks day', + 'leprechaun', + 'hearts', + 'serce', + 'corazón', + 'coração', + 'coeur' + ]), + Emoji( + name: 'blue heart', + char: '\u{1F499}', + shortName: 'blue_heart', + emojiGroup: EmojiGroup.smileysEmotion, + emojiSubgroup: EmojiSubgroup.emotion, + keywords: [ + 'blue', + 'uc6', + 'shapes', + 'love', + 'rainbow', + 'friend', + 'heart', + 'i love you', + 'te amo', + "je t'aime", + 'anniversary', + 'lovin', + 'amour', + 'aimer', + 'amor', + 'valentines day', + 'enamour', + 'lovey', + 'friends', + 'friendship', + 'best friends', + 'bestfriends', + 'ami', + 'amiga', + 'amigo', + 'hearts', + 'serce', + 'corazón', + 'coração', + 'coeur' + ]), + Emoji( + name: 'purple heart', + char: '\u{1F49C}', + shortName: 'purple_heart', + emojiGroup: EmojiGroup.smileysEmotion, + emojiSubgroup: EmojiSubgroup.emotion, + keywords: [ + 'purple', + 'uc6', + 'shapes', + 'love', + 'rainbow', + 'pink', + 'heart', + 'i love you', + 'te amo', + "je t'aime", + 'anniversary', + 'lovin', + 'amour', + 'aimer', + 'amor', + 'valentines day', + 'enamour', + 'lovey', + 'rose', + 'hearts', + 'serce', + 'corazón', + 'coração', + 'coeur' + ]), + Emoji( + name: 'black heart', + char: '\u{1F5A4}', + shortName: 'black_heart', + emojiGroup: EmojiGroup.smileysEmotion, + emojiSubgroup: EmojiSubgroup.emotion, + keywords: [ + 'black', + 'evil', + 'wicked', + 'uc9', + 'shapes', + 'halloween', + 'love', + 'heartbreak', + 'rainbow', + 'hate', + 'killer', + 'heart', + 'samhain', + 'i love you', + 'te amo', + "je t'aime", + 'anniversary', + 'lovin', + 'amour', + 'aimer', + 'amor', + 'valentines day', + 'enamour', + 'lovey', + 'broken heart', + 'heartbroken', + 'i hate', + 'disgust', + 'stump', + 'shout', + 'dislike', + 'rude', + 'annoy', + 'grinch', + 'gross', + 'grumpy', + 'mean', + 'problem', + 'suck', + 'jerk', + 'asshole', + 'no', + 'savage', + 'scary clown', + 'hearts', + 'serce', + 'corazón', + 'coração', + 'coeur' + ]), + Emoji( + name: 'brown heart', + char: '\u{1F90E}', + shortName: 'brown_heart', + emojiGroup: EmojiGroup.smileysEmotion, + emojiSubgroup: EmojiSubgroup.emotion, + keywords: [ + 'uc12', + 'shapes', + 'heart', + 'hearts', + 'serce', + 'corazón', + 'coração', + 'coeur' + ]), + Emoji( + name: 'white heart', + char: '\u{1F90D}', + shortName: 'white_heart', + emojiGroup: EmojiGroup.smileysEmotion, + emojiSubgroup: EmojiSubgroup.emotion, + keywords: [ + 'uc12', + 'shapes', + 'heart', + 'hearts', + 'serce', + 'corazón', + 'coração', + 'coeur' + ]), + Emoji( + name: 'broken heart', + char: '\u{1F494}', + shortName: 'broken_heart', + emojiGroup: EmojiGroup.smileysEmotion, + emojiSubgroup: EmojiSubgroup.emotion, + keywords: [ + 'break', + 'broken', + 'uc6', + 'love', + 'heartbreak', + 'red heart', + 'heart', + 'i love you', + 'te amo', + "je t'aime", + 'anniversary', + 'lovin', + 'amour', + 'aimer', + 'amor', + 'valentines day', + 'enamour', + 'lovey', + 'broken heart', + 'heartbroken', + 'hearts', + 'serce', + 'corazón', + 'coração', + 'coeur', + ' keywords; + List? _runes; + + /// Emoji class. + /// [name] of emoji. [char] and character of emoji. [shortName] and a digest name of emoji, [emojiGroup] is emoji's group and [emojiSubgroup] is emoji's subgroup. [keywords] list of keywords for emoji. [modifiable] `true` if emoji has skin. + Emoji( + {this.name, + this.char, + this.shortName, + this.emojiGroup, + this.emojiSubgroup, + this.keywords = const [], + this.modifiable = false}); + + /// Runes of Emoji Character + List get charRunes { + return _runes ??= char!.runes.toList(); + } + + /// Returns current Emoji with New requested [skinTone] if modifiable, else Returns current Emoji + Emoji? newSkin(fitzpatrick skinTone) { + if (modifiable) { + switch (skinTone) { + case fitzpatrick.light: + return Emoji( + name: this.name! + ', tone1', + char: modify(this.char, skinTone), + shortName: this.shortName! + '_tone1', + emojiGroup: this.emojiGroup, + emojiSubgroup: this.emojiSubgroup, + keywords: this.keywords, + modifiable: true); + case fitzpatrick.mediumLight: + return Emoji( + name: this.name! + ', tone2', + char: modify(this.char, skinTone), + shortName: this.shortName! + '_tone2', + emojiGroup: this.emojiGroup, + emojiSubgroup: this.emojiSubgroup, + keywords: this.keywords, + modifiable: true); + case fitzpatrick.medium: + return Emoji( + name: this.name! + ', tone3', + char: modify(this.char, skinTone), + shortName: this.shortName! + '_tone3', + emojiGroup: this.emojiGroup, + emojiSubgroup: this.emojiSubgroup, + keywords: this.keywords, + modifiable: true); + case fitzpatrick.mediumDark: + return Emoji( + name: this.name! + ', tone4', + char: modify(this.char, skinTone), + shortName: this.shortName! + '_tone4', + emojiGroup: this.emojiGroup, + emojiSubgroup: this.emojiSubgroup, + keywords: this.keywords, + modifiable: true); + case fitzpatrick.dark: + return Emoji( + name: this.name! + ', tone5', + char: modify(this.char, skinTone), + shortName: this.shortName! + '_tone5', + emojiGroup: this.emojiGroup, + emojiSubgroup: this.emojiSubgroup, + keywords: this.keywords, + modifiable: true); + case fitzpatrick.None: + return Emoji.byChar(stabilize(this.char)); + } + } + return this; + } + + /// Get all Emojis + static List all() => List.unmodifiable(_emojis); + + static Iterable chars() => + _emojis.map((e) => e.char).whereType(); + + /// Returns Emoji by [char] and character + static Emoji? byChar(String char) { + return _emojis.firstWhereOrNull((Emoji emoji) => emoji.char == char); + } + + /// Returns Emoji by [name] + static Emoji? byName(String name) { + name = name.toLowerCase(); // todo: searchable name + return _emojis.firstWhereOrNull((Emoji emoji) => emoji.name == name); + } + + /// Returns Emoji by [name] as short name. + static Emoji? byShortName(String name) { + return _emojis.firstWhereOrNull((Emoji emoji) => emoji.char == name); + } + + /// Returns list of Emojis in a same [group] + static Iterable byGroup(EmojiGroup group) { + return _emojis.where((Emoji emoji) => emoji.emojiGroup == group); + } + + /// Returns list of Emojis in a same [subgroup] + static Iterable bySubgroup(EmojiSubgroup subgroup) { + return _emojis.where((Emoji emoji) => emoji.emojiSubgroup == subgroup); + } + + /// Returns List of Emojis with Specific [keyword] + static Iterable byKeyword(String keyword) { + keyword = keyword.toLowerCase(); + return _emojis.where((Emoji emoji) => emoji.keywords.contains(keyword)); + } + + /// disassemble [emoji] to list of emojis, without skin tones if [noSkin] be `true`. + static List disassemble(String emoji, {bool noSkin = false}) { + List emojiRunes = emoji.runes.toList(); + emojiRunes.removeWhere((codeChar) => + ZeroWidthCharCodes.contains(codeChar) || + (noSkin && _isFitzpatrickCode(codeChar))); + return emojiRunes.map((char) => String.fromCharCode(char)).toList(); + // return emoji.runes.toList()..removeWhere((codeChar) => ZeroWidthCharCodes.contains(codeChar) || (noSkin && _isFitzpatrickCode(codeChar))).map((char) => String.fromCharCode(char)).toList() + } + + /// assemble emojis with [emojiChars] codes. + static String assemble(List emojiChars) { + List codeCharPoints = []; + + for (var i = 0; i < emojiChars.length; i++) { + if (i != 0 && !isFitzpatrick(emojiChars[i - 1])) { + codeCharPoints.add(ZWJ); + } + final emojiRunes = emojiChars[i].runes.toList(); + codeCharPoints.addAll(emojiRunes); + } + codeCharPoints.add(variationSelector16); + return String.fromCharCodes(codeCharPoints); + } + + /// Modify skin tone of [emoji] by requested [skinTone] + static String modify(String? emoji, fitzpatrick skinTone) { + int? skinToneCharCode; + switch (skinTone) { + case fitzpatrick.light: + skinToneCharCode = 127995; + break; + case fitzpatrick.mediumLight: + skinToneCharCode = 127996; + break; + case fitzpatrick.medium: + skinToneCharCode = 127997; + break; + case fitzpatrick.mediumDark: + skinToneCharCode = 127998; + break; + case fitzpatrick.dark: + skinToneCharCode = 127999; + break; + case fitzpatrick.None: + return stabilize(emoji); + } + + final emojiRunes = emoji!.runes.toList(); + List finalCharCodes = []; + for (final charCode in emojiRunes) { + if (!_isFitzpatrickCode(charCode)) { + finalCharCodes.add(charCode); + if (_isModifiable(charCode)) { + finalCharCodes.add(skinToneCharCode); + } + } + } + return String.fromCharCodes(finalCharCodes as Iterable); + } + + // todo: support unspecified gender for "... holding hands", "kiss", "couple with heart" and "family". + /// stabilize [skin] and [gender] of [emoji], if `true`. + static String stabilize(String? emoji, + {bool skin = true, bool gender = false}) { + if (gender) { + emoji = emoji! + .replaceAll( + '\u{200D}\u{2642}\u{FE0F}', '') // remove ZWJ man from emoji + .replaceAll( + '\u{200D}\u{2640}\u{FE0F}', '') // remove ZWJ woman from emoji + .replaceAll('\u{1F468}', '\u{1F9D1}') // replace man with person + .replaceAll('\u{1F469}', '\u{1F9D1}') // replace woman with person + .replaceAll( + '\u{1F474}', '\u{1F9D3}') // replace old man with old person + .replaceAll( + '\u{1F475}', '\u{1F9D3}'); // replace old woman with old person + } + + final List emojiRunes = emoji!.runes.toList(); + + if (skin) { + emojiRunes.removeWhere((codeChar) => _isFitzpatrickCode(codeChar)); + } + return String.fromCharCodes(emojiRunes); + } + + /// returns `true` if [emojiCode] is code of Emoji with skin!. + static _isModifiable(int emojiCode) { + return _modifiableCharCodes.contains(emojiCode); + } + + /// returns `true` if [emoji] is a Fitzpatrick Emoji. + static bool isFitzpatrick(String emoji) { + return skinToneEmojiChars.contains(emoji); + } + + /// returns `true` if [emojiCode] is code of Fitzpatrick Emoji. + static bool _isFitzpatrickCode(int emojiCode) { + return _skinToneCharCodes.contains(emojiCode); + } + + @override + toString() => char!; +} diff --git a/packages/stream_chat_flutter/lib/src/extension.dart b/packages/stream_chat_flutter/lib/src/extension.dart index daa8a69c..5161c8e8 100644 --- a/packages/stream_chat_flutter/lib/src/extension.dart +++ b/packages/stream_chat_flutter/lib/src/extension.dart @@ -1,17 +1,15 @@ import 'package:characters/characters.dart'; -import 'package:emojis/emoji.dart'; import 'package:file_picker/file_picker.dart'; import 'package:flutter/material.dart'; +import 'package:stream_chat_flutter/src/emoji/emoji.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; -final _emojis = Emoji.all(); +final _emojiChars = Emoji.chars(); /// String extension extension StringExtension on String { /// Returns the capitalized string - String capitalize() { - return '${this[0].toUpperCase()}${substring(1)}'; - } + String capitalize() => '${this[0].toUpperCase()}${substring(1)}'; /// Returns whether the string contains only emoji's or not. /// @@ -19,10 +17,10 @@ extension StringExtension on String { /// 1 to 3 emojis: big size with no text bubble. /// 4+ emojis or emojis+text: standard size with text bubble. bool get isOnlyEmoji { + if (isEmpty) return false; + if (length > 3) return false; final characters = trim().characters; - if (characters.isEmpty) return false; - if (characters.length > 3) return false; - return characters.every((c) => _emojis.map((e) => e.char).contains(c)); + return characters.every(_emojiChars.contains); } } @@ -46,59 +44,75 @@ extension PlatformFileX on PlatformFile { ); } +/// extension InputDecorationX on InputDecoration { - InputDecoration merge(InputDecoration other) { + /// + InputDecoration merge(InputDecoration? other) { if (other == null) return this; return copyWith( - icon: other?.icon, - labelText: other?.labelText, + icon: other.icon, + labelText: other.labelText, labelStyle: labelStyle?.merge(other.labelStyle) ?? other.labelStyle, - helperText: other?.helperText, + helperText: other.helperText, helperStyle: helperStyle?.merge(other.helperStyle) ?? other.helperStyle, - helperMaxLines: other?.helperMaxLines, - hintText: other?.hintText, + helperMaxLines: other.helperMaxLines, + hintText: other.hintText, hintStyle: hintStyle?.merge(other.hintStyle) ?? other.hintStyle, - hintTextDirection: other?.hintTextDirection, - hintMaxLines: other?.hintMaxLines, - errorText: other?.errorText, + hintTextDirection: other.hintTextDirection, + hintMaxLines: other.hintMaxLines, + errorText: other.errorText, errorStyle: errorStyle?.merge(other.errorStyle) ?? other.errorStyle, - errorMaxLines: other?.errorMaxLines, - floatingLabelBehavior: other?.floatingLabelBehavior, - isCollapsed: other?.isCollapsed, - isDense: other?.isDense, - contentPadding: other?.contentPadding, - prefixIcon: other?.prefixIcon, - prefix: other?.prefix, - prefixText: other?.prefixText, - prefixIconConstraints: other?.prefixIconConstraints, + errorMaxLines: other.errorMaxLines, + floatingLabelBehavior: other.floatingLabelBehavior, + isCollapsed: other.isCollapsed, + isDense: other.isDense, + contentPadding: other.contentPadding, + prefixIcon: other.prefixIcon, + prefix: other.prefix, + prefixText: other.prefixText, + prefixIconConstraints: other.prefixIconConstraints, prefixStyle: prefixStyle?.merge(other.prefixStyle) ?? other.prefixStyle, - suffixIcon: other?.suffixIcon, - suffix: other?.suffix, - suffixText: other?.suffixText, + suffixIcon: other.suffixIcon, + suffix: other.suffix, + suffixText: other.suffixText, suffixStyle: suffixStyle?.merge(other.suffixStyle) ?? other.suffixStyle, - suffixIconConstraints: other?.suffixIconConstraints, - counter: other?.counter, - counterText: other?.counterText, + suffixIconConstraints: other.suffixIconConstraints, + counter: other.counter, + counterText: other.counterText, counterStyle: counterStyle?.merge(other.counterStyle) ?? other.counterStyle, - filled: other?.filled, - fillColor: other?.fillColor, - focusColor: other?.focusColor, - hoverColor: other?.hoverColor, - errorBorder: other?.errorBorder, - focusedBorder: other?.focusedBorder, - focusedErrorBorder: other?.focusedErrorBorder, - disabledBorder: other?.disabledBorder, - enabledBorder: other?.enabledBorder, - border: other?.border, - enabled: other?.enabled, - semanticCounterText: other?.semanticCounterText, - alignLabelWithHint: other?.alignLabelWithHint, + filled: other.filled, + fillColor: other.fillColor, + focusColor: other.focusColor, + hoverColor: other.hoverColor, + errorBorder: other.errorBorder, + focusedBorder: other.focusedBorder, + focusedErrorBorder: other.focusedErrorBorder, + disabledBorder: other.disabledBorder, + enabledBorder: other.enabledBorder, + border: other.border, + enabled: other.enabled, + semanticCounterText: other.semanticCounterText, + alignLabelWithHint: other.alignLabelWithHint, ); } } +/// Gets text scale factor through context extension BuildContextX on BuildContext { + // ignore: public_member_api_docs double get textScaleFactor => MediaQuery.maybeOf(this)?.textScaleFactor ?? 1.0; } + +/// Extension on [BorderRadius] +extension FlipBorder on BorderRadius { + /// Flips borders (Y) + BorderRadius mirrorBorderIfReversed({bool reverse = true}) => reverse + ? BorderRadius.only( + topLeft: topRight, + topRight: topLeft, + bottomLeft: bottomRight, + bottomRight: bottomLeft) + : this; +} diff --git a/packages/stream_chat_flutter/lib/src/full_screen_media.dart b/packages/stream_chat_flutter/lib/src/full_screen_media.dart index aa35ed15..0f6101b9 100644 --- a/packages/stream_chat_flutter/lib/src/full_screen_media.dart +++ b/packages/stream_chat_flutter/lib/src/full_screen_media.dart @@ -6,37 +6,50 @@ import 'package:chewie/chewie.dart'; import 'package:flutter/material.dart'; import 'package:jiffy/jiffy.dart'; import 'package:photo_view/photo_view.dart'; -import 'package:stream_chat_flutter/src/image_footer.dart'; -import 'package:stream_chat_flutter/src/image_header.dart'; +import 'package:stream_chat_flutter/src/gallery_footer.dart'; +import 'package:stream_chat_flutter/src/gallery_header.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; import 'package:video_player/video_player.dart'; -import '../stream_chat_flutter.dart'; +/// Return action for coming back from pages +enum ReturnActionType { + /// No return action + none, -enum ReturnActionType { none, reply } + /// Go to reply message action + reply, +} +/// Callback when show message is tapped typedef ShowMessageCallback = void Function(Message message, Channel channel); /// A full screen image widget class FullScreenMedia extends StatefulWidget { - /// The url of the image - final List mediaAttachments; - final Message message; - - final int startIndex; - final String userName; - final DateTime sentAt; - final ShowMessageCallback onShowMessage; - /// Instantiate a new FullScreenImage const FullScreenMedia({ - Key key, - @required this.mediaAttachments, - this.message, + Key? key, + required this.mediaAttachments, + required this.message, this.startIndex = 0, - this.userName = '', - this.sentAt, + String? userName, this.onShowMessage, - }) : super(key: key); + }) : userName = userName ?? '', + super(key: key); + + /// The url of the image + final List mediaAttachments; + + /// Message where attachments are attached + final Message message; + + /// First index of media shown + final int startIndex; + + /// Username of sender + final String userName; + + /// Callback for when show message is tapped + final ShowMessageCallback? onShowMessage; @override _FullScreenMediaState createState() => _FullScreenMediaState(); @@ -46,10 +59,10 @@ class _FullScreenMediaState extends State with SingleTickerProviderStateMixin { bool _optionsShown = true; - AnimationController _controller; - PageController _pageController; + late final AnimationController _controller; + late final PageController _pageController; - int _currentPage; + late int _currentPage; final videoPackages = {}; @@ -58,7 +71,7 @@ class _FullScreenMediaState extends State super.initState(); _controller = AnimationController( vsync: this, - duration: Duration(milliseconds: 300), + duration: const Duration(milliseconds: 300), ); _pageController = PageController(initialPage: widget.startIndex); _currentPage = widget.startIndex; @@ -78,139 +91,139 @@ class _FullScreenMediaState extends State } @override - Widget build(BuildContext context) { - return Scaffold( - resizeToAvoidBottomInset: false, - body: Stack( - children: [ - AnimatedBuilder( - animation: _controller, - builder: (context, snapshot) { - return PageView.builder( - controller: _pageController, - onPageChanged: (val) { - setState(() { - _currentPage = val; - }); - }, - itemBuilder: (context, index) { - final attachment = widget.mediaAttachments[index]; - if (attachment.type == 'image' || - attachment.type == 'giphy') { - final imageUrl = attachment.imageUrl ?? - attachment.assetUrl ?? - attachment.thumbUrl; - return PhotoView( - imageProvider: - imageUrl == null && attachment.localUri != null - ? Image.memory(attachment.file.bytes).image - : CachedNetworkImageProvider(imageUrl), - maxScale: PhotoViewComputedScale.covered, - minScale: PhotoViewComputedScale.contained, - heroAttributes: PhotoViewHeroAttributes( - tag: widget.mediaAttachments, - ), - backgroundDecoration: BoxDecoration( - color: ColorTween( - begin: StreamChatTheme.of(context) - .channelTheme - .channelHeaderTheme - .color, - end: Colors.black) - .lerp(_controller.value), - ), - onTapUp: (a, b, c) { - setState(() { - _optionsShown = !_optionsShown; - }); - if (_controller.isCompleted) { - _controller.reverse(); - } else { - _controller.forward(); + Widget build(BuildContext context) => Scaffold( + resizeToAvoidBottomInset: false, + body: Stack( + children: [ + AnimatedBuilder( + animation: _controller, + builder: (context, snapshot) => PageView.builder( + controller: _pageController, + onPageChanged: (val) { + setState(() { + _currentPage = val; + }); + }, + itemBuilder: (context, index) { + final attachment = widget.mediaAttachments[index]; + if (attachment.type == 'image' || + attachment.type == 'giphy') { + final imageUrl = attachment.imageUrl ?? + attachment.assetUrl ?? + attachment.thumbUrl; + return PhotoView( + imageProvider: (imageUrl == null && + attachment.localUri != null && + attachment.file?.bytes != null) + ? Image.memory(attachment.file!.bytes!).image + : CachedNetworkImageProvider(imageUrl!), + maxScale: PhotoViewComputedScale.covered, + minScale: PhotoViewComputedScale.contained, + heroAttributes: PhotoViewHeroAttributes( + tag: widget.mediaAttachments, + ), + backgroundDecoration: BoxDecoration( + color: ColorTween( + begin: StreamChatTheme.of(context) + .channelTheme + .channelHeaderTheme + .color, + end: Colors.black, + ).lerp(_controller.value), + ), + onTapUp: (a, b, c) { + setState(() { + _optionsShown = !_optionsShown; + }); + if (_controller.isCompleted) { + _controller.reverse(); + } else { + _controller.forward(); + } + }, + ); + } else if (attachment.type == 'video') { + final controller = videoPackages[attachment.id]!; + if (!controller.initialized) { + return const Center( + child: CircularProgressIndicator(), + ); } - }, - ); - } else if (attachment.type == 'video') { - final controller = videoPackages[attachment.id]; - if (!controller.initialized) { - return Center( - child: CircularProgressIndicator(), - ); - } - return InkWell( - onTap: () { - setState(() { - _optionsShown = !_optionsShown; - }); - if (_controller.isCompleted) { - _controller.reverse(); - } else { - _controller.forward(); - } - }, - child: Padding( - padding: const EdgeInsets.symmetric( - vertical: 50.0, - ), - child: Chewie( - controller: controller.chewieController, - ), - ), - ); - } - return Container(); - }, - itemCount: widget.mediaAttachments.length, - ); - }), - AnimatedOpacity( - opacity: _optionsShown ? 1.0 : 0.0, - duration: Duration(milliseconds: 300), - child: Column( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - ImageHeader( - userName: widget.userName, - sentAt: widget.message.createdAt == null - ? '' - : 'Sent ${getDay(widget.message.createdAt)} at ${Jiffy(widget.sentAt.toLocal()).format('HH:mm')}', - onBackPressed: () { - Navigator.of(context).pop(); - }, - message: widget.message, - urls: widget.mediaAttachments, - currentIndex: _currentPage, - onShowMessage: () { - widget.onShowMessage( - widget.message, StreamChannel.of(context).channel); - }, - ), - if (widget.message.type != 'ephemeral') - ImageFooter( - currentPage: _currentPage, - totalPages: widget.mediaAttachments.length, - mediaAttachments: widget.mediaAttachments, + return InkWell( + onTap: () { + setState(() { + _optionsShown = !_optionsShown; + }); + if (_controller.isCompleted) { + _controller.reverse(); + } else { + _controller.forward(); + } + }, + child: Padding( + padding: const EdgeInsets.symmetric( + vertical: 50, + ), + child: Chewie( + controller: controller.chewieController!, + ), + ), + ); + } + return Container(); + }, + itemCount: widget.mediaAttachments.length, + )), + AnimatedOpacity( + opacity: _optionsShown ? 1.0 : 0.0, + duration: const Duration(milliseconds: 300), + child: Column( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + GalleryHeader( + userName: widget.userName, + sentAt: + // ignore: lines_longer_than_80_chars + 'Sent ${getDay(widget.message.createdAt.toLocal())} at ${Jiffy(widget.message.createdAt.toLocal()).format('HH:mm')}', + onBackPressed: () { + Navigator.of(context).pop(); + }, message: widget.message, - mediaSelectedCallBack: (val) { - setState(() { - _currentPage = val; - _pageController.animateToPage(val, - duration: Duration(milliseconds: 300), - curve: Curves.easeInOut); - Navigator.pop(context); - }); + currentIndex: _currentPage, + onShowMessage: () { + widget.onShowMessage?.call( + widget.message, + StreamChannel.of(context).channel, + ); }, ), - ], + if (widget.message.type != 'ephemeral') + GalleryFooter( + currentPage: _currentPage, + totalPages: widget.mediaAttachments.length, + mediaAttachments: widget.mediaAttachments, + message: widget.message, + mediaSelectedCallBack: (val) { + setState(() { + _currentPage = val; + _pageController.animateToPage( + val, + duration: const Duration(milliseconds: 300), + curve: Curves.easeInOut, + ); + Navigator.pop(context); + }); + }, + ), + ], + ), ), - ), - ], - ), - ); - } + ], + ), + ); String getDay(DateTime dateTime) { - var now = DateTime.now(); + final now = DateTime.now(); if (DateTime(dateTime.year, dateTime.month, dateTime.day) == DateTime(now.year, now.month, now.day)) { @@ -234,50 +247,54 @@ class _FullScreenMediaState extends State } } +/// Class for packaging up things required for videos class VideoPackage { - final bool _showControls; - final bool _autoInitialize; - final VideoPlayerController _videoPlayerController; - ChewieController _chewieController; - - VideoPlayerController get videoPlayer => _videoPlayerController; - - ChewieController get chewieController => _chewieController; - - bool get initialized => _videoPlayerController.value.isInitialized; - + /// Constructor for creating [VideoPackage] VideoPackage( Attachment attachment, { bool showControls = false, bool autoInitialize = true, - }) : assert(attachment != null), - _showControls = showControls, + }) : _showControls = showControls, _autoInitialize = autoInitialize, _videoPlayerController = attachment.localUri != null - ? VideoPlayerController.file(File.fromUri(attachment.localUri)) - : VideoPlayerController.network(attachment.assetUrl); + ? VideoPlayerController.file(File.fromUri(attachment.localUri!)) + : VideoPlayerController.network(attachment.assetUrl!); - Future initialize() { - return _videoPlayerController.initialize().then((_) { - _chewieController = ChewieController( - videoPlayerController: _videoPlayerController, - autoInitialize: _autoInitialize, - showControls: _showControls, - aspectRatio: _videoPlayerController.value.aspectRatio, - ); - }); - } + final bool _showControls; + final bool _autoInitialize; + final VideoPlayerController _videoPlayerController; + ChewieController? _chewieController; - void addListener(VoidCallback listener) { - return _videoPlayerController.addListener(listener); - } + /// Get video player for video + VideoPlayerController get videoPlayer => _videoPlayerController; - void removeListener(VoidCallback listener) { - return _videoPlayerController.removeListener(listener); - } + /// Get [ChewieController] for video + ChewieController? get chewieController => _chewieController; + /// Check if controller is initialised + bool get initialized => _videoPlayerController.value.isInitialized; + + /// Initialize all things required for [VideoPackage] + Future initialize() => _videoPlayerController.initialize().then((_) { + _chewieController = ChewieController( + videoPlayerController: _videoPlayerController, + autoInitialize: _autoInitialize, + showControls: _showControls, + aspectRatio: _videoPlayerController.value.aspectRatio, + ); + }); + + /// Add a listener to video player controller + void addListener(VoidCallback listener) => + _videoPlayerController.addListener(listener); + + /// Remove a listener to video player controller + void removeListener(VoidCallback listener) => + _videoPlayerController.removeListener(listener); + + /// Dispose controllers Future dispose() { _chewieController?.dispose(); - return _videoPlayerController?.dispose(); + return _videoPlayerController.dispose(); } } diff --git a/packages/stream_chat_flutter/lib/src/gallery_footer.dart b/packages/stream_chat_flutter/lib/src/gallery_footer.dart new file mode 100644 index 00000000..c26c8dbc --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/gallery_footer.dart @@ -0,0 +1,316 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:dio/dio.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:share_plus/share_plus.dart'; +import 'package:stream_chat_flutter/src/stream_chat_theme.dart'; +import 'package:stream_chat_flutter/src/video_thumbnail_image.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; +import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart'; + +/// Footer widget for media display +class GalleryFooter extends StatefulWidget implements PreferredSizeWidget { + /// Creates a channel header + const GalleryFooter({ + Key? key, + required this.message, + this.onBackPressed, + this.onTitleTap, + this.onImageTap, + this.currentPage = 0, + this.totalPages = 0, + this.mediaAttachments = const [], + this.mediaSelectedCallBack, + }) : preferredSize = const Size.fromHeight(kToolbarHeight), + super(key: key); + + /// Callback to call when pressing the back button. + /// By default it calls [Navigator.pop] + final VoidCallback? onBackPressed; + + /// Callback to call when the header is tapped. + final VoidCallback? onTitleTap; + + /// Callback to call when the image is tapped. + final VoidCallback? onImageTap; + + /// Stores the current index of media shown + final int currentPage; + + /// Total number of pages of media + final int totalPages; + + /// All attachments to show + final List mediaAttachments; + + /// Message which attachments are attached to + final Message message; + + /// Callback when media is selected + final ValueChanged? mediaSelectedCallBack; + + @override + _GalleryFooterState createState() => _GalleryFooterState(); + + @override + final Size preferredSize; +} + +class _GalleryFooterState extends State { + final TextEditingController _messageController = TextEditingController(); + final FocusNode _messageFocusNode = FocusNode(); + + final List _selectedChannels = []; + + @override + void initState() { + super.initState(); + _messageFocusNode.addListener(() { + setState(() {}); + }); + } + + @override + Widget build(BuildContext context) { + const showShareButton = !kIsWeb; + final mediaQueryData = MediaQuery.of(context); + final galleryFooterThemeData = GalleryFooterTheme.of(context); + return SizedBox.fromSize( + size: Size( + mediaQueryData.size.width, + mediaQueryData.padding.bottom + widget.preferredSize.height, + ), + child: MediaQuery.removePadding( + context: context, + removeTop: true, + child: BottomAppBar( + color: galleryFooterThemeData.backgroundColor, + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + if (!showShareButton) + Container( + width: 48, + ) + else + IconButton( + icon: StreamSvgIcon.iconShare( + size: 24, + color: galleryFooterThemeData.shareIconColor, + ), + onPressed: () async { + final attachment = + widget.mediaAttachments[widget.currentPage]; + final url = attachment.imageUrl ?? + attachment.assetUrl ?? + attachment.thumbUrl!; + final type = attachment.type == 'image' + ? 'jpg' + : url.split('?').first.split('.').last; + final request = await HttpClient().getUrl(Uri.parse(url)); + final response = await request.close(); + final bytes = + await consolidateHttpClientResponseBytes(response); + final tmpPath = await getTemporaryDirectory(); + final filePath = '${tmpPath.path}/${attachment.id}.$type'; + final file = File(filePath); + await file.writeAsBytes(bytes); + await Share.shareFiles( + [filePath], + mimeTypes: [ + 'image/$type', + ], + ); + }, + ), + InkWell( + onTap: widget.onTitleTap, + child: SizedBox( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + '${widget.currentPage + 1} of ${widget.totalPages}', + style: galleryFooterThemeData.titleTextStyle, + ), + ], + ), + ), + ), + IconButton( + icon: StreamSvgIcon.iconGrid( + color: galleryFooterThemeData.gridIconButtonColor, + ), + onPressed: () => _showPhotosModal(context), + ), + ], + ), + ), + ), + ); + } + + void _showPhotosModal(context) { + final chatThemeData = StreamChatTheme.of(context); + final galleryFooterThemeData = GalleryFooterTheme.of(context); + showModalBottomSheet( + context: context, + barrierColor: galleryFooterThemeData.bottomSheetBarrierColor, + backgroundColor: galleryFooterThemeData.bottomSheetBackgroundColor, + isScrollControlled: true, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.only( + topLeft: Radius.circular(16), + topRight: Radius.circular(16), + ), + ), + builder: (context) { + const crossAxisCount = 3; + final noOfRowToShowInitially = + widget.mediaAttachments.length > crossAxisCount ? 2 : 1; + final size = MediaQuery.of(context).size; + final initialChildSize = + 48 + (size.width * noOfRowToShowInitially) / crossAxisCount; + return DraggableScrollableSheet( + expand: false, + initialChildSize: initialChildSize / size.height, + minChildSize: initialChildSize / size.height, + builder: (context, scrollController) => SingleChildScrollView( + controller: scrollController, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Stack( + children: [ + Center( + child: Padding( + padding: const EdgeInsets.all(16), + child: Text( + 'Photos', + style: + galleryFooterThemeData.bottomSheetPhotosTextStyle, + ), + ), + ), + Align( + alignment: Alignment.centerRight, + child: IconButton( + icon: StreamSvgIcon.close( + color: + galleryFooterThemeData.bottomSheetCloseIconColor, + ), + onPressed: () => Navigator.maybePop(context), + ), + ), + ], + ), + Flexible( + child: GridView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + itemCount: widget.mediaAttachments.length, + padding: const EdgeInsets.all(1), + // ignore: lines_longer_than_80_chars + gridDelegate: + const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: crossAxisCount, + mainAxisSpacing: 2, + crossAxisSpacing: 2, + ), + itemBuilder: (context, index) { + Widget media; + final attachment = widget.mediaAttachments[index]; + if (attachment.type == 'video') { + media = InkWell( + onTap: () => widget.mediaSelectedCallBack!(index), + child: FittedBox( + fit: BoxFit.cover, + child: VideoThumbnailImage( + video: (attachment.file?.path ?? + attachment.assetUrl)!, + ), + ), + ); + } else { + media = InkWell( + onTap: () => widget.mediaSelectedCallBack!(index), + child: AspectRatio( + aspectRatio: 1, + child: CachedNetworkImage( + imageUrl: attachment.imageUrl ?? + attachment.assetUrl ?? + attachment.thumbUrl!, + fit: BoxFit.cover, + ), + ), + ); + } + + return Stack( + children: [ + media, + if (widget.message.user != null) + Padding( + padding: const EdgeInsets.all(8), + child: Container( + clipBehavior: Clip.antiAlias, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: Colors.white.withOpacity(0.6), + boxShadow: [ + BoxShadow( + blurRadius: 8, + color: chatThemeData + .colorTheme.textHighEmphasis + .withOpacity(0.3), + ), + ], + ), + padding: const EdgeInsets.all(2), + child: UserAvatar( + user: widget.message.user!, + constraints: + BoxConstraints.tight(const Size(24, 24)), + showOnlineStatus: false, + ), + ), + ), + ], + ); + }, + ), + ), + ], + ), + ), + ); + }, + ); + } + + /// Sends the current message + Future sendMessage() async { + final text = _messageController.text.trim(); + + final attachments = widget.message.attachments; + + _messageController.clear(); + + for (final channel in _selectedChannels) { + final message = Message( + text: text, + attachments: [attachments[widget.currentPage]], + ); + + await channel.sendMessage(message); + } + + _selectedChannels.clear(); + Navigator.pop(context); + } +} diff --git a/packages/stream_chat_flutter/lib/src/image_header.dart b/packages/stream_chat_flutter/lib/src/gallery_header.dart similarity index 58% rename from packages/stream_chat_flutter/lib/src/image_header.dart rename to packages/stream_chat_flutter/lib/src/gallery_header.dart index 023c8eaa..6c4ecfee 100644 --- a/packages/stream_chat_flutter/lib/src/image_header.dart +++ b/packages/stream_chat_flutter/lib/src/gallery_header.dart @@ -1,41 +1,16 @@ import 'package:flutter/material.dart'; +import 'package:stream_chat_flutter/src/attachment_actions_modal.dart'; import 'package:stream_chat_flutter/src/stream_chat_theme.dart'; import 'package:stream_chat_flutter/src/stream_svg_icon.dart'; import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart'; -import 'attachment_actions_modal.dart'; - -class ImageHeader extends StatelessWidget implements PreferredSizeWidget { - /// True if this header shows the leading back button - final bool showBackButton; - - /// Callback to call when pressing the back button. - /// By default it calls [Navigator.pop] - final VoidCallback onBackPressed; - - /// Callback to call when pressing the show message button. - final VoidCallback onShowMessage; - - /// Callback to call when the header is tapped. - final VoidCallback onTitleTap; - - /// Callback to call when the image is tapped. - final VoidCallback onImageTap; - - final Message message; - - final String userName; - final String sentAt; - - final List urls; - final currentIndex; - +/// Header/AppBar widget for media display screen +class GalleryHeader extends StatelessWidget implements PreferredSizeWidget { /// Creates a channel header - ImageHeader({ - Key key, - this.message, - this.urls, - this.currentIndex, + const GalleryHeader({ + Key? key, + required this.message, + this.currentIndex = 0, this.showBackButton = true, this.onBackPressed, this.onShowMessage, @@ -43,30 +18,59 @@ class ImageHeader extends StatelessWidget implements PreferredSizeWidget { this.onImageTap, this.userName = '', this.sentAt = '', - }) : preferredSize = Size.fromHeight(kToolbarHeight), + }) : preferredSize = const Size.fromHeight(kToolbarHeight), super(key: key); + /// True if this header shows the leading back button + final bool showBackButton; + + /// Callback to call when pressing the back button. + /// By default it calls [Navigator.pop] + final VoidCallback? onBackPressed; + + /// Callback to call when pressing the show message button. + final VoidCallback? onShowMessage; + + /// Callback to call when the header is tapped. + final VoidCallback? onTitleTap; + + /// Callback to call when the image is tapped. + final VoidCallback? onImageTap; + + /// Message which attachments are attached to + final Message message; + + /// Username of sender + final String userName; + + /// Text which connotes the time the message was sent + final String sentAt; + + /// Stores the current index of media shown + final int currentIndex; + @override Widget build(BuildContext context) { + final galleryHeaderThemeData = GalleryHeaderTheme.of(context); return AppBar( + textTheme: Theme.of(context).textTheme, brightness: Theme.of(context).brightness, elevation: 1, leading: showBackButton ? IconButton( icon: StreamSvgIcon.close( - color: StreamChatTheme.of(context).colorTheme.black, - size: 24.0, + color: galleryHeaderThemeData.closeButtonColor, + size: 24, ), onPressed: onBackPressed, ) - : SizedBox(), - backgroundColor: - StreamChatTheme.of(context).channelTheme.channelHeaderTheme.color, + : const SizedBox(), + backgroundColor: galleryHeaderThemeData.backgroundColor, actions: [ if (message.type != 'ephemeral') IconButton( icon: StreamSvgIcon.iconMenuPoint( - color: StreamChatTheme.of(context).colorTheme.black, + color: galleryHeaderThemeData.iconMenuPointColor, ), onPressed: () { _showMessageActionModalBottomSheet(context); @@ -77,29 +81,26 @@ class ImageHeader extends StatelessWidget implements PreferredSizeWidget { title: message.type != 'ephemeral' ? InkWell( onTap: onTitleTap, - child: Container( + child: SizedBox( height: preferredSize.height, width: preferredSize.width, child: Column( - crossAxisAlignment: CrossAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center, mainAxisSize: MainAxisSize.min, children: [ Text( userName, - style: StreamChatTheme.of(context).textTheme.headlineBold, + style: galleryHeaderThemeData.titleTextStyle, ), Text( sentAt, - style: StreamChatTheme.of(context) - .channelPreviewTheme - .subtitle, + style: galleryHeaderThemeData.subtitleTextStyle, ), ], ), ), ) - : SizedBox(), + : const SizedBox(), ); } @@ -108,20 +109,21 @@ class ImageHeader extends StatelessWidget implements PreferredSizeWidget { void _showMessageActionModalBottomSheet(BuildContext context) async { final channel = StreamChannel.of(context).channel; + final galleryHeaderThemeData = + StreamChatTheme.of(context).galleryHeaderTheme; - var result = await showDialog( + final result = await showDialog( + useRootNavigator: false, context: context, - barrierColor: StreamChatTheme.of(context).colorTheme.overlay, - builder: (context) { - return StreamChannel( - channel: channel, - child: AttachmentActionsModal( - message: message, - currentIndex: currentIndex, - onShowMessage: onShowMessage, - ), - ); - }, + barrierColor: galleryHeaderThemeData.bottomSheetBarrierColor, + builder: (context) => StreamChannel( + channel: channel, + child: AttachmentActionsModal( + message: message, + currentIndex: currentIndex, + onShowMessage: onShowMessage, + ), + ), ); if (result != null) { diff --git a/packages/stream_chat_flutter/lib/src/group_avatar.dart b/packages/stream_chat_flutter/lib/src/group_avatar.dart new file mode 100644 index 00000000..4a16279e --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/group_avatar.dart @@ -0,0 +1,155 @@ +import 'package:flutter/material.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; + +/// Widget for constructing a group of images +class GroupAvatar extends StatelessWidget { + /// Constructor for creating a [GroupAvatar] + const GroupAvatar({ + Key? key, + required this.members, + this.constraints, + this.onTap, + this.borderRadius, + this.selected = false, + this.selectionColor, + this.selectionThickness = 4, + }) : super(key: key); + + /// List of images to display + final List members; + + /// Constraints on the widget + final BoxConstraints? constraints; + + /// Callback when widget is tapped + final VoidCallback? onTap; + + /// Highlights if selected + final bool selected; + + /// [BorderRadius] to pass to the widget + final BorderRadius? borderRadius; + + /// Color of selection if selected + final Color? selectionColor; + + /// Thickness with which color of selection is shown + final double selectionThickness; + + @override + Widget build(BuildContext context) { + final channel = StreamChannel.of(context).channel; + + assert(channel.state != null, 'Channel ${channel.id} is not initialized'); + + final streamChatTheme = StreamChatTheme.of(context); + final colorTheme = streamChatTheme.colorTheme; + final previewTheme = streamChatTheme.channelPreviewTheme.avatarTheme; + + Widget avatar = GestureDetector( + onTap: onTap, + child: ClipRRect( + borderRadius: borderRadius ?? previewTheme?.borderRadius, + child: Container( + constraints: constraints ?? previewTheme?.constraints, + decoration: BoxDecoration(color: colorTheme.accentPrimary), + child: Flex( + direction: Axis.vertical, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Flexible( + fit: FlexFit.tight, + child: Flex( + direction: Axis.horizontal, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: members + .take(2) + .map( + (member) => Flexible( + fit: FlexFit.tight, + child: FittedBox( + fit: BoxFit.cover, + clipBehavior: Clip.antiAlias, + child: Transform.scale( + scale: 1.2, + child: BetterStreamBuilder( + stream: channel.state!.membersStream.map( + (members) => members.firstWhere( + (it) => it.userId == member.userId, + orElse: () => member, + ), + ), + initialData: member, + builder: (context, member) => UserAvatar( + user: member.user!, + borderRadius: BorderRadius.zero, + ), + ), + ), + ), + ), + ) + .toList(), + ), + ), + if (members.length > 2) + Flexible( + fit: FlexFit.tight, + child: Flex( + direction: Axis.horizontal, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: members + .skip(2) + .take(2) + .map( + (member) => Flexible( + fit: FlexFit.tight, + child: FittedBox( + fit: BoxFit.cover, + clipBehavior: Clip.antiAlias, + child: Transform.scale( + scale: 1.2, + child: BetterStreamBuilder( + stream: channel.state!.membersStream.map( + (members) => members.firstWhere( + (it) => it.userId == member.userId, + orElse: () => member, + ), + ), + initialData: member, + builder: (context, member) => UserAvatar( + user: member.user!, + borderRadius: BorderRadius.zero, + ), + ), + ), + ), + ), + ) + .toList(), + ), + ), + ], + ), + ), + ), + ); + + if (selected) { + avatar = ClipRRect( + borderRadius: BorderRadius.circular(selectionThickness) + + (borderRadius ?? previewTheme?.borderRadius ?? BorderRadius.zero), + child: Container( + constraints: constraints ?? previewTheme?.constraints, + color: selectionColor ?? colorTheme.accentPrimary, + child: Padding( + padding: EdgeInsets.all(selectionThickness), + child: avatar, + ), + ), + ); + } + + return avatar; + } +} diff --git a/packages/stream_chat_flutter/lib/src/group_image.dart b/packages/stream_chat_flutter/lib/src/group_image.dart deleted file mode 100644 index 4cfd655a..00000000 --- a/packages/stream_chat_flutter/lib/src/group_image.dart +++ /dev/null @@ -1,127 +0,0 @@ -import 'package:cached_network_image/cached_network_image.dart'; -import 'package:flutter/material.dart'; - -import '../stream_chat_flutter.dart'; - -class GroupImage extends StatelessWidget { - const GroupImage({ - Key key, - @required this.images, - this.constraints, - this.onTap, - this.borderRadius, - this.selected = false, - this.selectionColor, - this.selectionThickness = 4, - }) : super(key: key); - - final List images; - final BoxConstraints constraints; - final VoidCallback onTap; - final bool selected; - final BorderRadius borderRadius; - final Color selectionColor; - final double selectionThickness; - - @override - Widget build(BuildContext context) { - var avatar; - final streamChatTheme = StreamChatTheme.of(context); - - avatar = GestureDetector( - onTap: onTap, - child: ClipRRect( - borderRadius: borderRadius ?? - StreamChatTheme.of(context) - .ownMessageTheme - .avatarTheme - .borderRadius, - child: Container( - constraints: constraints ?? - StreamChatTheme.of(context) - .ownMessageTheme - .avatarTheme - .constraints, - decoration: BoxDecoration( - color: StreamChatTheme.of(context).colorTheme.accentBlue, - ), - child: Flex( - direction: Axis.vertical, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Flexible( - fit: FlexFit.tight, - child: Flex( - direction: Axis.horizontal, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: images - .take(2) - .map((url) => Flexible( - fit: FlexFit.tight, - child: FittedBox( - fit: BoxFit.cover, - clipBehavior: Clip.antiAlias, - child: Transform.scale( - scale: 1.2, - child: CachedNetworkImage( - imageUrl: url, - fit: BoxFit.cover, - ), - ), - ), - )) - .toList(), - ), - ), - if (images.length > 2) - Flexible( - fit: FlexFit.tight, - child: Flex( - direction: Axis.horizontal, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: images - .skip(2) - .map((url) => Flexible( - fit: FlexFit.tight, - child: FittedBox( - fit: BoxFit.cover, - clipBehavior: Clip.antiAlias, - child: Transform.scale( - scale: 1.2, - child: CachedNetworkImage( - imageUrl: url, - fit: BoxFit.cover, - ), - ), - ), - )) - .toList(), - ), - ), - ], - ), - ), - ), - ); - - if (selected) { - avatar = ClipRRect( - borderRadius: (borderRadius ?? - streamChatTheme.ownMessageTheme.avatarTheme.borderRadius) + - BorderRadius.circular(selectionThickness), - child: Container( - color: selectionColor ?? - StreamChatTheme.of(context).colorTheme.accentBlue, - height: 64.0, - width: 64.0, - child: Padding( - padding: EdgeInsets.all(selectionThickness), - child: avatar, - ), - ), - ); - } - - return avatar; - } -} diff --git a/packages/stream_chat_flutter/lib/src/image_footer.dart b/packages/stream_chat_flutter/lib/src/image_footer.dart deleted file mode 100644 index 6833a52f..00000000 --- a/packages/stream_chat_flutter/lib/src/image_footer.dart +++ /dev/null @@ -1,343 +0,0 @@ -import 'dart:async'; -import 'dart:io'; - -import 'package:cached_network_image/cached_network_image.dart'; -import 'package:dio/dio.dart'; -import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; -import 'package:share_plus/share_plus.dart'; -import 'package:path_provider/path_provider.dart'; -import 'package:stream_chat_flutter/src/stream_chat_theme.dart'; -import 'package:stream_chat_flutter/src/video_thumbnail_image.dart'; -import 'package:stream_chat_flutter/stream_chat_flutter.dart'; -import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart'; - -class ImageFooter extends StatefulWidget implements PreferredSizeWidget { - /// Callback to call when pressing the back button. - /// By default it calls [Navigator.pop] - final VoidCallback onBackPressed; - - /// Callback to call when the header is tapped. - final VoidCallback onTitleTap; - - /// Callback to call when the image is tapped. - final VoidCallback onImageTap; - - final int currentPage; - final int totalPages; - - final List mediaAttachments; - final Message message; - - final ValueChanged mediaSelectedCallBack; - - /// Creates a channel header - ImageFooter({ - Key key, - this.onBackPressed, - this.onTitleTap, - this.onImageTap, - this.currentPage = 0, - this.totalPages = 0, - this.mediaAttachments, - this.message, - this.mediaSelectedCallBack, - }) : preferredSize = Size.fromHeight(kToolbarHeight), - super(key: key); - - @override - _ImageFooterState createState() => _ImageFooterState(); - - @override - final Size preferredSize; -} - -class _ImageFooterState extends State { - TextEditingController _searchController; - final TextEditingController _messageController = TextEditingController(); - final FocusNode _messageFocusNode = FocusNode(); - - final List _selectedChannels = []; - - Function modalSetStateCallback; - - @override - void initState() { - super.initState(); - _messageFocusNode.addListener(() { - setState(() {}); - }); - } - - @override - void dispose() { - _searchController?.clear(); - _searchController?.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - final showShareButton = !kIsWeb; - return SizedBox.fromSize( - size: Size( - MediaQuery.of(context).size.width, - MediaQuery.of(context).padding.bottom + widget.preferredSize.height, - ), - child: MediaQuery.removePadding( - context: context, - removeTop: true, - child: BottomAppBar( - color: StreamChatTheme.of(context).colorTheme.white, - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - !showShareButton - ? Container( - width: 48, - ) - : IconButton( - icon: StreamSvgIcon.iconShare( - size: 24.0, - color: StreamChatTheme.of(context).colorTheme.black, - ), - onPressed: () async { - final attachment = - widget.mediaAttachments[widget.currentPage]; - final url = attachment.imageUrl ?? - attachment.assetUrl ?? - attachment.thumbUrl; - final type = attachment.type == 'image' - ? 'jpg' - : url?.split('?')?.first?.split('.')?.last ?? 'jpg'; - final request = - await HttpClient().getUrl(Uri.parse(url)); - final response = await request.close(); - final bytes = - await consolidateHttpClientResponseBytes(response); - final tmpPath = await getTemporaryDirectory(); - final filePath = - '${tmpPath.path}/${attachment.id}.$type'; - final file = File(filePath); - await file.writeAsBytes(bytes); - await Share.shareFiles( - [filePath], - mimeTypes: [ - 'image/$type', - ], - ); - }, - ), - InkWell( - onTap: widget.onTitleTap, - child: Container( - child: Column( - crossAxisAlignment: CrossAxisAlignment.center, - mainAxisAlignment: MainAxisAlignment.center, - mainAxisSize: MainAxisSize.min, - children: [ - Text( - '${widget.currentPage + 1} of ${widget.totalPages}', - style: - StreamChatTheme.of(context).textTheme.headlineBold, - ), - ], - ), - ), - ), - IconButton( - icon: StreamSvgIcon.iconGrid( - color: StreamChatTheme.of(context).colorTheme.black, - ), - onPressed: () => _showPhotosModal(context), - ), - ], - ), - ), - ), - ); - } - - void _showPhotosModal(context) { - showModalBottomSheet( - context: context, - barrierColor: StreamChatTheme.of(context).colorTheme.overlay, - backgroundColor: StreamChatTheme.of(context).colorTheme.white, - isScrollControlled: true, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.only( - topLeft: Radius.circular(16.0), - topRight: Radius.circular(16.0), - ), - ), - builder: (context) { - final crossAxisCount = 3; - final noOfRowToShowInitially = - widget.mediaAttachments.length > crossAxisCount ? 2 : 1; - final size = MediaQuery.of(context).size; - final initialChildSize = - 48 + (size.width * noOfRowToShowInitially) / crossAxisCount; - return DraggableScrollableSheet( - expand: false, - initialChildSize: initialChildSize / size.height, - minChildSize: initialChildSize / size.height, - builder: (context, scrollController) { - return SingleChildScrollView( - controller: scrollController, - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Stack( - children: [ - Center( - child: Padding( - padding: const EdgeInsets.all(16.0), - child: Text( - 'Photos', - style: StreamChatTheme.of(context) - .textTheme - .headlineBold, - ), - ), - ), - Align( - alignment: Alignment.centerRight, - child: IconButton( - icon: StreamSvgIcon.close( - color: StreamChatTheme.of(context).colorTheme.black, - ), - onPressed: () => Navigator.maybePop(context), - ), - ), - ], - ), - Flexible( - child: GridView.builder( - shrinkWrap: true, - physics: const NeverScrollableScrollPhysics(), - itemCount: widget.mediaAttachments.length, - padding: const EdgeInsets.all(1), - gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: crossAxisCount, - mainAxisSpacing: 2.0, - crossAxisSpacing: 2.0, - ), - itemBuilder: (context, index) { - Widget media; - final attachment = widget.mediaAttachments[index]; - if (attachment.type == 'video') { - media = InkWell( - onTap: () => widget.mediaSelectedCallBack(index), - child: FittedBox( - fit: BoxFit.cover, - child: VideoThumbnailImage( - video: attachment.file?.path ?? - attachment.assetUrl, - ), - ), - ); - } else { - media = InkWell( - onTap: () => widget.mediaSelectedCallBack(index), - child: AspectRatio( - aspectRatio: 1.0, - child: CachedNetworkImage( - imageUrl: attachment.imageUrl ?? - attachment.assetUrl ?? - attachment.thumbUrl, - fit: BoxFit.cover, - ), - ), - ); - } - - return Stack( - children: [ - media, - Padding( - padding: EdgeInsets.all(8.0), - child: Container( - clipBehavior: Clip.antiAlias, - decoration: BoxDecoration( - shape: BoxShape.circle, - color: Colors.white.withOpacity(0.6), - boxShadow: [ - BoxShadow( - blurRadius: 8.0, - color: StreamChatTheme.of(context) - .colorTheme - .black - .withOpacity(0.3), - ), - ], - ), - padding: const EdgeInsets.all(2), - child: UserAvatar( - user: widget.message.user, - constraints: - BoxConstraints.tight(Size(24, 24)), - showOnlineStatus: false, - ), - ), - ), - ], - ); - }, - ), - ), - ], - ), - ); - }, - ); - }, - ); - } - - /// Sends the current message - Future sendMessage() async { - var text = _messageController.text.trim(); - - final attachments = widget.message.attachments; - - _messageController.clear(); - - for (var channel in _selectedChannels) { - final message = Message( - text: text, - attachments: [attachments[widget.currentPage]], - ); - - await channel.sendMessage(message); - } - - _selectedChannels.clear(); - Navigator.pop(context); - } -} - -/// Used for clipping textfield prefix icon -class IconClipper extends CustomClipper { - @override - Path getClip(Size size) { - var leftX = size.width / 5; - var rightX = 4 * size.width / 5; - var topY = size.height / 5; - var bottomY = 4 * size.height / 5; - - final path = Path(); - path.moveTo(leftX, topY); - path.lineTo(leftX, bottomY); - path.lineTo(rightX, bottomY); - path.lineTo(rightX, topY); - path.lineTo(leftX, topY); - path.lineTo(0.0, 0.0); - path.close(); - return path; - } - - @override - bool shouldReclip(CustomClipper oldClipper) { - return false; - } -} diff --git a/packages/stream_chat_flutter/lib/src/image_group.dart b/packages/stream_chat_flutter/lib/src/image_group.dart index 39c2883a..4647fe57 100644 --- a/packages/stream_chat_flutter/lib/src/image_group.dart +++ b/packages/stream_chat_flutter/lib/src/image_group.dart @@ -1,117 +1,124 @@ import 'package:flutter/material.dart'; -import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart'; import 'package:stream_chat_flutter/src/full_screen_media.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; +import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart'; +/// Widget for constructing a group of images in message class ImageGroup extends StatelessWidget { + /// Constructor for creating [ImageGroup] widget const ImageGroup({ - Key key, - @required this.images, - @required this.message, - @required this.messageTheme, - @required this.size, + Key? key, + required this.images, + required this.message, + required this.messageTheme, + required this.size, + this.onReturnAction, this.onShowMessage, }) : super(key: key); + /// List of attachments to show final List images; + + /// Callback when attachment is returned to from other screens + final ValueChanged? onReturnAction; + + /// Message which images are attached to final Message message; + + /// [MessageTheme] to apply to message final MessageTheme messageTheme; + + /// Size of iamges final Size size; - final ShowMessageCallback onShowMessage; + + /// Callback for when show message is tapped + final ShowMessageCallback? onShowMessage; @override - Widget build(BuildContext context) { - return ConstrainedBox( - constraints: BoxConstraints.loose(size), - child: Flex( - direction: Axis.vertical, - children: [ - Flexible( - flex: 1, - fit: FlexFit.tight, - child: Flex( - crossAxisAlignment: CrossAxisAlignment.stretch, - direction: Axis.horizontal, - children: [ - Flexible( - flex: 1, - fit: FlexFit.tight, - child: _buildImage(context, 0), - ), - Flexible( - flex: 1, - fit: FlexFit.tight, - child: Padding( - padding: const EdgeInsets.only(left: 2.0), - child: _buildImage(context, 1), - ), - ), - ], - ), - ), - if (images.length >= 3) + Widget build(BuildContext context) => ConstrainedBox( + constraints: BoxConstraints.loose(size), + child: Flex( + direction: Axis.vertical, + children: [ Flexible( fit: FlexFit.tight, - flex: 1, - child: Padding( - padding: const EdgeInsets.only(top: 2.0), - child: Flex( - direction: Axis.horizontal, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Flexible( - fit: FlexFit.tight, - flex: 1, - child: _buildImage(context, 2), + child: Flex( + crossAxisAlignment: CrossAxisAlignment.stretch, + direction: Axis.horizontal, + children: [ + Flexible( + fit: FlexFit.tight, + child: _buildImage(context, 0), + ), + Flexible( + fit: FlexFit.tight, + child: Padding( + padding: const EdgeInsets.only(left: 2), + child: _buildImage(context, 1), ), - if (images.length >= 4) + ), + ], + ), + ), + if (images.length >= 3) + Flexible( + fit: FlexFit.tight, + child: Padding( + padding: const EdgeInsets.only(top: 2), + child: Flex( + direction: Axis.horizontal, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ Flexible( fit: FlexFit.tight, - flex: 1, - child: Padding( - padding: const EdgeInsets.only(left: 2.0), - child: Stack( - fit: StackFit.expand, - children: [ - _buildImage(context, 3), - if (images.length > 4) - Positioned.fill( - child: GestureDetector( - onTap: () => _onTap(context, 3), - child: Material( - color: Colors.black38, - child: Center( - child: Text( - '+ ${images.length - 4}', - style: TextStyle( - color: Colors.white, - fontSize: 26, + child: _buildImage(context, 2), + ), + if (images.length >= 4) + Flexible( + fit: FlexFit.tight, + child: Padding( + padding: const EdgeInsets.only(left: 2), + child: Stack( + fit: StackFit.expand, + children: [ + _buildImage(context, 3), + if (images.length > 4) + Positioned.fill( + child: GestureDetector( + onTap: () => _onTap(context, 3), + child: Material( + color: Colors.black38, + child: Center( + child: Text( + '+ ${images.length - 4}', + style: const TextStyle( + color: Colors.white, + fontSize: 26, + ), ), ), ), ), ), - ), - ], + ], + ), ), ), - ), - ], + ], + ), ), ), - ), - ], - ), - ); - } + ], + ), + ); void _onTap( - BuildContext context, [ + BuildContext context, int index, - ]) { + ) async { final channel = StreamChannel.of(context).channel; - Navigator.push( + final res = await Navigator.push( context, MaterialPageRoute( builder: (context) => StreamChannel( @@ -119,23 +126,21 @@ class ImageGroup extends StatelessWidget { child: FullScreenMedia( mediaAttachments: images, startIndex: index, - userName: message.user.name, - sentAt: message.createdAt, + userName: message.user?.name, message: message, onShowMessage: onShowMessage, ), ), ), ); + if (res != null) onReturnAction?.call(res); } - Widget _buildImage(BuildContext context, int index) { - return ImageAttachment( - attachment: images[index], - size: size, - message: message, - messageTheme: messageTheme, - onAttachmentTap: () => _onTap(context, index), - ); - } + Widget _buildImage(BuildContext context, int index) => ImageAttachment( + attachment: images[index], + size: size, + message: message, + messageTheme: messageTheme, + onAttachmentTap: () => _onTap(context, index), + ); } diff --git a/packages/stream_chat_flutter/lib/src/info_tile.dart b/packages/stream_chat_flutter/lib/src/info_tile.dart index 2e12d6b3..965d4fed 100644 --- a/packages/stream_chat_flutter/lib/src/info_tile.dart +++ b/packages/stream_chat_flutter/lib/src/info_tile.dart @@ -2,41 +2,59 @@ import 'package:flutter/material.dart'; import 'package:flutter_portal/flutter_portal.dart'; import 'package:stream_chat_flutter/src/stream_chat_theme.dart'; +/// Tile to display a message, used in stream chat to display connection status class InfoTile extends StatelessWidget { - final String message; - final Widget child; - final bool showMessage; - final Alignment tileAnchor; - final Alignment childAnchor; - final TextStyle textStyle; - final Color backgroundColor; + /// Constructor for creating an [InfoTile] widget + const InfoTile({ + Key? key, + required this.message, + required this.child, + required this.showMessage, + this.tileAnchor, + this.childAnchor, + this.textStyle, + this.backgroundColor, + }) : super(key: key); - InfoTile( - {this.message, - this.child, - this.showMessage, - this.tileAnchor, - this.childAnchor, - this.textStyle, - this.backgroundColor}); + /// String to display + final String message; + + /// Widget to display over + final Widget child; + + /// Flag to show message + final bool showMessage; + + /// Anchor for tile - [portalAnchor] for [PortalEntry] + final Alignment? tileAnchor; + + /// Alignment for child - [childAnchor] for [PortalEntry] + final Alignment? childAnchor; + + /// [TextStyle] for message + final TextStyle? textStyle; + + /// Background color for tile + final Color? backgroundColor; @override Widget build(BuildContext context) { + final chatThemeData = StreamChatTheme.of(context); return PortalEntry( visible: showMessage, portalAnchor: tileAnchor ?? Alignment.topCenter, childAnchor: childAnchor ?? Alignment.bottomCenter, portal: Container( - height: 25.0, + height: 25, color: backgroundColor ?? - StreamChatTheme.of(context).colorTheme.grey.withOpacity(0.9), + chatThemeData.colorTheme.textLowEmphasis.withOpacity(0.9), child: Center( child: Text( message, style: textStyle ?? - StreamChatTheme.of(context).textTheme.body.copyWith( - color: Colors.white, - ), + chatThemeData.textTheme.body.copyWith( + color: Colors.white, + ), maxLines: 1, overflow: TextOverflow.ellipsis, ), diff --git a/packages/stream_chat_flutter/lib/src/media_list_view.dart b/packages/stream_chat_flutter/lib/src/media_list_view.dart index ddaf2c24..77359f8b 100644 --- a/packages/stream_chat_flutter/lib/src/media_list_view.dart +++ b/packages/stream_chat_flutter/lib/src/media_list_view.dart @@ -5,8 +5,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_svg/flutter_svg.dart'; import 'package:photo_manager/photo_manager.dart'; import 'package:stream_chat_flutter/src/stream_svg_icon.dart'; - -import '../stream_chat_flutter.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; extension on Duration { String format() { @@ -19,16 +18,21 @@ extension on Duration { } } +/// Constructs a list of media class MediaListView extends StatefulWidget { - final List selectedIds; - final void Function(AssetEntity media) onSelect; - + /// Constructor for creating a [MediaListView] widget const MediaListView({ - Key key, + Key? key, this.selectedIds = const [], this.onSelect, }) : super(key: key); + /// Stores the media selected + final List selectedIds; + + /// Callback for on media selected + final void Function(AssetEntity media)? onSelect; + @override _MediaListViewState createState() => _MediaListViewState(); } @@ -39,103 +43,100 @@ class _MediaListViewState extends State { int _currentPage = 0; @override - Widget build(BuildContext context) { - return LazyLoadScrollView( - onEndOfPage: () async => _getMedia(), - child: GridView.builder( - itemCount: _media.length, - controller: _scrollController, - gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: 3, - ), - itemBuilder: ( - context, - position, - ) { - final media = _media.elementAt(position); - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 1.0, vertical: 1.0), - child: InkWell( - onTap: () { - if (widget.onSelect != null) { - widget.onSelect(media); - } - }, - child: Stack( - children: [ - AspectRatio( - aspectRatio: 1.0, - child: FadeInImage( - fadeInDuration: Duration(milliseconds: 300), - placeholder: AssetImage( - 'images/placeholder.png', - package: 'stream_chat_flutter', + Widget build(BuildContext context) => LazyLoadScrollView( + onEndOfPage: () async => _getMedia(), + child: GridView.builder( + itemCount: _media.length, + controller: _scrollController, + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 3, + ), + itemBuilder: ( + context, + position, + ) { + final media = _media.elementAt(position); + final chatThemeData = StreamChatTheme.of(context); + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 1, vertical: 1), + child: InkWell( + onTap: () { + if (widget.onSelect != null) { + widget.onSelect!(media); + } + }, + child: Stack( + children: [ + AspectRatio( + aspectRatio: 1, + child: FadeInImage( + fadeInDuration: const Duration(milliseconds: 300), + placeholder: const AssetImage( + 'images/placeholder.png', + package: 'stream_chat_flutter', + ), + image: MediaThumbnailProvider( + media: media, + ), + fit: BoxFit.cover, ), - image: MediaThumbnailProvider( - media: media, - ), - fit: BoxFit.cover, ), - ), - Positioned.fill( - child: IgnorePointer( - child: AnimatedOpacity( - duration: Duration(milliseconds: 300), - opacity: widget.selectedIds.any((id) => id == media.id) - ? 1.0 - : 0.0, - child: Container( - color: StreamChatTheme.of(context) - .colorTheme - .black - .withOpacity(0.5), - alignment: Alignment.topRight, - padding: const EdgeInsets.only( - top: 8, - right: 8, - ), - child: CircleAvatar( - radius: 12, - backgroundColor: - StreamChatTheme.of(context).colorTheme.white, - child: StreamSvgIcon.check( - size: 24, - color: - StreamChatTheme.of(context).colorTheme.black, + Positioned.fill( + child: IgnorePointer( + child: AnimatedOpacity( + duration: const Duration(milliseconds: 300), + opacity: + widget.selectedIds.any((id) => id == media.id) + ? 1.0 + : 0.0, + child: Container( + color: chatThemeData.colorTheme.textHighEmphasis + .withOpacity(0.5), + alignment: Alignment.topRight, + padding: const EdgeInsets.only( + top: 8, + right: 8, + ), + child: CircleAvatar( + radius: 12, + backgroundColor: chatThemeData.colorTheme.barsBg, + child: StreamSvgIcon.check( + size: 24, + color: + chatThemeData.colorTheme.textHighEmphasis, + ), ), ), ), ), ), - ), - if (media.type == AssetType.video) ...[ - Positioned( - left: 8, - bottom: 10, - child: SvgPicture.asset( - 'svgs/video_call_icon.svg', - package: 'stream_chat_flutter', - ), - ), - Positioned( - right: 4, - bottom: 10, - child: Text( - media.videoDuration.format(), - style: TextStyle( - color: StreamChatTheme.of(context).colorTheme.white, + if (media.type == AssetType.video) ...[ + Positioned( + left: 8, + bottom: 10, + child: SvgPicture.asset( + 'svgs/video_call_icon.svg', + package: 'stream_chat_flutter', ), ), - ), - ] - ], + Positioned( + right: 4, + bottom: 10, + child: Text( + media.videoDuration.format(), + style: TextStyle( + color: chatThemeData.colorTheme.barsBg, + ), + ), + ), + ] + ], + ), ), - ), - ); - }, - ), - ); - } + ); + }, + ), + ); @override void initState() { @@ -144,10 +145,8 @@ class _MediaListViewState extends State { } void _getMedia() async { - final assetList = await PhotoManager.getAssetPathList( - hasAll: true, - ).then((value) { - if (value?.isNotEmpty == true) { + final assetList = await PhotoManager.getAssetPathList().then((value) { + if (value.isNotEmpty == true) { return value.singleWhere((element) => element.isAll); } }); @@ -167,48 +166,49 @@ class _MediaListViewState extends State { } } +/// ImageProvider implementation class MediaThumbnailProvider extends ImageProvider { + /// Constructor for creating a [MediaThumbnailProvider] const MediaThumbnailProvider({ - @required this.media, - }) : assert(media != null); + required this.media, + }); + /// Media to load final AssetEntity media; @override - ImageStreamCompleter load(key, decode) { - return MultiFrameImageStreamCompleter( - codec: _loadAsync(key, decode), - scale: 1.0, - informationCollector: () sync* { - yield ErrorDescription('Id: ${media?.id}'); - }, - ); - } + ImageStreamCompleter load( + MediaThumbnailProvider key, DecoderCallback decode) => + MultiFrameImageStreamCompleter( + codec: _loadAsync(key, decode), + scale: 1, + informationCollector: () sync* { + yield ErrorDescription('Id: ${media.id}'); + }, + ); Future _loadAsync( MediaThumbnailProvider key, DecoderCallback decode) async { - assert(key == this); + assert(key == this, 'Checks MediaThumbnailProvider'); final bytes = await media.thumbData; - if (bytes?.isNotEmpty != true) return null; - return await decode(bytes); + return decode(bytes!); } @override - Future obtainKey(ImageConfiguration configuration) { - return SynchronousFuture(this); - } + Future obtainKey(ImageConfiguration configuration) => + SynchronousFuture(this); @override bool operator ==(dynamic other) { if (other.runtimeType != runtimeType) return false; final MediaThumbnailProvider typedOther = other; - return media?.id == typedOther.media?.id; + return media.id == typedOther.media.id; } @override - int get hashCode => media?.id?.hashCode ?? 0; + int get hashCode => media.id.hashCode; @override - String toString() => '$runtimeType("${media?.id}")'; + String toString() => '$runtimeType("${media.id}")'; } diff --git a/packages/stream_chat_flutter/lib/src/mention_tile.dart b/packages/stream_chat_flutter/lib/src/mention_tile.dart index 7d8b0ada..d9469215 100644 --- a/packages/stream_chat_flutter/lib/src/mention_tile.dart +++ b/packages/stream_chat_flutter/lib/src/mention_tile.dart @@ -1,55 +1,57 @@ import 'package:flutter/material.dart'; - -import '../stream_chat_flutter.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; /// This widget is used for showing user tiles for mentions -/// Use [title], [subtitle], [leading], [trailing] for substituting widgets in respective positions +/// Use [title], [subtitle], [leading], [trailing] for +/// substituting widgets in respective positions class MentionTile extends StatelessWidget { - /// Member to display in the tile - final Member member; - - /// Widget to display as title - final Widget title; - - /// Widget to display below [title] - final Widget subtitle; - - /// Widget at the start of the tile - final Widget leading; - - /// Widget at the end of tile - final Widget trailing; - - MentionTile( + /// Constructor for creating a [MentionTile] widget + const MentionTile( this.member, { + Key? key, this.title, this.subtitle, this.leading, this.trailing, - }); + }) : super(key: key); + + /// Member to display in the tile + final Member member; + + /// Widget to display as title + final Widget? title; + + /// Widget to display below [title] + final Widget? subtitle; + + /// Widget at the start of the tile + final Widget? leading; + + /// Widget at the end of tile + final Widget? trailing; @override Widget build(BuildContext context) { - return Container( - height: 56.0, + final chatThemeData = StreamChatTheme.of(context); + return SizedBox( + height: 56, child: Row( - crossAxisAlignment: CrossAxisAlignment.center, children: [ - SizedBox( - width: 16.0, + const SizedBox( + width: 16, ), leading ?? UserAvatar( constraints: BoxConstraints.tight( - Size( + const Size( 40, 40, ), ), - user: member.user, + user: member.user!, ), - SizedBox( - width: 8.0, + const SizedBox( + width: 8, ), Expanded( child: Align( @@ -60,26 +62,22 @@ class MentionTile extends StatelessWidget { children: [ title ?? Text( - '${member.user.name}', + member.user!.name, maxLines: 1, overflow: TextOverflow.ellipsis, - style: StreamChatTheme.of(context).textTheme.bodyBold, + style: chatThemeData.textTheme.bodyBold, ), - SizedBox( - height: 2.0, + const SizedBox( + height: 2, ), subtitle ?? Text( '@${member.userId}', maxLines: 1, overflow: TextOverflow.ellipsis, - style: StreamChatTheme.of(context) - .textTheme - .footnoteBold - .copyWith( - color: - StreamChatTheme.of(context).colorTheme.grey, - ), + style: chatThemeData.textTheme.footnoteBold.copyWith( + color: chatThemeData.colorTheme.textLowEmphasis, + ), ), ], ), @@ -87,9 +85,12 @@ class MentionTile extends StatelessWidget { ), trailing ?? Padding( - padding: const EdgeInsets.only(right: 18.0, left: 8.0), + padding: const EdgeInsets.only( + right: 18, + left: 8, + ), child: StreamSvgIcon.mentions( - color: StreamChatTheme.of(context).colorTheme.accentBlue, + color: chatThemeData.colorTheme.accentPrimary, ), ), ], diff --git a/packages/stream_chat_flutter/lib/src/message_action.dart b/packages/stream_chat_flutter/lib/src/message_action.dart index 5cedc962..49a57b1e 100644 --- a/packages/stream_chat_flutter/lib/src/message_action.dart +++ b/packages/stream_chat_flutter/lib/src/message_action.dart @@ -3,19 +3,19 @@ import 'package:stream_chat_flutter/stream_chat_flutter.dart'; /// Class describing a message action class MessageAction { - /// leading widget - final Widget leading; - - /// title widget - final Widget title; - - /// callback called on tap - final OnMessageTap onTap; - /// returns a new instance of a [MessageAction] MessageAction({ this.leading, this.title, this.onTap, }); + + /// leading widget + final Widget? leading; + + /// title widget + final Widget? title; + + /// callback called on tap + final OnMessageTap? onTap; } diff --git a/packages/stream_chat_flutter/lib/src/message_actions_modal.dart b/packages/stream_chat_flutter/lib/src/message_actions_modal.dart index c997d09c..3e653187 100644 --- a/packages/stream_chat_flutter/lib/src/message_actions_modal.dart +++ b/packages/stream_chat_flutter/lib/src/message_actions_modal.dart @@ -1,48 +1,22 @@ -import 'dart:convert'; import 'dart:ui'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; +import 'package:stream_chat_flutter/src/extension.dart'; import 'package:stream_chat_flutter/src/message_action.dart'; import 'package:stream_chat_flutter/src/reaction_picker.dart'; import 'package:stream_chat_flutter/src/stream_svg_icon.dart'; import 'package:stream_chat_flutter/src/utils.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; -import 'extension.dart'; -import 'message_input.dart'; -import 'message_widget.dart'; -import 'stream_chat.dart'; -import 'stream_chat_theme.dart'; - +/// Constructs a modal with actions for a message class MessageActionsModal extends StatefulWidget { - final Widget Function(BuildContext, Message) editMessageInputBuilder; - final void Function(Message) onThreadReplyTap; - final void Function(Message) onReplyTap; - final Message message; - final MessageTheme messageTheme; - final bool showReactions; - final bool showDeleteMessage; - final bool showCopyMessage; - final bool showEditMessage; - final bool showResendMessage; - final bool showReplyMessage; - final bool showThreadReplyMessage; - final bool showFlagButton; - final bool reverse; - final ShapeBorder messageShape; - final ShapeBorder attachmentShape; - final DisplayWidget showUserAvatar; - final BorderRadius attachmentBorderRadiusGeometry; - - /// List of custom actions - final List customActions; - + /// Constructor for creating a [MessageActionsModal] widget const MessageActionsModal({ - Key key, - @required this.message, - @required this.messageTheme, + Key? key, + required this.message, + required this.messageWidget, + required this.messageTheme, this.showReactions = true, this.showDeleteMessage = true, this.showEditMessage = true, @@ -53,15 +27,67 @@ class MessageActionsModal extends StatefulWidget { this.showResendMessage = true, this.showThreadReplyMessage = true, this.showFlagButton = true, - this.showUserAvatar = DisplayWidget.show, + this.showPinButton = true, this.editMessageInputBuilder, - this.messageShape, - this.attachmentShape, this.reverse = false, this.customActions = const [], - this.attachmentBorderRadiusGeometry, + this.onCopyTap, }) : super(key: key); + /// Widget that shows the message + final Widget messageWidget; + + /// Builder for edit message + final Widget Function(BuildContext, Message)? editMessageInputBuilder; + + /// Callback for when thread reply is tapped + final OnMessageTap? onThreadReplyTap; + + /// Callback for when reply is tapped + final OnMessageTap? onReplyTap; + + /// Message in focus for actions + final Message message; + + /// [MessageTheme] for message + final MessageTheme messageTheme; + + /// Flag for showing reactions + final bool showReactions; + + /// Callback when copy is tapped + final OnMessageTap? onCopyTap; + + /// Callback when delete is tapped + final bool showDeleteMessage; + + /// Flag for showing copy action + final bool showCopyMessage; + + /// Flag for showing edit action + final bool showEditMessage; + + /// Flag for showing resend action + final bool showResendMessage; + + /// Flag for showing reply action + final bool showReplyMessage; + + /// Flag for showing thread reply action + final bool showThreadReplyMessage; + + /// Flag for showing flag action + final bool showFlagButton; + + /// Flag for showing pin action + final bool showPinButton; + + /// Flag for reversing message + final bool reverse; + + /// List of custom actions + final List customActions; + @override _MessageActionsModalState createState() => _MessageActionsModalState(); } @@ -70,33 +96,119 @@ class _MessageActionsModalState extends State { bool _showActions = true; @override - Widget build(BuildContext context) { - return _showMessageOptionsModal(); - } + Widget build(BuildContext context) => _showMessageOptionsModal(); Widget _showMessageOptionsModal() { - final size = MediaQuery.of(context).size; + final mediaQueryData = MediaQuery.of(context); + final size = mediaQueryData.size; final user = StreamChat.of(context).user; final roughMaxSize = 2 * size.width / 3; - var messageTextLength = widget.message.text.length; + var messageTextLength = widget.message.text!.length; if (widget.message.quotedMessage != null) { - var quotedMessageLength = widget.message.quotedMessage.text.length + 40; - if (widget.message.quotedMessage.attachments?.isNotEmpty == true) { + var quotedMessageLength = + (widget.message.quotedMessage!.text?.length ?? 0) + 40; + if (widget.message.quotedMessage!.attachments.isNotEmpty) { quotedMessageLength += 40; } if (quotedMessageLength > messageTextLength) { messageTextLength = quotedMessageLength; } } - final roughSentenceSize = - messageTextLength * widget.messageTheme.messageText.fontSize * 1.2; - final divFactor = widget.message.attachments?.isNotEmpty == true + final roughSentenceSize = messageTextLength * + (widget.messageTheme.messageText?.fontSize ?? 1) * + 1.2; + final divFactor = widget.message.attachments.isNotEmpty == true ? 1 : (roughSentenceSize == 0 ? 1 : (roughSentenceSize / roughMaxSize)); - final hasFileAttachment = - widget.message.attachments?.any((it) => it.type == 'file') == true; + final streamChatThemeData = StreamChatTheme.of(context); + + final numberOfReactions = streamChatThemeData.reactionIcons.length; + final shiftFactor = + numberOfReactions < 5 ? (5 - numberOfReactions) * 0.1 : 0.0; + + final child = Center( + child: SingleChildScrollView( + child: Padding( + padding: const EdgeInsets.all(8), + child: Column( + crossAxisAlignment: widget.reverse + ? CrossAxisAlignment.end + : CrossAxisAlignment.start, + children: [ + if (widget.showReactions && + (widget.message.status == MessageSendingStatus.sent)) + Align( + alignment: Alignment( + user?.id == widget.message.user?.id + ? (divFactor >= 1.0 + ? -0.2 - shiftFactor + : (1.2 - divFactor)) + : (divFactor >= 1.0 + ? 0.2 + shiftFactor + : -(1.2 - divFactor)), + 0), + child: ReactionPicker( + message: widget.message, + ), + ), + const SizedBox(height: 8), + IgnorePointer( + child: widget.messageWidget, + ), + const SizedBox(height: 8), + Padding( + padding: EdgeInsets.only( + left: widget.reverse ? 0 : 40, + ), + child: SizedBox( + width: mediaQueryData.size.width * 0.75, + child: Material( + color: streamChatThemeData.colorTheme.appBg, + clipBehavior: Clip.hardEdge, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (widget.showReplyMessage && + widget.message.status == MessageSendingStatus.sent) + _buildReplyButton(context), + if (widget.showThreadReplyMessage && + (widget.message.status == + MessageSendingStatus.sent) && + widget.message.parentId == null) + _buildThreadReplyButton(context), + if (widget.showResendMessage) + _buildResendMessage(context), + if (widget.showEditMessage) _buildEditMessage(context), + if (widget.showCopyMessage) _buildCopyButton(context), + if (widget.showFlagButton) _buildFlagButton(context), + if (widget.showPinButton) _buildPinButton(context), + if (widget.showDeleteMessage) + _buildDeleteButton(context), + ...widget.customActions + .map((action) => _buildCustomAction( + context, + action, + )) + ].insertBetween( + Container( + height: 1, + color: streamChatThemeData.colorTheme.borders, + ), + ), + ), + ), + ), + ), + ], + ), + ), + ), + ); return GestureDetector( behavior: HitTestBehavior.translucent, @@ -110,153 +222,20 @@ class _MessageActionsModalState extends State { sigmaY: 10, ), child: Container( - color: StreamChatTheme.of(context).colorTheme.overlay, + color: streamChatThemeData.colorTheme.overlay, ), ), ), if (_showActions) TweenAnimationBuilder( - tween: Tween(begin: 0.0, end: 1.0), - duration: Duration(milliseconds: 300), + tween: Tween(begin: 0, end: 1), + duration: const Duration(milliseconds: 300), curve: Curves.easeInOutBack, - builder: (context, val, snapshot) { - return Transform.scale( - scale: val, - child: Center( - child: SingleChildScrollView( - child: Padding( - padding: const EdgeInsets.all(8.0), - child: Column( - crossAxisAlignment: widget.reverse - ? CrossAxisAlignment.end - : CrossAxisAlignment.start, - children: [ - if (widget.showReactions && - (widget.message.status == - MessageSendingStatus.sent || - widget.message.status == null)) - Align( - alignment: Alignment( - user.id == widget.message.user.id - ? (divFactor > 1.0 - ? 0.0 - : (1.0 - divFactor)) - : (divFactor > 1.0 - ? 0.0 - : -(1.0 - divFactor)), - 0.0), - child: ReactionPicker( - message: widget.message, - messageTheme: widget.messageTheme, - ), - ), - SizedBox(height: 8), - IgnorePointer( - child: MessageWidget( - key: Key('MessageWidget'), - reverse: widget.reverse, - attachmentBorderRadiusGeometry: - widget.attachmentBorderRadiusGeometry, - message: widget.message.copyWith( - text: widget.message.text.length > 200 - ? '${widget.message.text.substring(0, 200)}...' - : widget.message.text, - ), - messageTheme: widget.messageTheme, - showReactions: false, - showUsername: false, - showThreadReplyIndicator: false, - showReplyMessage: false, - showUserAvatar: widget.showUserAvatar, - attachmentPadding: EdgeInsets.all( - hasFileAttachment ? 4 : 2, - ), - showTimestamp: false, - translateUserAvatar: false, - padding: const EdgeInsets.all(0), - textPadding: EdgeInsets.symmetric( - vertical: 8.0, - horizontal: widget.message.text.isOnlyEmoji - ? 0 - : 16.0, - ), - showReactionPickerIndicator: - widget.showReactions && - (widget.message.status == - MessageSendingStatus.sent || - widget.message.status == null), - showInChannelIndicator: false, - showSendingIndicator: false, - shape: widget.messageShape, - attachmentShape: widget.attachmentShape, - ), - ), - SizedBox(height: 8), - Padding( - padding: EdgeInsets.only( - left: widget.reverse ? 0 : 40, - ), - child: SizedBox( - width: MediaQuery.of(context).size.width * 0.75, - child: Material( - color: StreamChatTheme.of(context) - .colorTheme - .whiteSnow, - clipBehavior: Clip.hardEdge, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(16), - ), - child: Column( - crossAxisAlignment: - CrossAxisAlignment.stretch, - children: [ - if (widget.showReplyMessage && - (widget.message.status == - MessageSendingStatus.sent || - widget.message.status == null) && - widget.message.parentId == null) - _buildReplyButton(context), - if (widget.showThreadReplyMessage && - (widget.message.status == - MessageSendingStatus.sent || - widget.message.status == null) && - widget.message.parentId == null) - _buildThreadReplyButton(context), - if (widget.showResendMessage) - _buildResendMessage(context), - if (widget.showEditMessage) - _buildEditMessage(context), - if (widget.showCopyMessage) - _buildCopyButton(context), - if (widget.showFlagButton) - _buildFlagButton(context), - if (widget.showDeleteMessage) - _buildDeleteButton(context), - ...widget.customActions.map((action) { - return _buildCustomAction( - context, - action, - ); - }) - ].insertBetween( - Container( - height: 1, - color: StreamChatTheme.of(context) - .colorTheme - .greyWhisper, - ), - ), - ), - ), - ), - ), - ], - ), - ), - ), - ), - ); - }, + builder: (context, val, child) => Transform.scale( + scale: val, + child: child, + ), + child: child, ), ], ), @@ -266,61 +245,63 @@ class _MessageActionsModalState extends State { InkWell _buildCustomAction( BuildContext context, MessageAction messageAction, - ) { - return InkWell( - onTap: () { - messageAction.onTap?.call(widget.message); - }, - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 11.0, horizontal: 16.0), - child: Row( - children: [ - messageAction.leading ?? Offstage(), - const SizedBox(width: 16), - messageAction.title ?? Offstage(), - ], + ) => + InkWell( + onTap: () { + messageAction.onTap?.call(widget.message); + }, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 11, horizontal: 16), + child: Row( + children: [ + messageAction.leading ?? const Offstage(), + const SizedBox(width: 16), + messageAction.title ?? const Offstage(), + ], + ), ), - ), - ); - } + ); void _showFlagDialog() async { final client = StreamChat.of(context).client; + final streamChatThemeData = StreamChatTheme.of(context); final answer = await showConfirmationDialog( context, title: 'Flag Message', icon: StreamSvgIcon.flag( - color: StreamChatTheme.of(context).colorTheme.accentRed, - size: 24.0, + color: streamChatThemeData.colorTheme.accentError, + size: 24, ), question: + // ignore: lines_longer_than_80_chars 'Do you want to send a copy of this message to a\nmoderator for further investigation?', okText: 'FLAG', cancelText: 'CANCEL', ); - final theme = StreamChatTheme.of(context); + final theme = streamChatThemeData; if (answer == true) { try { await client.flagMessage(widget.message.id); await showInfoDialog( context, icon: StreamSvgIcon.flag( - color: theme.colorTheme.accentRed, - size: 24.0, + color: theme.colorTheme.accentError, + size: 24, ), details: 'The message has been reported to a moderator.', title: 'Message flagged', okText: 'OK', ); } catch (err) { - if (json.decode(err?.body ?? {})['code'] == 4) { + if (err is StreamChatNetworkError && + err.errorCode == ChatErrorCode.inputError) { await showInfoDialog( context, icon: StreamSvgIcon.flag( - color: theme.colorTheme.accentRed, - size: 24.0, + color: theme.colorTheme.accentError, + size: 24, ), details: 'The message has been reported to a moderator.', title: 'Message flagged', @@ -333,23 +314,38 @@ class _MessageActionsModalState extends State { } } + void _togglePin() async { + final channel = StreamChannel.of(context).channel; + + Navigator.pop(context); + try { + if (!widget.message.pinned) { + await channel.pinMessage(widget.message); + } else { + await channel.unpinMessage(widget.message); + } + } catch (e) { + _showErrorAlert(); + } + } + void _showDeleteDialog() async { setState(() { _showActions = false; }); - var answer = await showConfirmationDialog( + final answer = await showConfirmationDialog( context, title: 'Delete message', icon: StreamSvgIcon.flag( - color: StreamChatTheme.of(context).colorTheme.accentRed, - size: 24.0, + color: StreamChatTheme.of(context).colorTheme.accentError, + size: 24, ), question: 'Are you sure you want to permanently delete this\nmessage?', okText: 'DELETE', cancelText: 'CANCEL', ); - if (answer) { + if (answer == true) { try { Navigator.pop(context); await StreamChannel.of(context).channel.deleteMessage(widget.message); @@ -367,8 +363,8 @@ class _MessageActionsModalState extends State { showInfoDialog( context, icon: StreamSvgIcon.error( - color: StreamChatTheme.of(context).colorTheme.accentRed, - size: 24.0, + color: StreamChatTheme.of(context).colorTheme.accentError, + size: 24, ), details: 'The operation couldn\'t be completed.', title: 'Something went wrong', @@ -377,24 +373,25 @@ class _MessageActionsModalState extends State { } Widget _buildReplyButton(BuildContext context) { + final streamChatThemeData = StreamChatTheme.of(context); return InkWell( onTap: () { Navigator.pop(context); if (widget.onReplyTap != null) { - widget.onReplyTap(widget.message); + widget.onReplyTap!(widget.message); } }, child: Padding( - padding: const EdgeInsets.symmetric(vertical: 11.0, horizontal: 16.0), + padding: const EdgeInsets.symmetric(vertical: 11, horizontal: 16), child: Row( children: [ StreamSvgIcon.reply( - color: StreamChatTheme.of(context).primaryIconTheme.color, + color: streamChatThemeData.primaryIconTheme.color, ), const SizedBox(width: 16), Text( 'Reply', - style: StreamChatTheme.of(context).textTheme.body, + style: streamChatThemeData.textTheme.body, ), ], ), @@ -403,19 +400,43 @@ class _MessageActionsModalState extends State { } Widget _buildFlagButton(BuildContext context) { + final streamChatThemeData = StreamChatTheme.of(context); return InkWell( - onTap: () => _showFlagDialog(), + onTap: _showFlagDialog, child: Padding( - padding: const EdgeInsets.symmetric(vertical: 11.0, horizontal: 16.0), + padding: const EdgeInsets.symmetric(vertical: 11, horizontal: 16), child: Row( children: [ StreamSvgIcon.iconFlag( - color: StreamChatTheme.of(context).primaryIconTheme.color, + color: streamChatThemeData.primaryIconTheme.color, ), const SizedBox(width: 16), Text( 'Flag Message', - style: StreamChatTheme.of(context).textTheme.body, + style: streamChatThemeData.textTheme.body, + ), + ], + ), + ), + ); + } + + Widget _buildPinButton(BuildContext context) { + final streamChatThemeData = StreamChatTheme.of(context); + return InkWell( + onTap: _togglePin, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 11, horizontal: 16), + child: Row( + children: [ + StreamSvgIcon.pin( + color: streamChatThemeData.primaryIconTheme.color, + size: 24, + ), + const SizedBox(width: 16), + Text( + '${widget.message.pinned ? 'Unpin from' : 'Pin to'} Conversation', + style: streamChatThemeData.textTheme.body, ), ], ), @@ -427,9 +448,9 @@ class _MessageActionsModalState extends State { final isDeleteFailed = widget.message.status == MessageSendingStatus.failed_delete; return InkWell( - onTap: () => _showDeleteDialog(), + onTap: _showDeleteDialog, child: Padding( - padding: const EdgeInsets.symmetric(vertical: 11.0, horizontal: 16.0), + padding: const EdgeInsets.symmetric(vertical: 11, horizontal: 16), child: Row( children: [ StreamSvgIcon.delete( @@ -450,23 +471,24 @@ class _MessageActionsModalState extends State { } Widget _buildCopyButton(BuildContext context) { + final streamChatThemeData = StreamChatTheme.of(context); return InkWell( onTap: () async { - await Clipboard.setData(ClipboardData(text: widget.message.text)); + widget.onCopyTap?.call(widget.message); Navigator.pop(context); }, child: Padding( - padding: const EdgeInsets.symmetric(vertical: 11.0, horizontal: 16.0), + padding: const EdgeInsets.symmetric(vertical: 11, horizontal: 16), child: Row( children: [ StreamSvgIcon.copy( size: 24, - color: StreamChatTheme.of(context).primaryIconTheme.color, + color: streamChatThemeData.primaryIconTheme.color, ), const SizedBox(width: 16), Text( 'Copy Message', - style: StreamChatTheme.of(context).textTheme.body, + style: streamChatThemeData.textTheme.body, ), ], ), @@ -475,22 +497,23 @@ class _MessageActionsModalState extends State { } Widget _buildEditMessage(BuildContext context) { + final streamChatThemeData = StreamChatTheme.of(context); return InkWell( onTap: () async { Navigator.pop(context); _showEditBottomSheet(context); }, child: Padding( - padding: const EdgeInsets.symmetric(vertical: 11.0, horizontal: 16.0), + padding: const EdgeInsets.symmetric(vertical: 11, horizontal: 16), child: Row( children: [ StreamSvgIcon.edit( - color: StreamChatTheme.of(context).primaryIconTheme.color, + color: streamChatThemeData.primaryIconTheme.color, ), const SizedBox(width: 16), Text( 'Edit Message', - style: StreamChatTheme.of(context).textTheme.body, + style: streamChatThemeData.textTheme.body, ), ], ), @@ -501,6 +524,7 @@ class _MessageActionsModalState extends State { Widget _buildResendMessage(BuildContext context) { final isUpdateFailed = widget.message.status == MessageSendingStatus.failed_update; + final streamChatThemeData = StreamChatTheme.of(context); return InkWell( onTap: () { Navigator.pop(context); @@ -512,16 +536,16 @@ class _MessageActionsModalState extends State { } }, child: Padding( - padding: const EdgeInsets.symmetric(vertical: 11.0, horizontal: 16.0), + padding: const EdgeInsets.symmetric(vertical: 11, horizontal: 16), child: Row( children: [ StreamSvgIcon.circleUp( - color: StreamChatTheme.of(context).colorTheme.accentBlue, + color: streamChatThemeData.colorTheme.accentPrimary, ), const SizedBox(width: 16), Text( isUpdateFailed ? 'Resend Edited Message' : 'Resend', - style: StreamChatTheme.of(context).textTheme.body, + style: streamChatThemeData.textTheme.body, ), ], ), @@ -531,21 +555,22 @@ class _MessageActionsModalState extends State { void _showEditBottomSheet(BuildContext context) { final channel = StreamChannel.of(context).channel; + final streamChatThemeData = StreamChatTheme.of(context); showModalBottomSheet( context: context, elevation: 2, clipBehavior: Clip.hardEdge, isScrollControlled: true, - backgroundColor: - StreamChatTheme.of(context).messageInputTheme.inputBackground, - shape: RoundedRectangleBorder( + backgroundColor: streamChatThemeData.messageInputTheme.inputBackground, + shape: const RoundedRectangleBorder( borderRadius: BorderRadius.only( topLeft: Radius.circular(16), topRight: Radius.circular(16), ), ), - builder: (context) { - return StreamChannel( + builder: (context) => Padding( + padding: MediaQuery.of(context).viewInsets, + child: StreamChannel( channel: channel, child: Flex( direction: Axis.vertical, @@ -558,14 +583,12 @@ class _MessageActionsModalState extends State { mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Padding( - padding: const EdgeInsets.all(8.0), + padding: const EdgeInsets.all(8), child: StreamSvgIcon.edit( - color: StreamChatTheme.of(context) - .colorTheme - .greyGainsboro, + color: streamChatThemeData.colorTheme.disabled, ), ), - Text( + const Text( 'Edit Message', style: TextStyle(fontWeight: FontWeight.bold), ), @@ -577,42 +600,44 @@ class _MessageActionsModalState extends State { ], ), ), - widget.editMessageInputBuilder != null - ? widget.editMessageInputBuilder(context, widget.message) - : MessageInput( - editMessage: widget.message, - preMessageSending: (m) { - FocusScope.of(context).unfocus(); - Navigator.pop(context); - return m; - }, - ), + if (widget.editMessageInputBuilder != null) + widget.editMessageInputBuilder!(context, widget.message) + else + MessageInput( + editMessage: widget.message, + preMessageSending: (m) { + FocusScope.of(context).unfocus(); + Navigator.pop(context); + return m; + }, + ), ], ), - ); - }, + ), + ), ); } Widget _buildThreadReplyButton(BuildContext context) { + final streamChatThemeData = StreamChatTheme.of(context); return InkWell( onTap: () { Navigator.pop(context); if (widget.onThreadReplyTap != null) { - widget.onThreadReplyTap(widget.message); + widget.onThreadReplyTap!(widget.message); } }, child: Padding( - padding: const EdgeInsets.symmetric(vertical: 11.0, horizontal: 16.0), + padding: const EdgeInsets.symmetric(vertical: 11, horizontal: 16), child: Row( children: [ StreamSvgIcon.thread( - color: StreamChatTheme.of(context).primaryIconTheme.color, + color: streamChatThemeData.primaryIconTheme.color, ), const SizedBox(width: 16), Text( 'Thread Reply', - style: StreamChatTheme.of(context).textTheme.body, + style: streamChatThemeData.textTheme.body, ), ], ), diff --git a/packages/stream_chat_flutter/lib/src/message_input.dart b/packages/stream_chat_flutter/lib/src/message_input.dart index 385dc41f..5a4f6822 100644 --- a/packages/stream_chat_flutter/lib/src/message_input.dart +++ b/packages/stream_chat_flutter/lib/src/message_input.dart @@ -2,7 +2,6 @@ import 'dart:async'; import 'dart:math'; import 'package:cached_network_image/cached_network_image.dart'; -import 'package:emojis/emoji.dart'; import 'package:file_picker/file_picker.dart'; import 'package:flutter/cupertino.dart'; import 'package:flutter/foundation.dart'; @@ -12,20 +11,33 @@ import 'package:flutter_svg/flutter_svg.dart'; import 'package:image_picker/image_picker.dart'; import 'package:photo_manager/photo_manager.dart'; import 'package:shimmer/shimmer.dart'; +import 'package:stream_chat_flutter/src/emoji/emoji.dart'; +import 'package:stream_chat_flutter/src/extension.dart'; import 'package:stream_chat_flutter/src/media_list_view.dart'; import 'package:stream_chat_flutter/src/message_list_view.dart'; +import 'package:stream_chat_flutter/src/quoted_message_widget.dart'; import 'package:stream_chat_flutter/src/stream_chat_theme.dart'; import 'package:stream_chat_flutter/src/stream_svg_icon.dart'; import 'package:stream_chat_flutter/src/video_service.dart'; +import 'package:stream_chat_flutter/src/video_thumbnail_image.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart'; import 'package:substring_highlight/substring_highlight.dart'; +import 'package:video_compress/video_compress.dart'; -import '../stream_chat_flutter.dart'; -import 'attachment/attachment.dart'; -import 'extension.dart'; -import 'quoted_message_widget.dart'; -import 'video_thumbnail_image.dart'; +export 'package:video_compress/video_compress.dart' show VideoQuality; +/// A callback that can be passed to [MessageInput.onError]. +/// +/// This callback should not throw. +/// +/// It exists merely for error reporting, and should not be used otherwise. +typedef ErrorListener = void Function( + Object error, + StackTrace? stackTrace, +); + +/// Builder for attachment thumbnails typedef AttachmentThumbnailBuilder = Widget Function( BuildContext, Attachment, @@ -38,16 +50,30 @@ typedef MentionTileBuilder = Widget Function( Member member, ); +/// Location for actions on the [MessageInput] enum ActionsLocation { + /// Align to left left, + + /// Align to right right, + + /// Align to left but inside the [TextField] leftInside, + + /// Align to right but inside the [TextField] rightInside, } +/// Default attachments for widget enum DefaultAttachmentTypes { + /// Image Attachment image, + + /// Video Attachment video, + + /// File Attachment file, } @@ -62,7 +88,7 @@ enum SendButtonLocation { const _kMinMediaPickerSize = 360.0; -const _kMaxAttachmentSize = 20971520; // 20MB in Bytes +const _kDefaultMaxAttachmentSize = 20971520; // 20MB in Bytes /// Inactive state /// ![screenshot](https://raw.githubusercontent.com/GetStream/stream-chat-flutter/master/screenshots/message_input.png) @@ -102,14 +128,16 @@ const _kMaxAttachmentSize = 20971520; // 20MB in Bytes /// } /// ``` /// -/// You usually put this widget in the same page of a [MessageListView] as the bottom widget. +/// You usually put this widget in the same page of a [MessageListView] +/// as the bottom widget. /// -/// The widget renders the ui based on the first ancestor of type [StreamChatTheme]. +/// The widget renders the ui based on the first ancestor of +/// type [StreamChatTheme]. /// Modify it to change the widget appearance. class MessageInput extends StatefulWidget { /// Instantiate a new MessageInput - MessageInput({ - Key key, + const MessageInput({ + Key? key, this.onMessageSent, this.preMessageSending, this.parentMessage, @@ -132,23 +160,38 @@ class MessageInput extends StatefulWidget { this.activeSendButton, this.showCommandsButton = true, this.mentionsTileBuilder, + this.maxAttachmentSize = _kDefaultMaxAttachmentSize, + this.compressedVideoQuality = VideoQuality.DefaultQuality, + this.compressedVideoFrameRate = 30, + this.onError, }) : super(key: key); /// Message to edit - final Message editMessage; + final Message? editMessage; + + /// Video quality to use when compressing the videos + final VideoQuality compressedVideoQuality; + + /// Frame rate to use when compressing the videos + final int compressedVideoFrameRate; + + /// Max attachment size in bytes + /// Defaults to 20 MB + /// do not set it if you're using our default CDN + final int maxAttachmentSize; /// Message to start with - final Message initialMessage; + final Message? initialMessage; /// Function called after sending the message - final void Function(Message) onMessageSent; + final void Function(Message)? onMessageSent; /// Function called right before sending the message /// Use this to transform the message - final FutureOr Function(Message) preMessageSending; + final FutureOr Function(Message)? preMessageSending; /// Parent message in case of a thread - final Message parentMessage; + final Message? parentMessage; /// Maximum Height for the TextField to grow before it starts scrolling final double maxHeight; @@ -166,25 +209,25 @@ class MessageInput extends StatefulWidget { final bool hideSendAsDm; /// The text controller of the TextField - final TextEditingController textEditingController; + final TextEditingController? textEditingController; /// List of action widgets - final List actions; + final List? actions; /// The location of the custom actions final ActionsLocation actionsLocation; /// Map that defines a thumbnail builder for an attachment type - final Map attachmentThumbnailBuilders; + final Map? attachmentThumbnailBuilders; /// The focus node associated to the TextField - final FocusNode focusNode; + final FocusNode? focusNode; /// - final Message quotedMessage; + final Message? quotedMessage; /// - final VoidCallback onQuotedMessageCleared; + final VoidCallback? onQuotedMessageCleared; /// The location of the send button final SendButtonLocation sendButtonLocation; @@ -193,46 +236,46 @@ class MessageInput extends StatefulWidget { final bool autofocus; /// Send button widget in an idle state - final Widget idleSendButton; + final Widget? idleSendButton; /// Send button widget in an active state - final Widget activeSendButton; + final Widget? activeSendButton; /// Customize the tile for the mentions overlay - final MentionTileBuilder mentionsTileBuilder; + final MentionTileBuilder? mentionsTileBuilder; + + /// A callback for error reporting + final ErrorListener? onError; @override MessageInputState createState() => MessageInputState(); /// Use this method to get the current [StreamChatState] instance static MessageInputState of(BuildContext context) { - MessageInputState messageInputState; - + MessageInputState? messageInputState; messageInputState = context.findAncestorStateOfType(); - - if (messageInputState == null) { - throw Exception( - 'You must have a MessageInput widget as ancestor of your widget tree'); - } - - return messageInputState; + assert( + messageInputState != null, + 'You must have a MessageInput widget as ancestor of your widget tree', + ); + return messageInputState!; } } +/// State of [MessageInput] class MessageInputState extends State { final _attachments = {}; final List _mentionedUsers = []; final _imagePicker = ImagePicker(); - FocusNode _focusNode; + late final FocusNode _focusNode; bool _inputEnabled = true; bool _messageIsPresent = false; - bool _animateContainer = true; bool _commandEnabled = false; - OverlayEntry _commandsOverlay, _mentionsOverlay, _emojiOverlay; - Iterable _emojiNames; + OverlayEntry? _commandsOverlay, _mentionsOverlay, _emojiOverlay; + late Iterable _emojiNames; - Command _chosenCommand; + Command? _chosenCommand; bool _actionsShrunk = false; bool _sendAsDm = false; bool _openFilePickerSection = false; @@ -242,7 +285,9 @@ class MessageInputState extends State { KeyboardVisibilityController(); /// The editing controller passed to the input TextField - TextEditingController textEditingController; + late final TextEditingController textEditingController; + + late StreamChatThemeData _streamChatTheme; bool get _hasQuotedMessage => widget.quotedMessage != null; @@ -250,7 +295,8 @@ class MessageInputState extends State { void initState() { super.initState(); _focusNode = widget.focusNode ?? FocusNode(); - _emojiNames = Emoji.all().map((e) => e.name); + _emojiNames = + Emoji.all().where((it) => it.name != null).map((e) => e.name!); if (!kIsWeb) { _keyboardListener = @@ -264,7 +310,7 @@ class MessageInputState extends State { textEditingController = widget.textEditingController ?? TextEditingController(); if (widget.editMessage != null || widget.initialMessage != null) { - _parseExistingMessage(widget.editMessage ?? widget.initialMessage); + _parseExistingMessage(widget.editMessage ?? widget.initialMessage!); } textEditingController.addListener(() { @@ -280,8 +326,10 @@ class MessageInputState extends State { @override Widget build(BuildContext context) { - Widget child = Container( - color: StreamChatTheme.of(context).messageInputTheme.inputBackground, + Widget child = DecoratedBox( + decoration: BoxDecoration( + color: _streamChatTheme.messageInputTheme.inputBackground, + ), child: SafeArea( child: GestureDetector( onPanUpdate: (details) { @@ -304,14 +352,12 @@ class MessageInputState extends State { mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Padding( - padding: const EdgeInsets.all(8.0), + padding: const EdgeInsets.all(8), child: StreamSvgIcon.reply( - color: StreamChatTheme.of(context) - .colorTheme - .greyGainsboro, + color: _streamChatTheme.colorTheme.disabled, ), ), - Text( + const Text( 'Reply to Message', style: TextStyle(fontWeight: FontWeight.bold), ), @@ -324,15 +370,15 @@ class MessageInputState extends State { ), ), Padding( - padding: const EdgeInsets.symmetric(vertical: 8.0), + padding: const EdgeInsets.symmetric(vertical: 8), child: _buildTextField(context), ), if (widget.parentMessage != null && !widget.hideSendAsDm) Padding( padding: const EdgeInsets.only( - right: 12.0, - left: 12.0, - bottom: 12.0, + right: 12, + left: 12, + bottom: 12, ), child: _buildDmCheckbox(), ), @@ -351,87 +397,79 @@ class MessageInputState extends State { return child; } - Flex _buildTextField(BuildContext context) { - return Flex( - direction: Axis.horizontal, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - if (!_commandEnabled && widget.actionsLocation == ActionsLocation.left) - _buildExpandActionsButton(), - _buildTextInput(context), - if (!_commandEnabled && widget.actionsLocation == ActionsLocation.right) - _buildExpandActionsButton(), - if (widget.sendButtonLocation == SendButtonLocation.outside) - _animateSendButton(context), - ], - ); - } + Flex _buildTextField(BuildContext context) => Flex( + direction: Axis.horizontal, + children: [ + if (!_commandEnabled && + widget.actionsLocation == ActionsLocation.left) + _buildExpandActionsButton(), + _buildTextInput(context), + if (!_commandEnabled && + widget.actionsLocation == ActionsLocation.right) + _buildExpandActionsButton(), + if (widget.sendButtonLocation == SendButtonLocation.outside) + _animateSendButton(context), + ], + ); - Widget _buildDmCheckbox() { - return Row( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Container( - height: 16, - width: 16, - foregroundDecoration: BoxDecoration( - border: _sendAsDm - ? null - : Border.all( - color: StreamChatTheme.of(context) - .colorTheme - .black - .withOpacity(.5), - width: 2, - ), - borderRadius: BorderRadius.circular(3), - ), - child: Center( - child: Material( + Widget _buildDmCheckbox() => Row( + children: [ + Container( + height: 16, + width: 16, + foregroundDecoration: BoxDecoration( + border: _sendAsDm + ? null + : Border.all( + color: _streamChatTheme.colorTheme.textHighEmphasis + .withOpacity(.5), + width: 2, + ), borderRadius: BorderRadius.circular(3), - color: _sendAsDm - ? StreamChatTheme.of(context).colorTheme.accentBlue - : StreamChatTheme.of(context).colorTheme.white, - child: InkWell( - onTap: () { - setState(() { - _sendAsDm = !_sendAsDm; - }); - }, - child: AnimatedCrossFade( - duration: Duration(milliseconds: 300), - reverseDuration: Duration(milliseconds: 300), - crossFadeState: _sendAsDm - ? CrossFadeState.showFirst - : CrossFadeState.showSecond, - firstChild: StreamSvgIcon.check( - size: 16.0, - color: StreamChatTheme.of(context).colorTheme.white, - ), - secondChild: SizedBox( - height: 16, - width: 16, + ), + child: Center( + child: Material( + borderRadius: BorderRadius.circular(3), + color: _sendAsDm + ? _streamChatTheme.colorTheme.accentPrimary + : _streamChatTheme.colorTheme.barsBg, + child: InkWell( + onTap: () { + setState(() { + _sendAsDm = !_sendAsDm; + }); + }, + child: AnimatedCrossFade( + duration: const Duration(milliseconds: 300), + reverseDuration: const Duration(milliseconds: 300), + crossFadeState: _sendAsDm + ? CrossFadeState.showFirst + : CrossFadeState.showSecond, + firstChild: StreamSvgIcon.check( + size: 16, + color: _streamChatTheme.colorTheme.barsBg, + ), + secondChild: const SizedBox( + height: 16, + width: 16, + ), ), ), ), ), ), - ), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 12.0), - child: Text( - 'Also send as direct message', - style: StreamChatTheme.of(context).textTheme.footnote.copyWith( - color: StreamChatTheme.of(context) - .colorTheme - .black - .withOpacity(0.5), - ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 12), + child: Text( + 'Also send as direct message', + style: _streamChatTheme.textTheme.footnote.copyWith( + color: _streamChatTheme.colorTheme.textHighEmphasis + .withOpacity(0.5), + ), + ), ), - ), - ], - ); - } + ], + ); Widget _animateSendButton(BuildContext context) { final sendButton = widget.activeSendButton != null @@ -446,118 +484,113 @@ class MessageInputState extends State { : CrossFadeState.showSecond, firstChild: sendButton, secondChild: widget.idleSendButton ?? _buildIdleSendButton(context), - duration: - StreamChatTheme.of(context).messageInputTheme.sendAnimationDuration, + duration: _streamChatTheme.messageInputTheme.sendAnimationDuration!, alignment: Alignment.center, ); } Widget _buildExpandActionsButton() { + final channel = StreamChannel.of(context).channel; return Padding( - padding: const EdgeInsets.symmetric(horizontal: 8.0), + padding: const EdgeInsets.symmetric(horizontal: 8), child: AnimatedCrossFade( crossFadeState: _actionsShrunk ? CrossFadeState.showFirst : CrossFadeState.showSecond, firstChild: IconButton( - onPressed: () => setState(() => _actionsShrunk = false), + onPressed: () { + if (_actionsShrunk) { + setState(() => _actionsShrunk = false); + } + }, icon: Transform.rotate( - alignment: Alignment.center, angle: (widget.actionsLocation == ActionsLocation.right || widget.actionsLocation == ActionsLocation.rightInside) ? pi : 0, child: StreamSvgIcon.emptyCircleLeft( - color: StreamChatTheme.of(context) - .messageInputTheme - .expandButtonColor, + color: _streamChatTheme.messageInputTheme.expandButtonColor, ), ), padding: const EdgeInsets.all(0), - constraints: BoxConstraints.tightFor( + constraints: const BoxConstraints.tightFor( height: 24, width: 24, ), splashRadius: 24, ), - secondChild: FittedBox( - child: Row( - crossAxisAlignment: CrossAxisAlignment.center, - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: [ - if (!widget.disableAttachments) _buildAttachmentButton(), - if (widget.showCommandsButton && - widget.editMessage == null && - StreamChannel.of(context) - .channel - ?.config - ?.commands - ?.isNotEmpty == - true) - _buildCommandButton(), - ...widget.actions ?? [], - ].insertBetween(const SizedBox(width: 8)), - ), - ), - duration: Duration(milliseconds: 300), + secondChild: widget.disableAttachments && + !widget.showCommandsButton && + widget.actions?.isNotEmpty != true + ? const Offstage() + : FittedBox( + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + if (!widget.disableAttachments) _buildAttachmentButton(), + if (widget.showCommandsButton && + widget.editMessage == null && + channel.state != null && + channel.config?.commands.isNotEmpty == true) + _buildCommandButton(), + ...widget.actions ?? [], + ].insertBetween(const SizedBox(width: 8)), + ), + ), + duration: const Duration(milliseconds: 300), alignment: Alignment.center, ), ); } Expanded _buildTextInput(BuildContext context) { - final theme = StreamChatTheme.of(context); final margin = (widget.sendButtonLocation == SendButtonLocation.inside - ? const EdgeInsets.only(right: 8.0) + ? const EdgeInsets.only(right: 8) : EdgeInsets.zero) + (widget.actionsLocation != ActionsLocation.left - ? const EdgeInsets.only(left: 8.0) + ? const EdgeInsets.only(left: 8) : EdgeInsets.zero); return Expanded( - child: Center( - child: Container( - clipBehavior: Clip.antiAlias, - margin: margin, - decoration: BoxDecoration( - borderRadius: theme.messageInputTheme.borderRadius, - gradient: _focusNode.hasFocus - ? theme.messageInputTheme.activeBorderGradient - : theme.messageInputTheme.idleBorderGradient, - ), - child: Padding( - padding: const EdgeInsets.all(1.5), - child: Container( - clipBehavior: Clip.antiAlias, - decoration: BoxDecoration( - borderRadius: theme.messageInputTheme.borderRadius, - color: theme.messageInputTheme.inputBackground, - ), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _buildReplyToMessage(), - _buildAttachments(), - LimitedBox( - maxHeight: widget.maxHeight, - child: TextField( - key: Key('messageInputText'), - enabled: _inputEnabled, - minLines: null, - maxLines: null, - onSubmitted: (_) => sendMessage(), - keyboardType: widget.keyboardType, - controller: textEditingController, - focusNode: _focusNode, - style: theme.messageInputTheme.inputTextStyle, - autofocus: widget.autofocus, - textAlignVertical: TextAlignVertical.center, - decoration: _getInputDecoration(), - textCapitalization: TextCapitalization.sentences, - ), - ) - ], - ), + child: Container( + clipBehavior: Clip.hardEdge, + margin: margin, + decoration: BoxDecoration( + borderRadius: _streamChatTheme.messageInputTheme.borderRadius, + gradient: _focusNode.hasFocus + ? _streamChatTheme.messageInputTheme.activeBorderGradient + : _streamChatTheme.messageInputTheme.idleBorderGradient, + ), + child: Padding( + padding: const EdgeInsets.all(1.5), + child: DecoratedBox( + decoration: BoxDecoration( + borderRadius: _streamChatTheme.messageInputTheme.borderRadius, + color: _streamChatTheme.messageInputTheme.inputBackground, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildReplyToMessage(), + _buildAttachments(), + LimitedBox( + maxHeight: widget.maxHeight, + child: TextField( + key: const Key('messageInputText'), + enabled: _inputEnabled, + maxLines: null, + onSubmitted: (_) => sendMessage(), + keyboardType: widget.keyboardType, + controller: textEditingController, + focusNode: _focusNode, + style: _streamChatTheme.messageInputTheme.inputTextStyle, + autofocus: widget.autofocus, + textAlignVertical: TextAlignVertical.center, + decoration: _getInputDecoration(), + textCapitalization: TextCapitalization.sentences, + ), + ) + ], ), ), ), @@ -566,35 +599,34 @@ class MessageInputState extends State { } InputDecoration _getInputDecoration() { - final theme = StreamChatTheme.of(context); - final passedDecoration = theme.messageInputTheme.inputDecoration; + final passedDecoration = _streamChatTheme.messageInputTheme.inputDecoration; return InputDecoration( isDense: true, hintText: _getHint(), - hintStyle: theme.messageInputTheme.inputTextStyle.copyWith( - color: theme.colorTheme.grey, + hintStyle: _streamChatTheme.messageInputTheme.inputTextStyle!.copyWith( + color: _streamChatTheme.colorTheme.textLowEmphasis, ), - border: OutlineInputBorder( + border: const OutlineInputBorder( borderSide: BorderSide( color: Colors.transparent, ), ), - focusedBorder: OutlineInputBorder( + focusedBorder: const OutlineInputBorder( borderSide: BorderSide( color: Colors.transparent, ), ), - enabledBorder: OutlineInputBorder( + enabledBorder: const OutlineInputBorder( borderSide: BorderSide( color: Colors.transparent, ), ), - errorBorder: OutlineInputBorder( + errorBorder: const OutlineInputBorder( borderSide: BorderSide( color: Colors.transparent, ), ), - disabledBorder: OutlineInputBorder( + disabledBorder: const OutlineInputBorder( borderSide: BorderSide( color: Colors.transparent, ), @@ -605,12 +637,12 @@ class MessageInputState extends State { mainAxisSize: MainAxisSize.min, children: [ Padding( - padding: const EdgeInsets.all(8.0), + padding: const EdgeInsets.all(8), child: Container( - constraints: BoxConstraints.tight(Size(64, 24)), + constraints: BoxConstraints.tight(const Size(64, 24)), decoration: BoxDecoration( borderRadius: BorderRadius.circular(12), - color: theme.colorTheme.accentBlue, + color: _streamChatTheme.colorTheme.accentPrimary, ), alignment: Alignment.center, child: Row( @@ -618,16 +650,14 @@ class MessageInputState extends State { children: [ StreamSvgIcon.lightning( color: Colors.white, - size: 16.0, + size: 16, ), Text( - _chosenCommand?.name?.toUpperCase() ?? '', - style: StreamChatTheme.of(context) - .textTheme - .footnoteBold - .copyWith( - color: Colors.white, - ), + _chosenCommand?.name.toUpperCase() ?? '', + style: + _streamChatTheme.textTheme.footnoteBold.copyWith( + color: Colors.white, + ), ), ], ), @@ -638,26 +668,24 @@ class MessageInputState extends State { : (widget.actionsLocation == ActionsLocation.leftInside ? Row( mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.center, children: [ _buildExpandActionsButton(), ], ) : null), - suffixIconConstraints: BoxConstraints.tightFor(height: 40), - prefixIconConstraints: BoxConstraints.tightFor(height: 40), + suffixIconConstraints: const BoxConstraints.tightFor(height: 40), + prefixIconConstraints: const BoxConstraints.tightFor(height: 40), suffixIcon: Row( mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.center, children: [ if (_commandEnabled) Padding( - padding: const EdgeInsets.only(right: 8.0), + padding: const EdgeInsets.only(right: 8), child: IconButton( icon: StreamSvgIcon.closeSmall(), splashRadius: 24, padding: const EdgeInsets.all(0), - constraints: BoxConstraints.tightFor( + constraints: const BoxConstraints.tightFor( height: 24, width: 24, ), @@ -676,9 +704,9 @@ class MessageInputState extends State { ).merge(passedDecoration); } - Timer _debounce; + Timer? _debounce; - String _previousValue; + String? _previousValue; void _onChanged(BuildContext context, String s) { if (s == _previousValue) { @@ -686,14 +714,17 @@ class MessageInputState extends State { } _previousValue = s; - if (_debounce?.isActive == true) _debounce.cancel(); + if (_debounce?.isActive == true) _debounce!.cancel(); _debounce = Timer( const Duration(milliseconds: 350), () { if (!mounted) { return; } - StreamChannel.of(context).channel.keyStroke().catchError((e) {}); + StreamChannel.of(context) + .channel + .keyStroke(widget.parentMessage?.id) + .catchError((e) {}); setState(() { _messageIsPresent = s.trim().isNotEmpty; @@ -721,7 +752,7 @@ class MessageInputState extends State { } String _getHint() { - if (_commandEnabled && _chosenCommand.name == 'giphy') { + if (_commandEnabled && _chosenCommand!.name == 'giphy') { return 'Search GIFs'; } if (_attachments.isNotEmpty) { @@ -742,7 +773,7 @@ class MessageInputState extends State { final textToSelection = textEditingController.text .substring(0, textEditingController.value.selection.start); final splits = textToSelection.split(':'); - final query = splits[splits.length - 2]?.toLowerCase(); + final query = splits[splits.length - 2].toLowerCase(); final emoji = Emoji.byName(query); if (textToSelection.endsWith(':') && emoji != null) { @@ -751,7 +782,7 @@ class MessageInputState extends State { _emojiOverlay = _buildEmojiOverlay(); if (_emojiOverlay != null) { - Overlay.of(context).insert(_emojiOverlay); + Overlay.of(context)!.insert(_emojiOverlay!); } } } @@ -767,19 +798,19 @@ class MessageInputState extends State { .contains('@')) { _mentionsOverlay = _buildMentionsOverlayEntry(); if (_mentionsOverlay != null) { - Overlay.of(context).insert(_mentionsOverlay); + Overlay.of(context)!.insert(_mentionsOverlay!); } } } void _checkCommands(String s, BuildContext context) { if (s.startsWith('/')) { - var matchedCommandsList = StreamChannel.of(context) + final matchedCommandsList = StreamChannel.of(context) .channel .config ?.commands - ?.where((element) => element.name == s.substring(1)) - ?.toList() ?? + .where((element) => element.name == s.substring(1)) + .toList() ?? []; if (matchedCommandsList.length == 1) { @@ -789,210 +820,193 @@ class MessageInputState extends State { setState(() { _commandEnabled = true; }); - _commandsOverlay.remove(); + _commandsOverlay?.remove(); _commandsOverlay = null; } else { _commandsOverlay = _buildCommandsOverlayEntry(); if (_commandsOverlay != null) { - Overlay.of(context).insert(_commandsOverlay); + Overlay.of(context)!.insert(_commandsOverlay!); } } } } - OverlayEntry _buildCommandsOverlayEntry() { + OverlayEntry? _buildCommandsOverlayEntry() { final text = textEditingController.text.trimLeft(); final commands = StreamChannel.of(context) .channel .config ?.commands - ?.where((c) => c.name.contains(text.replaceFirst('/', ''))) - ?.toList() ?? + .where((c) => c.name.contains(text.replaceFirst('/', ''))) + .toList() ?? []; if (commands.isEmpty) { return null; } - RenderBox renderBox = context.findRenderObject(); + // ignore: cast_nullable_to_non_nullable + final renderBox = context.findRenderObject() as RenderBox; final size = renderBox.size; - return OverlayEntry(builder: (context) { - return Positioned( - bottom: size.height + MediaQuery.of(context).viewInsets.bottom, - left: 0, - right: 0, - child: TweenAnimationBuilder( - tween: Tween(begin: 0.0, end: 1.0), - duration: Duration(milliseconds: 300), - curve: Curves.easeInOutExpo, - builder: (context, val, wid) { - return Transform.scale( - alignment: Alignment.center, - scale: val, - child: Padding( - padding: const EdgeInsets.all(8.0), - child: Card( - elevation: 2.0, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8.0), - ), - color: StreamChatTheme.of(context).colorTheme.white, - clipBehavior: Clip.antiAlias, - child: Container( - constraints: BoxConstraints.loose(Size.fromHeight(400)), - decoration: BoxDecoration( - color: StreamChatTheme.of(context).colorTheme.white, - borderRadius: BorderRadius.circular(8.0)), - child: ListView( - padding: const EdgeInsets.all(0), - shrinkWrap: true, - children: [ - if (commands.isNotEmpty) - Padding( - padding: - const EdgeInsets.only(left: 0.0, top: 8.0), - child: Row( + final child = Padding( + padding: const EdgeInsets.all(8), + child: Card( + elevation: 2, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + color: _streamChatTheme.colorTheme.barsBg, + clipBehavior: Clip.hardEdge, + child: Container( + constraints: BoxConstraints.loose(const Size.fromHeight(400)), + decoration: BoxDecoration( + color: _streamChatTheme.colorTheme.barsBg, + borderRadius: BorderRadius.circular(8)), + child: ListView( + padding: const EdgeInsets.all(0), + shrinkWrap: true, + children: [ + if (commands.isNotEmpty) + Padding( + padding: const EdgeInsets.only(top: 8), + child: Row( + children: [ + Padding( + padding: const EdgeInsets.symmetric( + horizontal: 8, + ), + child: StreamSvgIcon.lightning( + color: _streamChatTheme.colorTheme.accentPrimary, + ), + ), + Text( + 'Instant Commands', + style: TextStyle( + color: _streamChatTheme.colorTheme.textHighEmphasis + .withOpacity(.5), + ), + ) + ], + ), + ), + const SizedBox( + height: 10, + ), + ...commands + .map( + (c) => InkWell( + onTap: () { + _setCommand(c); + }, + child: SizedBox( + height: 40, + child: Row( + children: [ + const SizedBox( + width: 16, + ), + _buildCommandIcon(c.name), + const SizedBox( + width: 8, + ), + Text.rich( + TextSpan( + text: c.name.capitalize(), + style: const TextStyle( + fontWeight: FontWeight.bold), children: [ - Padding( - padding: const EdgeInsets.symmetric( - horizontal: 8.0, - ), - child: StreamSvgIcon.lightning( - color: StreamChatTheme.of(context) + TextSpan( + text: ' /${c.name} ${c.args}', + style: _streamChatTheme.textTheme.body + .copyWith( + // ignore: lines_longer_than_80_chars + color: _streamChatTheme + // ignore: lines_longer_than_80_chars .colorTheme - .accentBlue, + .textLowEmphasis, ), ), - Text( - 'Instant Commands', - style: TextStyle( - color: StreamChatTheme.of(context) - .colorTheme - .black - .withOpacity(.5), - ), - ) ], ), ), - SizedBox( - height: 10.0, - ), - ...commands - .map( - (c) => InkWell( - onTap: () { - _setCommand(c); - }, - child: Container( - height: 40.0, - child: Row( - children: [ - SizedBox( - width: 16.0, - ), - _buildCommandIcon(c.name), - SizedBox( - width: 8.0, - ), - Text.rich( - TextSpan( - text: '${c.name.capitalize()}', - style: TextStyle( - fontWeight: FontWeight.bold), - children: [ - TextSpan( - text: ' /${c.name} ${c.args}', - style: - StreamChatTheme.of(context) - .textTheme - .body - .copyWith( - color: StreamChatTheme - .of(context) - .colorTheme - .grey, - ), - ), - ], - ), - ), - ], - ), - ), - ), - ) - .toList(), - ], + ], + ), ), ), - ), + ) + .toList(), + ], + ), + ), + ), + ); + return OverlayEntry( + builder: (context) => Positioned( + bottom: size.height + MediaQuery.of(context).viewInsets.bottom, + left: 0, + right: 0, + child: TweenAnimationBuilder( + tween: Tween(begin: 0, end: 1), + duration: const Duration(milliseconds: 300), + curve: Curves.easeInOutExpo, + builder: (context, val, child) => Transform.scale( + scale: val, + child: child, ), - ); - }), - ); - }); + child: child, + ), + )); } Widget _buildFilePickerSection() { - final _attachmentContainsFile = _attachments.values.any((it) { - return it.type == 'file'; - }); + final _attachmentContainsFile = + _attachments.values.any((it) => it.type == 'file'); Color _getIconColor(int index) { + final streamChatThemeData = _streamChatTheme; switch (index) { case 0: return _attachments.isEmpty - ? StreamChatTheme.of(context).colorTheme.accentBlue + ? streamChatThemeData.colorTheme.accentPrimary : (!_attachmentContainsFile - ? StreamChatTheme.of(context).colorTheme.accentBlue - : StreamChatTheme.of(context) - .colorTheme - .black + ? streamChatThemeData.colorTheme.accentPrimary + : streamChatThemeData.colorTheme.textHighEmphasis .withOpacity(0.2)); - break; case 1: return _attachmentContainsFile - ? StreamChatTheme.of(context).colorTheme.accentBlue + ? streamChatThemeData.colorTheme.accentPrimary : (_attachments.isEmpty - ? StreamChatTheme.of(context) - .colorTheme - .black + ? streamChatThemeData.colorTheme.textHighEmphasis .withOpacity(0.5) - : StreamChatTheme.of(context) - .colorTheme - .black + : streamChatThemeData.colorTheme.textHighEmphasis .withOpacity(0.2)); - break; case 2: return _attachmentContainsFile && _attachments.isNotEmpty - ? StreamChatTheme.of(context).colorTheme.black.withOpacity(0.2) - : StreamChatTheme.of(context).colorTheme.black.withOpacity(0.5); - break; + ? streamChatThemeData.colorTheme.textHighEmphasis.withOpacity(0.2) + : streamChatThemeData.colorTheme.textHighEmphasis + .withOpacity(0.5); case 3: return _attachmentContainsFile && _attachments.isNotEmpty - ? StreamChatTheme.of(context).colorTheme.black.withOpacity(0.2) - : StreamChatTheme.of(context).colorTheme.black.withOpacity(0.5); - break; + ? streamChatThemeData.colorTheme.textHighEmphasis.withOpacity(0.2) + : streamChatThemeData.colorTheme.textHighEmphasis + .withOpacity(0.5); default: return Colors.black; } } return AnimatedContainer( - duration: _animateContainer ? Duration(milliseconds: 300) : Duration.zero, + duration: const Duration(milliseconds: 300), height: _openFilePickerSection ? _filePickerSize : 0, child: Material( - color: StreamChatTheme.of(context).colorTheme.whiteSmoke, + color: _streamChatTheme.colorTheme.inputBg, child: Column( mainAxisSize: MainAxisSize.min, children: [ Row( - mainAxisAlignment: MainAxisAlignment.start, children: [ IconButton( - iconSize: 24, icon: StreamSvgIcon.pictures( color: _getIconColor(0), ), @@ -1012,11 +1026,10 @@ class MessageInputState extends State { onPressed: !_attachmentContainsFile && _attachments.isNotEmpty ? null : () { - pickFile(DefaultAttachmentTypes.file, false); + pickFile(DefaultAttachmentTypes.file); }, ), IconButton( - iconSize: 24, icon: StreamSvgIcon.camera( color: _getIconColor(2), ), @@ -1028,7 +1041,6 @@ class MessageInputState extends State { ), IconButton( padding: const EdgeInsets.all(0), - iconSize: 24, icon: StreamSvgIcon.record( color: _getIconColor(3), ), @@ -1043,33 +1055,33 @@ class MessageInputState extends State { GestureDetector( onVerticalDragUpdate: (update) { setState(() { - _animateContainer = false; _filePickerSize = (_filePickerSize - update.delta.dy).clamp( _kMinMediaPickerSize, MediaQuery.of(context).size.height / 1.7, ); }); }, - child: Container( + child: DecoratedBox( decoration: BoxDecoration( - color: StreamChatTheme.of(context).colorTheme.white, - borderRadius: BorderRadius.only( - topLeft: Radius.circular(16.0), - topRight: Radius.circular(16.0), + color: _streamChatTheme.colorTheme.barsBg, + borderRadius: const BorderRadius.only( + topLeft: Radius.circular(16), + topRight: Radius.circular(16), ), ), - child: Container( + child: SizedBox( width: double.infinity, child: Center( child: Padding( - padding: const EdgeInsets.all(8.0), - child: Container( - width: 40.0, - height: 4.0, - decoration: BoxDecoration( - color: - StreamChatTheme.of(context).colorTheme.whiteSmoke, - borderRadius: BorderRadius.circular(4.0), + padding: const EdgeInsets.all(8), + child: SizedBox( + width: 40, + height: 4, + child: DecoratedBox( + decoration: BoxDecoration( + color: _streamChatTheme.colorTheme.inputBg, + borderRadius: BorderRadius.circular(4), + ), ), ), ), @@ -1079,13 +1091,14 @@ class MessageInputState extends State { ), if (_openFilePickerSection) Expanded( - child: Container( + child: DecoratedBox( decoration: BoxDecoration( - color: StreamChatTheme.of(context).colorTheme.white, - borderRadius: BorderRadius.circular(8.0), + color: _streamChatTheme.colorTheme.barsBg, + borderRadius: BorderRadius.circular(8), ), child: _PickerWidget( filePickerIndex: _filePickerIndex, + streamChatTheme: _streamChatTheme, containsFile: _attachmentContainsFile, selectedMedias: _attachments.keys.toList(), onAddMoreFilesClick: pickFile, @@ -1107,22 +1120,31 @@ class MessageInputState extends State { void _addAttachment(AssetEntity medium) async { final mediaFile = await medium.originFile.timeout( - Duration(seconds: 5), + const Duration(seconds: 5), onTimeout: () => medium.originFile, ); + if (mediaFile == null) { + return; + } + var file = AttachmentFile( path: mediaFile.path, size: await mediaFile.length(), bytes: mediaFile.readAsBytesSync(), ); - if (file.size > _kMaxAttachmentSize) { - if (medium?.type == AssetType.video) { - final mediaInfo = await VideoService.compressVideo(file.path); + if (file.size! > widget.maxAttachmentSize) { + if (medium.type == AssetType.video && file.path != null) { + final mediaInfo = await (VideoService.compressVideo( + file.path!, + frameRate: widget.compressedVideoFrameRate, + quality: widget.compressedVideoQuality, + ) as FutureOr); - if (mediaInfo.filesize > _kMaxAttachmentSize) { + if (mediaInfo.filesize! > widget.maxAttachmentSize) { _showErrorAlert( + // ignore: lines_longer_than_80_chars 'The file is too large to upload. The file size limit is 20MB. We tried compressing it, but it was not enough.', ); return; @@ -1130,7 +1152,7 @@ class MessageInputState extends State { file = AttachmentFile( name: file.name, size: mediaInfo.filesize, - bytes: await mediaInfo.file.readAsBytes(), + bytes: await mediaInfo.file?.readAsBytes(), path: mediaInfo.path, ); } else { @@ -1156,201 +1178,187 @@ class MessageInputState extends State { return CircleAvatar( radius: 12, child: StreamSvgIcon.giphyIcon( - size: 24.0, + size: 24, ), ); - break; case 'ban': return CircleAvatar( - backgroundColor: StreamChatTheme.of(context).colorTheme.accentBlue, + backgroundColor: _streamChatTheme.colorTheme.accentPrimary, radius: 12, child: StreamSvgIcon.iconUserDelete( - size: 16.0, + size: 16, color: Colors.white, ), ); - break; case 'flag': return CircleAvatar( - backgroundColor: StreamChatTheme.of(context).colorTheme.accentBlue, + backgroundColor: _streamChatTheme.colorTheme.accentPrimary, radius: 12, child: StreamSvgIcon.flag( - size: 14.0, + size: 14, color: Colors.white, ), ); - break; case 'imgur': return CircleAvatar( - backgroundColor: StreamChatTheme.of(context).colorTheme.accentBlue, + backgroundColor: _streamChatTheme.colorTheme.accentPrimary, radius: 12, child: ClipOval( child: StreamSvgIcon.imgur( - size: 24.0, + size: 24, ), ), ); - break; case 'mute': return CircleAvatar( - backgroundColor: StreamChatTheme.of(context).colorTheme.accentBlue, + backgroundColor: _streamChatTheme.colorTheme.accentPrimary, radius: 12, child: StreamSvgIcon.mute( - size: 16.0, + size: 16, color: Colors.white, ), ); - break; case 'unban': return CircleAvatar( - backgroundColor: StreamChatTheme.of(context).colorTheme.accentBlue, + backgroundColor: _streamChatTheme.colorTheme.accentPrimary, radius: 12, child: StreamSvgIcon.userAdd( - size: 16.0, + size: 16, color: Colors.white, ), ); - break; case 'unmute': return CircleAvatar( - backgroundColor: StreamChatTheme.of(context).colorTheme.accentBlue, + backgroundColor: _streamChatTheme.colorTheme.accentPrimary, radius: 12, child: StreamSvgIcon.volumeUp( - size: 16.0, + size: 16, color: Colors.white, ), ); - break; default: return CircleAvatar( - backgroundColor: StreamChatTheme.of(context).colorTheme.accentBlue, + backgroundColor: _streamChatTheme.colorTheme.accentPrimary, radius: 12, child: StreamSvgIcon.lightning( - size: 16.0, + size: 16, color: Colors.white, ), ); - break; } } - OverlayEntry _buildMentionsOverlayEntry() { + OverlayEntry? _buildMentionsOverlayEntry() { final splits = textEditingController.text .substring(0, textEditingController.value.selection.start) .split('@'); final query = splits.last.toLowerCase(); - Future> queryMembers; + Future>? queryMembers; + final channelState = StreamChannel.of(context); if (query.isNotEmpty) { - queryMembers = StreamChannel.of(context).channel.queryMembers(filter: { - 'name': { - '\$autocomplete': query, - }, - }).then((res) => res.members); + queryMembers = channelState.channel + .queryMembers(filter: Filter.autoComplete('name', query)) + .then((res) => res.members); } - final members = StreamChannel.of(context).channel.state.members?.where((m) { - return m.user.name.toLowerCase().contains(query); - })?.toList() ?? + final members = channelState.channel.state?.members + .where((m) => m.user?.name.toLowerCase().contains(query) == true) + .toList() ?? []; if (members.isEmpty) { return null; } - RenderBox renderBox = context.findRenderObject(); + // ignore: cast_nullable_to_non_nullable + final renderBox = context.findRenderObject() as RenderBox; final size = renderBox.size; + final child = Card( + margin: const EdgeInsets.all(8), + elevation: 2, + color: _streamChatTheme.colorTheme.barsBg, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + clipBehavior: Clip.hardEdge, + child: Container( + constraints: BoxConstraints.loose(const Size.fromHeight(240)), + decoration: BoxDecoration( + color: _streamChatTheme.colorTheme.barsBg, + ), + child: FutureBuilder>( + future: queryMembers ?? Future.value(members), + initialData: members, + builder: (context, snapshot) => ListView( + padding: const EdgeInsets.all(0), + shrinkWrap: true, + children: [ + const SizedBox( + height: 8, + ), + ...snapshot.data! + .where((it) => it.user != null) + .map( + (m) => Material( + color: _streamChatTheme.colorTheme.barsBg, + child: InkWell( + onTap: () { + if (m.user != null) { + _mentionedUsers.add(m.user!); + } - return OverlayEntry( - builder: (context) { - return Positioned( - bottom: size.height + MediaQuery.of(context).viewInsets.bottom, - left: 0, - right: 0, - child: TweenAnimationBuilder( - tween: Tween(begin: 0.0, end: 1.0), - duration: Duration(milliseconds: 300), - curve: Curves.easeInOutExpo, - builder: (context, val, wid) { - return Transform.scale( - scale: val, - child: Card( - margin: EdgeInsets.all(8.0), - elevation: 2.0, - color: StreamChatTheme.of(context).colorTheme.white, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8.0), - ), - clipBehavior: Clip.antiAlias, - child: Container( - constraints: BoxConstraints.loose(Size.fromHeight(240)), - decoration: BoxDecoration( - color: StreamChatTheme.of(context).colorTheme.white, - ), - child: FutureBuilder>( - future: queryMembers ?? Future.value(members), - initialData: members, - builder: (context, snapshot) { - return ListView( - padding: const EdgeInsets.all(0), - shrinkWrap: true, - children: [ - SizedBox( - height: 8.0, + splits[splits.length - 1] = m.user!.name; + final rejoin = splits.join('@'); + + textEditingController.value = TextEditingValue( + text: rejoin + + textEditingController.text.substring( + textEditingController.selection.start), + selection: TextSelection.collapsed( + offset: rejoin.length, ), - ...snapshot.data.map( - (m) { - return Material( - color: StreamChatTheme.of(context) - .colorTheme - .white, - child: InkWell( - onTap: () { - _mentionedUsers.add(m.user); - - splits[splits.length - 1] = m.user.name; - final rejoin = splits.join('@'); - - textEditingController.value = - TextEditingValue( - text: rejoin + - textEditingController.text - .substring(textEditingController - .selection.start), - selection: TextSelection.collapsed( - offset: rejoin.length, - ), - ); - _debounce.cancel(); - _mentionsOverlay?.remove(); - _mentionsOverlay = null; - }, - child: widget.mentionsTileBuilder != null - ? widget.mentionsTileBuilder(context, m) - : MentionTile(m), - ), - ); - }, - ).toList(), - SizedBox( - height: 8.0, - ), - ], - ); - }, + ); + _debounce!.cancel(); + _mentionsOverlay?.remove(); + _mentionsOverlay = null; + }, + child: widget.mentionsTileBuilder != null + ? widget.mentionsTileBuilder!(context, m) + : MentionTile(m), + ), ), - ), - ), - ); - }, + ) + .toList(), + const SizedBox( + height: 8, + ), + ], ), - ); - }, + ), + ), + ); + return OverlayEntry( + builder: (context) => Positioned( + bottom: size.height + MediaQuery.of(context).viewInsets.bottom, + left: 0, + right: 0, + child: TweenAnimationBuilder( + tween: Tween(begin: 0, end: 1), + duration: const Duration(milliseconds: 300), + curve: Curves.easeInOutExpo, + builder: (context, val, child) => Transform.scale( + scale: val, + child: child, + ), + child: child, + ), + ), ); } - OverlayEntry _buildEmojiOverlay() { + OverlayEntry? _buildEmojiOverlay() { final splits = textEditingController.text .substring(0, textEditingController.value.selection.start) .split(':'); @@ -1362,103 +1370,104 @@ class MessageInputState extends State { final emojis = _emojiNames .where((e) => e.contains(query)) - .map((e) => Emoji.byName(e)) + .map(Emoji.byName) .where((e) => e != null); if (emojis.isEmpty) { return null; } - RenderBox renderBox = context.findRenderObject(); + // ignore: cast_nullable_to_non_nullable + final renderBox = context.findRenderObject() as RenderBox; final size = renderBox.size; - return OverlayEntry(builder: (context) { - return Positioned( - bottom: size.height + MediaQuery.of(context).viewInsets.bottom, - left: 0, - right: 0, - child: Card( - margin: EdgeInsets.all(8.0), - elevation: 2.0, - color: StreamChatTheme.of(context).colorTheme.white, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8.0), - ), - clipBehavior: Clip.antiAlias, - child: Container( - constraints: BoxConstraints.loose(Size.fromHeight(200)), - decoration: BoxDecoration( - boxShadow: [ - BoxShadow( - spreadRadius: -8, - blurRadius: 5.0, - offset: Offset(0, -4), + return OverlayEntry( + builder: (context) => Positioned( + bottom: size.height + MediaQuery.of(context).viewInsets.bottom, + left: 0, + right: 0, + child: Card( + margin: const EdgeInsets.all(8), + elevation: 2, + color: _streamChatTheme.colorTheme.barsBg, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), ), - ], - color: StreamChatTheme.of(context).colorTheme.white, - ), - child: ListView.builder( - padding: const EdgeInsets.all(0), - shrinkWrap: true, - itemCount: emojis.length + 1, - itemBuilder: (context, i) { - if (i == 0) { - return Padding( - padding: const EdgeInsets.only(left: 8.0, top: 8.0), - child: Row( - children: [ - Padding( - padding: - const EdgeInsets.symmetric(horizontal: 8.0), - child: StreamSvgIcon.smile( - color: StreamChatTheme.of(context) - .colorTheme - .accentBlue, - ), - ), - Flexible( - child: Text( - 'Emoji matching "$query"', - style: TextStyle( - color: StreamChatTheme.of(context) - .colorTheme - .black - .withOpacity(.5), - ), - ), - ) - ], + clipBehavior: Clip.hardEdge, + child: Container( + constraints: BoxConstraints.loose(const Size.fromHeight(200)), + decoration: BoxDecoration( + boxShadow: const [ + BoxShadow( + spreadRadius: -8, + blurRadius: 5, + offset: Offset(0, -4), ), - ); - } + ], + color: _streamChatTheme.colorTheme.barsBg, + ), + child: ListView.builder( + padding: const EdgeInsets.all(0), + shrinkWrap: true, + itemCount: emojis.length + 1, + itemBuilder: (context, i) { + if (i == 0) { + return Padding( + padding: const EdgeInsets.only(left: 8, top: 8), + child: Row( + children: [ + Padding( + padding: + const EdgeInsets.symmetric(horizontal: 8), + child: StreamSvgIcon.smile( + color: _streamChatTheme + .colorTheme.accentPrimary, + ), + ), + Flexible( + child: Text( + 'Emoji matching "$query"', + style: TextStyle( + color: _streamChatTheme + .colorTheme.textHighEmphasis + .withOpacity(.5), + ), + ), + ) + ], + ), + ); + } - final emoji = emojis.elementAt(i - 1); - return ListTile( - title: SubstringHighlight( - text: "${emoji.char} ${emoji.name.replaceAll('_', ' ')}", - term: query, - textStyleHighlight: - Theme.of(context).textTheme.headline6.copyWith( - fontSize: 14.5, - fontWeight: FontWeight.bold, - ), - textStyle: Theme.of(context).textTheme.headline6.copyWith( - fontSize: 14.5, + final emoji = emojis.elementAt(i - 1)!; + final themeData = Theme.of(context); + return ListTile( + title: SubstringHighlight( + text: + // ignore: lines_longer_than_80_chars + "${emoji.char} ${emoji.name!.replaceAll('_', ' ')}", + term: query, + textStyleHighlight: + themeData.textTheme.headline6!.copyWith( + fontSize: 14.5, + fontWeight: FontWeight.bold, + ), + textStyle: themeData.textTheme.headline6!.copyWith( + fontSize: 14.5, + ), ), - ), - onTap: () { - _chooseEmoji(splits, emoji); - }, - ); - }), - ), - ), - ); - }); + onTap: () { + _chooseEmoji(splits, emoji); + }, + ); + }), + ), + ), + )); } void _chooseEmoji(List splits, Emoji emoji) { - final rejoin = splits.sublist(0, splits.length - 1).join(':') + emoji.char; + final rejoin = splits.sublist(0, splits.length - 1).join(':') + emoji.char!; textEditingController.value = TextEditingValue( text: rejoin + @@ -1485,25 +1494,21 @@ class MessageInputState extends State { } Widget _buildReplyToMessage() { - if (!_hasQuotedMessage) return Offstage(); - final containsUrl = widget.quotedMessage.attachments - ?.any((element) => element.ogScrapeUrl != null) == + if (!_hasQuotedMessage) return const Offstage(); + final containsUrl = widget.quotedMessage!.attachments + .any((element) => element.ogScrapeUrl != null) == true; - return Transform( - transform: Matrix4.rotationY(pi), - alignment: Alignment.center, - child: QuotedMessageWidget( - reverse: true, - showBorder: !containsUrl, - message: widget.quotedMessage, - messageTheme: StreamChatTheme.of(context).otherMessageTheme, - padding: const EdgeInsets.fromLTRB(8, 8, 8, 0), - ), + return QuotedMessageWidget( + reverse: true, + showBorder: !containsUrl, + message: widget.quotedMessage!, + messageTheme: _streamChatTheme.otherMessageTheme, + padding: const EdgeInsets.fromLTRB(8, 8, 8, 0), ); } Widget _buildAttachments() { - if (_attachments.isEmpty) return Offstage(); + if (_attachments.isEmpty) return const Offstage(); final fileAttachments = _attachments.values .where((it) => it.type == 'file') .toList(growable: false); @@ -1516,7 +1521,7 @@ class MessageInputState extends State { Padding( padding: const EdgeInsets.fromLTRB(8, 8, 8, 0), child: LimitedBox( - maxHeight: 136.0, + maxHeight: 136, child: ListView( reverse: true, shrinkWrap: true, @@ -1524,16 +1529,17 @@ class MessageInputState extends State { .map( (e) => ClipRRect( borderRadius: BorderRadius.circular(10), - clipBehavior: Clip.antiAlias, child: FileAttachment( - message: null, + message: Message( + status: MessageSendingStatus.sending, + ), // dummy message attachment: e, size: Size( MediaQuery.of(context).size.width * 0.65, - 56.0, + 56, ), trailing: Padding( - padding: const EdgeInsets.all(8.0), + padding: const EdgeInsets.all(8), child: _buildRemoveButton(e), ), ), @@ -1547,19 +1553,18 @@ class MessageInputState extends State { Padding( padding: const EdgeInsets.fromLTRB(8, 8, 8, 0), child: LimitedBox( - maxHeight: 104.0, + maxHeight: 104, child: ListView( scrollDirection: Axis.horizontal, children: remainingAttachments .map( (attachment) => ClipRRect( borderRadius: BorderRadius.circular(10), - clipBehavior: Clip.antiAlias, child: Stack( children: [ AspectRatio( - aspectRatio: 1.0, - child: Container( + aspectRatio: 1, + child: SizedBox( height: 104, width: 104, child: _buildAttachment(attachment), @@ -1582,39 +1587,35 @@ class MessageInputState extends State { ); } - Widget _buildRemoveButton(Attachment attachment) { - return Container( - height: 24, - width: 24, - child: RawMaterialButton( - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(16), - ), - elevation: 0, - highlightElevation: 0, - focusElevation: 0, - disabledElevation: 0, - hoverElevation: 0, - onPressed: () { - setState(() => _attachments.remove(attachment.id)); - }, - fillColor: StreamChatTheme.of(context).colorTheme.black.withOpacity(.5), - child: Center( - child: StreamSvgIcon.close( - size: 24, - color: StreamChatTheme.of(context).colorTheme.white, + Widget _buildRemoveButton(Attachment attachment) => SizedBox( + height: 24, + width: 24, + child: RawMaterialButton( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + elevation: 0, + highlightElevation: 0, + focusElevation: 0, + hoverElevation: 0, + onPressed: () { + setState(() => _attachments.remove(attachment.id)); + }, + fillColor: + _streamChatTheme.colorTheme.textHighEmphasis.withOpacity(.5), + child: Center( + child: StreamSvgIcon.close( + size: 24, + color: _streamChatTheme.colorTheme.barsBg, + ), ), ), - ), - ); - } + ); Widget _buildAttachment(Attachment attachment) { - if (attachment == null) return Offstage(); - if (widget.attachmentThumbnailBuilders?.containsKey(attachment.type) == true) { - return widget.attachmentThumbnailBuilders[attachment.type]( + return widget.attachmentThumbnailBuilders![attachment.type!]!( context, attachment, ); @@ -1625,36 +1626,29 @@ class MessageInputState extends State { case 'giphy': return attachment.file != null ? Image.memory( - attachment.file.bytes, + attachment.file!.bytes!, fit: BoxFit.cover, - errorBuilder: (context, _, __) { - return Image.asset( - 'images/placeholder.png', - package: 'stream_chat_flutter', - ); - }, + errorBuilder: (context, _, __) => Image.asset( + 'images/placeholder.png', + package: 'stream_chat_flutter', + ), ) : CachedNetworkImage( imageUrl: attachment.imageUrl ?? attachment.assetUrl ?? - attachment.thumbUrl, + attachment.thumbUrl!, fit: BoxFit.cover, - errorWidget: (_, obj, trace) { - return getFileTypeImage(attachment.extraData['other']); - }, - progressIndicatorBuilder: (context, _, progress) { - return Shimmer.fromColors( - baseColor: - StreamChatTheme.of(context).colorTheme.greyGainsboro, - highlightColor: - StreamChatTheme.of(context).colorTheme.whiteSmoke, - child: Image.asset( - 'images/placeholder.png', - fit: BoxFit.cover, - package: 'stream_chat_flutter', - ), - ); - }, + errorWidget: (_, obj, trace) => + getFileTypeImage(attachment.extraData['other'] as String?), + placeholder: (context, _) => Shimmer.fromColors( + baseColor: _streamChatTheme.colorTheme.disabled, + highlightColor: _streamChatTheme.colorTheme.inputBg, + child: Image.asset( + 'images/placeholder.png', + fit: BoxFit.cover, + package: 'stream_chat_flutter', + ), + ), ); case 'video': return Stack( @@ -1662,7 +1656,7 @@ class MessageInputState extends State { VideoThumbnailImage( height: 104, width: 104, - video: attachment.file?.path ?? attachment.assetUrl, + video: (attachment.file?.path ?? attachment.assetUrl)!, fit: BoxFit.cover, ), Positioned( @@ -1678,7 +1672,7 @@ class MessageInputState extends State { default: return Container( color: Colors.black26, - child: Icon(Icons.insert_drive_file), + child: const Icon(Icons.insert_drive_file), ); } } @@ -1689,17 +1683,13 @@ class MessageInputState extends State { return IconButton( icon: StreamSvgIcon.lightning( color: s.isNotEmpty - ? StreamChatTheme.of(context).colorTheme.greyGainsboro + ? _streamChatTheme.colorTheme.disabled : (_commandsOverlay != null - ? StreamChatTheme.of(context) - .messageInputTheme - .actionButtonColor - : StreamChatTheme.of(context) - .messageInputTheme - .actionButtonIdleColor), + ? _streamChatTheme.messageInputTheme.actionButtonColor + : _streamChatTheme.messageInputTheme.actionButtonIdleColor), ), padding: const EdgeInsets.all(0), - constraints: BoxConstraints.tightFor( + constraints: const BoxConstraints.tightFor( height: 24, width: 24, ), @@ -1707,18 +1697,17 @@ class MessageInputState extends State { onPressed: () async { if (_openFilePickerSection) { setState(() { - _animateContainer = false; _openFilePickerSection = false; _filePickerSize = _kMinMediaPickerSize; }); - await Future.delayed(Duration(milliseconds: 300)); + await Future.delayed(const Duration(milliseconds: 300)); } if (_commandsOverlay == null) { setState(() { _commandsOverlay = _buildCommandsOverlayEntry(); if (_commandsOverlay != null) { - Overlay.of(context).insert(_commandsOverlay); + Overlay.of(context)!.insert(_commandsOverlay!); } }); } else { @@ -1731,43 +1720,39 @@ class MessageInputState extends State { ); } - Widget _buildAttachmentButton() { - return IconButton( - icon: StreamSvgIcon.attach( - color: _openFilePickerSection - ? StreamChatTheme.of(context).messageInputTheme.actionButtonColor - : StreamChatTheme.of(context) - .messageInputTheme - .actionButtonIdleColor, - ), - padding: const EdgeInsets.all(0), - constraints: BoxConstraints.tightFor( - height: 24, - width: 24, - ), - splashRadius: 24, - onPressed: () async { - _emojiOverlay?.remove(); - _emojiOverlay = null; - _commandsOverlay?.remove(); - _commandsOverlay = null; - _mentionsOverlay?.remove(); - _mentionsOverlay = null; + Widget _buildAttachmentButton() => IconButton( + icon: StreamSvgIcon.attach( + color: _openFilePickerSection + ? _streamChatTheme.messageInputTheme.actionButtonColor + : _streamChatTheme.messageInputTheme.actionButtonIdleColor, + ), + padding: const EdgeInsets.all(0), + constraints: const BoxConstraints.tightFor( + height: 24, + width: 24, + ), + splashRadius: 24, + onPressed: () async { + _emojiOverlay?.remove(); + _emojiOverlay = null; + _commandsOverlay?.remove(); + _commandsOverlay = null; + _mentionsOverlay?.remove(); + _mentionsOverlay = null; - if (_openFilePickerSection) { - setState(() { - _animateContainer = true; - _openFilePickerSection = false; - _filePickerSize = _kMinMediaPickerSize; - }); - } else { - showAttachmentModal(); - } - }, - ); - } + if (_openFilePickerSection) { + setState(() { + _openFilePickerSection = false; + _filePickerSize = _kMinMediaPickerSize; + }); + } else { + showAttachmentModal(); + } + }, + ); - /// Show the attachment modal, making the user choose where to pick a media from + /// Show the attachment modal, making the user choose where to + /// pick a media from void showAttachmentModal() { if (_focusNode.hasFocus) { _focusNode.unfocus(); @@ -1780,7 +1765,7 @@ class MessageInputState extends State { } else { showModalBottomSheet( clipBehavior: Clip.hardEdge, - shape: RoundedRectangleBorder( + shape: const RoundedRectangleBorder( borderRadius: BorderRadius.only( topLeft: Radius.circular(32), topRight: Radius.circular(32), @@ -1788,63 +1773,61 @@ class MessageInputState extends State { ), context: context, isScrollControlled: true, - builder: (_) { - return Column( - mainAxisSize: MainAxisSize.min, - children: [ - ListTile( - title: Text( - 'Add a file', - style: TextStyle( - fontWeight: FontWeight.bold, + builder: (_) => Column( + mainAxisSize: MainAxisSize.min, + children: [ + const ListTile( + title: Text( + 'Add a file', + style: TextStyle( + fontWeight: FontWeight.bold, + ), ), ), - ), - ListTile( - leading: Icon(Icons.image), - title: Text('Upload a photo'), - onTap: () { - pickFile(DefaultAttachmentTypes.image, false); - Navigator.pop(context); - }, - ), - ListTile( - leading: Icon(Icons.video_library), - title: Text('Upload a video'), - onTap: () { - pickFile(DefaultAttachmentTypes.video, false); - Navigator.pop(context); - }, - ), - if (!kIsWeb) ListTile( - leading: Icon(Icons.camera_alt), - title: Text('Photo from camera'), + leading: const Icon(Icons.image), + title: const Text('Upload a photo'), onTap: () { - pickFile(DefaultAttachmentTypes.image, true); + pickFile(DefaultAttachmentTypes.image); Navigator.pop(context); }, ), - if (!kIsWeb) ListTile( - leading: Icon(Icons.videocam), - title: Text('Video from camera'), + leading: const Icon(Icons.video_library), + title: const Text('Upload a video'), onTap: () { - pickFile(DefaultAttachmentTypes.video, true); + pickFile(DefaultAttachmentTypes.video); Navigator.pop(context); }, ), - ListTile( - leading: Icon(Icons.insert_drive_file), - title: Text('Upload a file'), - onTap: () { - pickFile(DefaultAttachmentTypes.file, false); - Navigator.pop(context); - }, - ), - ], - ); - }); + if (!kIsWeb) + ListTile( + leading: const Icon(Icons.camera_alt), + title: const Text('Photo from camera'), + onTap: () { + pickFile(DefaultAttachmentTypes.image, true); + Navigator.pop(context); + }, + ), + if (!kIsWeb) + ListTile( + leading: const Icon(Icons.videocam), + title: const Text('Video from camera'), + onTap: () { + pickFile(DefaultAttachmentTypes.video, true); + Navigator.pop(context); + }, + ), + ListTile( + leading: const Icon(Icons.insert_drive_file), + title: const Text('Upload a file'), + onTap: () { + pickFile(DefaultAttachmentTypes.file); + Navigator.pop(context); + }, + ), + ], + )); } } @@ -1853,18 +1836,19 @@ class MessageInputState extends State { void addAttachment(Attachment attachment) { setState(() { _attachments[attachment.id] = attachment.copyWith( - uploadState: attachment.uploadState ?? UploadState.success(), + uploadState: attachment.uploadState, ); }); } /// Pick a file from the device /// If [camera] is true then the camera will open + // ignore: avoid_positional_boolean_parameters void pickFile(DefaultAttachmentTypes fileType, [bool camera = false]) async { setState(() => _inputEnabled = false); - AttachmentFile file; - String attachmentType; + AttachmentFile? file; + String? attachmentType; if (fileType == DefaultAttachmentTypes.image) { attachmentType = 'image'; @@ -1875,11 +1859,11 @@ class MessageInputState extends State { } if (camera) { - PickedFile pickedFile; + XFile? pickedFile; if (fileType == DefaultAttachmentTypes.image) { - pickedFile = await _imagePicker.getImage(source: ImageSource.camera); + pickedFile = await _imagePicker.pickImage(source: ImageSource.camera); } else if (fileType == DefaultAttachmentTypes.video) { - pickedFile = await _imagePicker.getVideo(source: ImageSource.camera); + pickedFile = await _imagePicker.pickVideo(source: ImageSource.camera); } if (pickedFile == null) { return; @@ -1891,7 +1875,7 @@ class MessageInputState extends State { bytes: bytes, ); } else { - FileType type; + late FileType type; if (fileType == DefaultAttachmentTypes.image) { type = FileType.image; } else if (fileType == DefaultAttachmentTypes.video) { @@ -1903,8 +1887,8 @@ class MessageInputState extends State { type: type, withData: true, ); - if (res?.files?.isNotEmpty == true) { - file = res.files.single.toAttachmentFile; + if (res?.files.isNotEmpty == true) { + file = res!.files.single.toAttachmentFile; } } @@ -1912,30 +1896,34 @@ class MessageInputState extends State { if (file == null) return; - final mimeType = file.name?.mimeType ?? file.path.split('/').last.mimeType; + final mimeType = file.name?.mimeType ?? file.path!.split('/').last.mimeType; - final extraDataMap = {}; + final extraDataMap = {}; if (mimeType?.subtype != null) { - extraDataMap['mime_type'] = mimeType.subtype.toLowerCase(); + extraDataMap['mime_type'] = mimeType!.subtype.toLowerCase(); } - if (file.size != null) { - extraDataMap['file_size'] = file.size; - } + extraDataMap['file_size'] = file.size!; final attachment = Attachment( file: file, type: attachmentType, - extraData: extraDataMap.isNotEmpty ? extraDataMap : null, + uploadState: const UploadState.preparing(), + extraData: extraDataMap, ); - if (file.size > _kMaxAttachmentSize) { - if (attachmentType == 'Video') { - final mediaInfo = await VideoService.compressVideo(file.path); + if (file.size! > widget.maxAttachmentSize) { + if (attachmentType == 'video' && file.path != null) { + final mediaInfo = await (VideoService.compressVideo( + file.path!, + frameRate: widget.compressedVideoFrameRate, + quality: widget.compressedVideoQuality, + ) as FutureOr); - if (mediaInfo.filesize > _kMaxAttachmentSize) { + if (mediaInfo.filesize! > widget.maxAttachmentSize) { _showErrorAlert( + // ignore: lines_longer_than_80_chars 'The file is too large to upload. The file size limit is 20MB. We tried compressing it, but it was not enough.', ); return; @@ -1943,7 +1931,7 @@ class MessageInputState extends State { file = AttachmentFile( name: file.name, size: mediaInfo.filesize, - bytes: await mediaInfo.file.readAsBytes(), + bytes: await mediaInfo.file!.readAsBytes(), path: mediaInfo.path, ); } else { @@ -1957,44 +1945,40 @@ class MessageInputState extends State { _attachments[attachment.id] = attachment; setState(() { - _attachments.update(attachment.id, (it) { - return it.copyWith( - file: file, - extraData: {...it.extraData}..update('file_size', (_) => file.size), - ); - }); + _attachments.update( + attachment.id, + (it) => it.copyWith( + file: file, + extraData: {...it.extraData} + ..update('file_size', ((_) => file!.size!)), + )); }); } - Widget _buildIdleSendButton(BuildContext context) { - return Padding( - padding: const EdgeInsets.all(8.0), - child: StreamSvgIcon( - assetName: _getIdleSendIcon(), - color: - StreamChatTheme.of(context).messageInputTheme.sendButtonIdleColor, - ), - ); - } + Widget _buildIdleSendButton(BuildContext context) => Padding( + padding: const EdgeInsets.all(8), + child: StreamSvgIcon( + assetName: _getIdleSendIcon(), + color: _streamChatTheme.messageInputTheme.sendButtonIdleColor, + ), + ); - Widget _buildSendButton(BuildContext context) { - return Padding( - padding: const EdgeInsets.all(8.0), - child: IconButton( - onPressed: sendMessage, - padding: const EdgeInsets.all(0), - splashRadius: 24, - constraints: BoxConstraints.tightFor( - height: 24, - width: 24, + Widget _buildSendButton(BuildContext context) => Padding( + padding: const EdgeInsets.all(8), + child: IconButton( + onPressed: sendMessage, + padding: const EdgeInsets.all(0), + splashRadius: 24, + constraints: const BoxConstraints.tightFor( + height: 24, + width: 24, + ), + icon: StreamSvgIcon( + assetName: _getSendIcon(), + color: _streamChatTheme.messageInputTheme.sendButtonColor, + ), ), - icon: StreamSvgIcon( - assetName: _getSendIcon(), - color: StreamChatTheme.of(context).messageInputTheme.sendButtonColor, - ), - ), - ); - } + ); String _getIdleSendIcon() { if (_commandEnabled) { @@ -2015,7 +1999,7 @@ class MessageInputState extends State { } /// Sends the current message - void sendMessage() async { + Future sendMessage() async { var text = textEditingController.text.trim(); if (text.isEmpty && _attachments.isEmpty) { return; @@ -2024,16 +2008,14 @@ class MessageInputState extends State { final shouldUnfocus = _commandEnabled; if (_commandEnabled) { - text = '/${_chosenCommand.name} ' + text; + text = '${'/${_chosenCommand!.name} '}$text'; } final attachments = [..._attachments.values]; textEditingController.clear(); _attachments.clear(); - if (widget.onQuotedMessageCleared != null) { - widget.onQuotedMessageCleared(); - } + widget.onQuotedMessageCleared?.call(); setState(() { _messageIsPresent = false; @@ -2045,10 +2027,9 @@ class MessageInputState extends State { _mentionsOverlay?.remove(); _mentionsOverlay = null; - Future sendingFuture; Message message; if (widget.editMessage != null) { - message = widget.editMessage.copyWith( + message = widget.editMessage!.copyWith( text: text, attachments: attachments, mentionedUsers: @@ -2067,123 +2048,122 @@ class MessageInputState extends State { if (widget.quotedMessage != null) { message = message.copyWith( - quotedMessageId: widget.quotedMessage.id, + quotedMessageId: widget.quotedMessage!.id, ); } if (widget.preMessageSending != null) { - message = await widget.preMessageSending(message); + message = await widget.preMessageSending!(message); } final streamChannel = StreamChannel.of(context); final channel = streamChannel.channel; - if (!channel.state.isUpToDate) { + if (!channel.state!.isUpToDate) { await streamChannel.reloadChannel(); } _mentionedUsers.clear(); - if (widget.editMessage == null || - widget.editMessage.status == MessageSendingStatus.failed || - widget.editMessage.status == MessageSendingStatus.sending) { - sendingFuture = channel.sendMessage(message); - } else { - sendingFuture = channel.updateMessage(message); - } + try { + Future sendingFuture; + if (widget.editMessage == null || + widget.editMessage!.status == MessageSendingStatus.failed || + widget.editMessage!.status == MessageSendingStatus.sending) { + sendingFuture = channel.sendMessage(message); + } else { + sendingFuture = channel.updateMessage(message); + } - if (!shouldUnfocus) { - FocusScope.of(context).requestFocus(_focusNode); - } + if (!shouldUnfocus) { + FocusScope.of(context).requestFocus(_focusNode); + } - return sendingFuture.then((resp) { + final resp = await sendingFuture; if (resp.message?.type == 'error') { _parseExistingMessage(message); } - if (widget.onMessageSent != null) { - widget.onMessageSent(resp.message); + widget.onMessageSent?.call(resp.message); + } catch (e, stk) { + if (widget.onError != null) { + widget.onError?.call(e, stk); + } else { + rethrow; } - }); + } } - StreamSubscription _keyboardListener; + StreamSubscription? _keyboardListener; void _showErrorAlert(String description) { showModalBottomSheet( - backgroundColor: StreamChatTheme.of(context).colorTheme.white, + backgroundColor: _streamChatTheme.colorTheme.barsBg, context: context, - shape: RoundedRectangleBorder( + shape: const RoundedRectangleBorder( borderRadius: BorderRadius.only( - topLeft: Radius.circular(16.0), - topRight: Radius.circular(16.0), + topLeft: Radius.circular(16), + topRight: Radius.circular(16), )), - builder: (context) { - return Column( - mainAxisSize: MainAxisSize.min, - children: [ - SizedBox( - height: 26.0, + builder: (context) => Column( + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox( + height: 26, + ), + StreamSvgIcon.error( + color: _streamChatTheme.colorTheme.accentError, + size: 24, + ), + const SizedBox( + height: 26, + ), + Text( + 'Something went wrong', + style: _streamChatTheme.textTheme.headlineBold, + ), + const SizedBox( + height: 7, + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Text( + description, + textAlign: TextAlign.center, ), - StreamSvgIcon.error( - color: StreamChatTheme.of(context).colorTheme.accentRed, - size: 24.0, - ), - SizedBox( - height: 26.0, - ), - Text( - 'Something went wrong', - style: StreamChatTheme.of(context).textTheme.headlineBold, - ), - SizedBox( - height: 7.0, - ), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16.0), - child: Text( - description, - textAlign: TextAlign.center, - ), - ), - SizedBox( - height: 36.0, - ), - Container( - color: - StreamChatTheme.of(context).colorTheme.black.withOpacity(.08), - height: 1.0, - ), - Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - TextButton( - onPressed: () { - Navigator.of(context).pop(); - }, - child: Text( - 'OK', - style: StreamChatTheme.of(context) - .textTheme - .bodyBold - .copyWith( - color: StreamChatTheme.of(context) - .colorTheme - .accentBlue), - ), + ), + const SizedBox( + height: 36, + ), + Container( + color: + _streamChatTheme.colorTheme.textHighEmphasis.withOpacity(.08), + height: 1, + ), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + TextButton( + onPressed: () { + Navigator.of(context).pop(); + }, + child: Text( + 'OK', + style: _streamChatTheme.textTheme.bodyBold.copyWith( + color: _streamChatTheme.colorTheme.accentPrimary), ), - ], - ), - ], - ); - }, + ), + ], + ), + ], + ), ); } void _parseExistingMessage(Message message) { - textEditingController.text = message.text; + textEditingController.text = message.text!; _messageIsPresent = true; - for (final attachment in message?.attachments) { + for (final attachment in message.attachments) { _attachments[attachment.id] = attachment.copyWith( - uploadState: attachment.uploadState ?? UploadState.success(), + uploadState: attachment.uploadState, ); } } @@ -2201,6 +2181,7 @@ class MessageInputState extends State { @override void didChangeDependencies() { + _streamChatTheme = StreamChatTheme.of(context); if (widget.editMessage != null && !_initialized) { FocusScope.of(context).requestFocus(_focusNode); _initialized = true; @@ -2211,12 +2192,6 @@ class MessageInputState extends State { /// Represents a 2-tuple, or pair. class Tuple2 { - /// Returns the first item of the tuple - final T1 item1; - - /// Returns the second item of the tuple - final T2 item2; - /// Creates a new tuple value with the specified items. const Tuple2(this.item1, this.item2); @@ -2229,6 +2204,12 @@ class Tuple2 { return Tuple2(items[0] as T1, items[1] as T2); } + /// Returns the first item of the tuple + final T1 item1; + + /// Returns the second item of the tuple + final T2 item2; + /// Returns a tuple with the first item set to the specified value. Tuple2 withItem1(T1 v) => Tuple2(v, item2); @@ -2258,27 +2239,29 @@ class Tuple2 { } class _PickerWidget extends StatefulWidget { + const _PickerWidget({ + Key? key, + required this.filePickerIndex, + required this.containsFile, + required this.selectedMedias, + required this.onAddMoreFilesClick, + required this.onMediaSelected, + required this.streamChatTheme, + }) : super(key: key); + final int filePickerIndex; final bool containsFile; final List selectedMedias; final void Function(DefaultAttachmentTypes) onAddMoreFilesClick; final void Function(AssetEntity) onMediaSelected; - - const _PickerWidget({ - Key key, - @required this.filePickerIndex, - @required this.containsFile, - @required this.selectedMedias, - @required this.onAddMoreFilesClick, - @required this.onMediaSelected, - }) : super(key: key); + final StreamChatThemeData streamChatTheme; @override __PickerWidgetState createState() => __PickerWidgetState(); } class __PickerWidgetState extends State<_PickerWidget> { - Future requestPermission; + Future? requestPermission; @override void initState() { @@ -2289,7 +2272,7 @@ class __PickerWidgetState extends State<_PickerWidget> { @override Widget build(BuildContext context) { if (widget.filePickerIndex != 0) { - return Offstage(); + return const Offstage(); } return FutureBuilder( future: requestPermission, @@ -2298,20 +2281,20 @@ class __PickerWidgetState extends State<_PickerWidget> { return const Center(child: CircularProgressIndicator()); } - if (snapshot.data) { + if (snapshot.data!) { if (widget.containsFile) { return GestureDetector( onTap: () { widget.onAddMoreFilesClick(DefaultAttachmentTypes.file); }, child: Container( - constraints: BoxConstraints.expand(), - color: StreamChatTheme.of(context).colorTheme.whiteSmoke, + constraints: const BoxConstraints.expand(), + color: widget.streamChatTheme.colorTheme.inputBg, alignment: Alignment.center, child: Text( 'Add more files', style: TextStyle( - color: StreamChatTheme.of(context).colorTheme.accentBlue, + color: widget.streamChatTheme.colorTheme.accentPrimary, fontWeight: FontWeight.bold, ), ), @@ -2329,7 +2312,7 @@ class __PickerWidgetState extends State<_PickerWidget> { PhotoManager.openSetting(); }, child: Container( - color: StreamChatTheme.of(context).colorTheme.whiteSmoke, + color: widget.streamChatTheme.colorTheme.inputBg, child: Column( mainAxisAlignment: MainAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.stretch, @@ -2338,26 +2321,23 @@ class __PickerWidgetState extends State<_PickerWidget> { 'svgs/icon_picture_empty_state.svg', package: 'stream_chat_flutter', height: 140, - color: StreamChatTheme.of(context).colorTheme.greyGainsboro, + color: widget.streamChatTheme.colorTheme.disabled, ), Text( + // ignore: lines_longer_than_80_chars 'Please enable access to your photos \nand videos so you can share them with friends.', - style: StreamChatTheme.of(context).textTheme.body.copyWith( - color: StreamChatTheme.of(context).colorTheme.grey), + style: widget.streamChatTheme.textTheme.body.copyWith( + color: + widget.streamChatTheme.colorTheme.textLowEmphasis), textAlign: TextAlign.center, ), - SizedBox(height: 6.0), + const SizedBox(height: 6), Center( child: Text( 'Allow access to your gallery', - style: StreamChatTheme.of(context) - .textTheme - .bodyBold - .copyWith( - color: StreamChatTheme.of(context) - .colorTheme - .accentBlue, - ), + style: widget.streamChatTheme.textTheme.bodyBold.copyWith( + color: widget.streamChatTheme.colorTheme.accentPrimary, + ), ), ), ], diff --git a/packages/stream_chat_flutter/lib/src/message_list_view.dart b/packages/stream_chat_flutter/lib/src/message_list_view.dart index 9af5bc4a..822a2f5a 100644 --- a/packages/stream_chat_flutter/lib/src/message_list_view.dart +++ b/packages/stream_chat_flutter/lib/src/message_list_view.dart @@ -1,72 +1,93 @@ +// ignore_for_file: lines_longer_than_80_chars import 'dart:async'; -import 'dart:math'; +import 'package:collection/collection.dart'; import 'package:flutter/cupertino.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:jiffy/jiffy.dart'; import 'package:rxdart/rxdart.dart'; import 'package:scrollable_positioned_list/scrollable_positioned_list.dart'; +import 'package:stream_chat_flutter/src/extension.dart'; import 'package:stream_chat_flutter/src/info_tile.dart'; import 'package:stream_chat_flutter/src/message_widget.dart'; import 'package:stream_chat_flutter/src/stream_svg_icon.dart'; +import 'package:stream_chat_flutter/src/swipeable.dart'; import 'package:stream_chat_flutter/src/system_message.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart'; import 'package:visibility_detector/visibility_detector.dart'; -import '../stream_chat_flutter.dart'; -import 'connection_status_builder.dart'; -import 'date_divider.dart'; -import 'extension.dart'; -import 'swipeable.dart'; - +/// Widget builder for message +/// [defaultMessageWidget] is the default [MessageWidget] configuration +/// Use [defaultMessageWidget.copyWith] to easily customize it typedef MessageBuilder = Widget Function( BuildContext, MessageDetails, List, + MessageWidget defaultMessageWidget, ); + +/// Widget builder for parent message +/// [defaultMessageWidget] is the default [MessageWidget] configuration +/// Use [defaultMessageWidget.copyWith] to easily customize it typedef ParentMessageBuilder = Widget Function( BuildContext, - Message, + Message?, + MessageWidget defaultMessageWidget, ); + +/// Widget builder for system message typedef SystemMessageBuilder = Widget Function( BuildContext, Message, ); -typedef ThreadBuilder = Widget Function(BuildContext context, Message parent); -typedef ThreadTapCallback = void Function(Message, Widget); +/// Widget builder for thread +typedef ThreadBuilder = Widget Function(BuildContext context, Message? parent); + +/// Callback for thread taps +typedef ThreadTapCallback = void Function(Message, Widget?); + +/// Callback on message swiped typedef OnMessageSwiped = void Function(Message); + +/// Callback on message tapped typedef OnMessageTap = void Function(Message); + +/// Callback on reply tapped typedef ReplyTapCallback = void Function(Message); +/// Class for message details class MessageDetails { - /// True if the message belongs to the current user - bool isMyMessage; - - /// True if the user message is the same of the previous message - bool isLastUser; - - /// True if the user message is the same of the next message - bool isNextUser; - - /// The message - Message message; - - /// The index of the message - int index; - + /// Constructor for creating [MessageDetails] MessageDetails( - BuildContext context, + String currentUserId, this.message, List messages, this.index, ) { - isMyMessage = message.user.id == StreamChat.of(context).user.id; + isMyMessage = message.user?.id == currentUserId; isLastUser = index + 1 < messages.length && - message.user.id == messages[index + 1]?.user?.id; + message.user?.id == messages[index + 1].user?.id; isNextUser = - index - 1 >= 0 && message.user.id == messages[index - 1]?.user?.id; + index - 1 >= 0 && message.user!.id == messages[index - 1].user?.id; } + + /// True if the message belongs to the current user + late final bool isMyMessage; + + /// True if the user message is the same of the previous message + late final bool isLastUser; + + /// True if the user message is the same of the next message + late final bool isNextUser; + + /// The message + final Message message; + + /// The index of the message + final int index; } /// ![screenshot](https://raw.githubusercontent.com/GetStream/stream-chat-flutter/master/screenshots/message_listview.png) @@ -104,22 +125,23 @@ class MessageDetails { /// ``` /// /// -/// Make sure to have a [StreamChannel] ancestor in order to provide the information about the channels. +/// Make sure to have a [StreamChannel] ancestor in order to +/// provide the information about the channels. /// The widget uses a [ListView.custom] to render the list of channels. /// -/// The widget components render the ui based on the first ancestor of type [StreamChatTheme]. +/// The widget components render the ui based on the first +/// ancestor of type [StreamChatTheme]. /// Modify it to change the widget appearance. class MessageListView extends StatefulWidget { /// Instantiate a new MessageListView - MessageListView({ - Key key, + const MessageListView({ + Key? key, this.showScrollToBottom = true, this.messageBuilder, this.parentMessageBuilder, this.parentMessage, this.threadBuilder, this.onThreadTap, - this.onReplyTap, this.dateDividerBuilder, this.scrollPhysics = const ClampingScrollPhysics(), this.initialScrollIndex, @@ -129,68 +151,69 @@ class MessageListView extends StatefulWidget { this.onMessageSwiped, this.highlightInitialMessage = false, this.messageHighlightColor, - this.onShowMessage, this.showConnectionStateTile = false, + this.headerBuilder, + this.footerBuilder, this.loadingBuilder, this.emptyBuilder, this.systemMessageBuilder, this.messageListBuilder, - this.errorWidgetBuilder, + this.errorBuilder, this.messageFilter, - this.customAttachmentBuilders, this.onMessageTap, this.onSystemMessageTap, - this.onAttachmentTap, - this.textBuilder, + this.pinPermissions = const [], + this.showFloatingDateDivider = true, + this.threadSeparatorBuilder, + this.messageListController, }) : super(key: key); /// Function used to build a custom message widget - final MessageBuilder messageBuilder; + final MessageBuilder? messageBuilder; /// Function used to build a custom system message widget - final SystemMessageBuilder systemMessageBuilder; + final SystemMessageBuilder? systemMessageBuilder; /// Function used to build a custom parent message widget - final ParentMessageBuilder parentMessageBuilder; + final ParentMessageBuilder? parentMessageBuilder; /// Function used to build a custom thread widget - final ThreadBuilder threadBuilder; + final ThreadBuilder? threadBuilder; /// Function called when tapping on a thread - /// By default it calls [Navigator.push] using the widget built using [threadBuilder] - final ThreadTapCallback onThreadTap; + /// By default it calls [Navigator.push] using the widget + /// built using [threadBuilder] + final ThreadTapCallback? onThreadTap; - /// If true will show a scroll to bottom message when there are new messages and the scroll offset is not zero + /// If true will show a scroll to bottom message when there are new + /// messages and the scroll offset is not zero final bool showScrollToBottom; /// Parent message in case of a thread - final Message parentMessage; + final Message? parentMessage; /// Builder used to render date dividers - final Widget Function(DateTime) dateDividerBuilder; + final Widget Function(DateTime)? dateDividerBuilder; /// Index of an item to initially align within the viewport. - final int initialScrollIndex; + final int? initialScrollIndex; /// Determines where the leading edge of the item at [initialScrollIndex] /// should be placed. - final double initialAlignment; + final double? initialAlignment; /// Controller for jumping or scrolling to an item. - final ItemScrollController scrollController; + final ItemScrollController? scrollController; /// Provides a listenable iterable of [itemPositions] of items that are on /// screen and their locations. - final ItemPositionsListener itemPositionListener; + final ItemPositionsListener? itemPositionListener; /// The ScrollPhysics used by the ListView final ScrollPhysics scrollPhysics; /// Called when message item gets swiped - final OnMessageSwiped onMessageSwiped; - - /// - final ReplyTapCallback onReplyTap; + final OnMessageSwiped? onMessageSwiped; /// If true the list will highlight the initialMessage if there is any. /// @@ -198,65 +221,77 @@ class MessageListView extends StatefulWidget { final bool highlightInitialMessage; /// Color used while highlighting initial message - final Color messageHighlightColor; - - final ShowMessageCallback onShowMessage; + final Color? messageHighlightColor; + /// Flag for showing tile on header final bool showConnectionStateTile; + /// Flag for showing the floating date divider + final bool showFloatingDateDivider; + /// Function called when messages are fetched - final Widget Function(BuildContext, List) messageListBuilder; + final Widget Function(BuildContext, List)? messageListBuilder; + + /// Function used to build a header widget + final WidgetBuilder? headerBuilder; + + /// Function used to build a footer widget + final WidgetBuilder? footerBuilder; /// Function used to build a loading widget - final WidgetBuilder loadingBuilder; + final WidgetBuilder? loadingBuilder; /// Function used to build an empty widget - final WidgetBuilder emptyBuilder; + final WidgetBuilder? emptyBuilder; - /// Callback triggered when an error occurs while performing the given request. - /// This parameter can be used to display an error message to users in the event + /// Callback triggered when an error occurs while performing the + /// given request. + /// This parameter can be used to display an error message to + /// users in the event /// of a connection failure. - final ErrorBuilder errorWidgetBuilder; + final ErrorBuilder? errorBuilder; /// Predicate used to filter messages - final bool Function(Message) messageFilter; + final bool Function(Message)? messageFilter; - /// Attachment builders for the default message widget - /// Please change this in the [MessageWidget] if you are using a custom implementation - final Map customAttachmentBuilders; - - /// Called when any message is tapped except a system message (use [onSystemMessageTap] instead) - final OnMessageTap onMessageTap; + /// Called when any message is tapped except a system message + /// (use [onSystemMessageTap] instead) + final OnMessageTap? onMessageTap; /// Called when system message is tapped - final OnMessageTap onSystemMessageTap; + final OnMessageTap? onSystemMessageTap; - /// Customize onTap on attachment - final void Function(Message message, Attachment attachment) onAttachmentTap; + /// A List of user types that have permission to pin messages + final List pinPermissions; - /// Customize the MessageWidget textBuilder - final void Function(BuildContext context, Message message) textBuilder; + /// Builder used to build the thread separator in case it's a thread view + final WidgetBuilder? threadSeparatorBuilder; + + /// A [MessageListController] allows pagination. + /// Use [ChannelListController.paginateData] pagination. + final MessageListController? messageListController; @override _MessageListViewState createState() => _MessageListViewState(); } class _MessageListViewState extends State { - ItemScrollController _scrollController; - Function _onThreadTap; + ItemScrollController? _scrollController; + void Function(Message)? _onThreadTap; bool _showScrollToBottom = false; - ItemPositionsListener _itemPositionListener; - int _messageListLength; - StreamChannelState streamChannel; + late final ItemPositionsListener _itemPositionListener; + late final Stream> _itemPositionStream; + int? _messageListLength; + StreamChannelState? streamChannel; + late StreamChatThemeData _streamTheme; - int get _initialIndex { + int? get _initialIndex { if (widget.initialScrollIndex != null) return widget.initialScrollIndex; - if (streamChannel.initialMessageId != null) { - final messages = streamChannel.channel.state.messages; + if (streamChannel!.initialMessageId != null) { + final messages = streamChannel!.channel.state!.messages; final totalMessages = messages.length; - final messageIndex = messages.indexWhere((e) { - return e.id == streamChannel.initialMessageId; - }); + final messageIndex = + messages.indexWhere((e) => e.id == streamChannel!.initialMessageId); final index = totalMessages - messageIndex; if (index != 0) return index - 1; return index; @@ -264,24 +299,22 @@ class _MessageListViewState extends State { return 0; } - double get _initialAlignment { + double? get _initialAlignment { if (widget.initialAlignment != null) return widget.initialAlignment; return 0; } - bool _isInitialMessage(String id) { - return streamChannel.initialMessageId == id; - } + bool _isInitialMessage(String id) => streamChannel!.initialMessageId == id; - bool get _upToDate => streamChannel.channel.state.isUpToDate; + bool get _upToDate => streamChannel!.channel.state!.isUpToDate; bool get _isThreadConversation => widget.parentMessage != null; bool _topPaginationActive = false; bool _bottomPaginationActive = false; - int initialIndex; - double initialAlignment; + int? initialIndex; + double? initialAlignment; List messages = []; @@ -289,53 +322,40 @@ class _MessageListViewState extends State { bool _inBetweenList = false; - final MessageListController _messageListController = MessageListController(); + late final _defaultController = MessageListController(); + MessageListController get _messageListController => + widget.messageListController ?? _defaultController; @override - Widget build(BuildContext context) { - return MessageListCore( - messageFilter: widget.messageFilter, - loadingBuilder: widget.loadingBuilder ?? - (context) { - return Center( - child: const CircularProgressIndicator(), - ); - }, - emptyBuilder: widget.emptyBuilder ?? - (context) { - return Center( - child: Text( - 'No chats here yet...', - style: StreamChatTheme.of(context).textTheme.footnote.copyWith( - color: StreamChatTheme.of(context) - .colorTheme - .black - .withOpacity(.5)), - ), - ); - }, - messageListBuilder: widget.messageListBuilder ?? - (context, list) { - return _buildListView(list); - }, - messageListController: _messageListController, - parentMessage: widget.parentMessage, - showScrollToBottom: widget.showScrollToBottom, - errorWidgetBuilder: widget.errorWidgetBuilder ?? - (BuildContext context, Object error) { - return Center( - child: Text( - 'Something went wrong', - style: StreamChatTheme.of(context).textTheme.footnote.copyWith( - color: StreamChatTheme.of(context) - .colorTheme - .black - .withOpacity(.5)), - ), - ); - }, - ); - } + Widget build(BuildContext context) => MessageListCore( + messageFilter: widget.messageFilter, + loadingBuilder: widget.loadingBuilder ?? + (context) => const Center( + child: CircularProgressIndicator(), + ), + emptyBuilder: widget.emptyBuilder ?? + (context) => Center( + child: Text( + 'No chats here yet...', + style: _streamTheme.textTheme.footnote.copyWith( + color: _streamTheme.colorTheme.textHighEmphasis + .withOpacity(.5)), + ), + ), + messageListBuilder: widget.messageListBuilder ?? + (context, list) => _buildListView(list), + messageListController: _messageListController, + parentMessage: widget.parentMessage, + errorBuilder: widget.errorBuilder ?? + (BuildContext context, Object error) => Center( + child: Text( + 'Something went wrong', + style: _streamTheme.textTheme.footnote.copyWith( + color: _streamTheme.colorTheme.textHighEmphasis + .withOpacity(.5)), + ), + ), + ); Widget _buildListView(List data) { messages = data; @@ -343,9 +363,9 @@ class _MessageListViewState extends State { if (_messageListLength != null) { if (_bottomPaginationActive || (_inBetweenList && _upToDate)) { - if (_itemPositionListener.itemPositions.value?.isNotEmpty == true) { + if (_itemPositionListener.itemPositions.value.isNotEmpty == true) { final first = _itemPositionListener.itemPositions.value.first; - final diff = newMessagesListLength - _messageListLength; + final diff = newMessagesListLength - _messageListLength!; if (diff > 0) { initialIndex = first.index + diff; initialAlignment = first.itemLeadingEdge; @@ -360,6 +380,12 @@ class _MessageListViewState extends State { _messageListLength = newMessagesListLength; + final itemCount = messages.length + // total messages + 2 + // top + bottom loading indicator + 2 + // header + footer + 1 // parent message + ; + return Stack( alignment: Alignment.center, children: [ @@ -381,7 +407,7 @@ class _MessageListViewState extends State { } return InfoTile( - showMessage: widget.showConnectionStateTile ? showStatus : false, + showMessage: widget.showConnectionStateTile && showStatus, tileAnchor: Alignment.topCenter, childAnchor: Alignment.topCenter, message: statusString, @@ -413,55 +439,72 @@ class _MessageListViewState extends State { _inBetweenList = true; }, child: ScrollablePositionedList.separated( - key: ValueKey(initialIndex + initialAlignment), + key: ValueKey(initialIndex! + initialAlignment!), itemPositionsListener: _itemPositionListener, - addAutomaticKeepAlives: true, initialScrollIndex: initialIndex ?? 0, initialAlignment: initialAlignment ?? 0, physics: widget.scrollPhysics, itemScrollController: _scrollController, reverse: true, - itemCount: - messages.length + 2 + (_isThreadConversation ? 1 : 0), + addAutomaticKeepAlives: false, + itemCount: itemCount, + + // Item Count -> 8 (1 parent, 2 header+footer, 2 top+bottom, 3 messages) + // eg: |Type| rev(|Index(item)|) rev(|Index(separator)|) |Index(item)| |Index(separator)| + // ParentMessage -> 7 (count-1) + // Separator(ThreadSeparator) -> 6 (count-2) + // Header -> 6 (count-2) + // Separator(Header -> 8??T -> 0||52) -> 5 (count-3) + // TopLoader -> 5 (count-3) + // Separator(0) -> 4 (count-4) + // Message -> 4 (count-4) + // Separator(2||8) -> 3 (count-5) + // Message -> 3 (count-5) + // Separator(2||8) -> 2 (count-6) + // Message -> 2 (count-6) + // Separator(0) -> 1 (count-7) + // BottomLoader -> 1 (count-7) + // Separator(Footer -> 8??30) -> 0 (count-8) + // Footer -> 0 (count-8) + separatorBuilder: (context, i) { - if (i == messages.length) return Offstage(); - if (i == 0) return SizedBox(height: 30); - if (i == messages.length + 1) { - final replyCount = widget.parentMessage.replyCount; - return Container( - decoration: BoxDecoration( - gradient: - StreamChatTheme.of(context).colorTheme.bgGradient, - ), - child: Padding( - padding: const EdgeInsets.all(8.0), - child: Text( - '$replyCount ${replyCount == 1 ? 'Reply' : 'Replies'}', - textAlign: TextAlign.center, - style: StreamChatTheme.of(context) - .channelTheme - .channelHeaderTheme - .subtitle, - ), - ), - ); + if (i == itemCount - 2) { + if (widget.parentMessage == null) { + return const Offstage(); + } + return _buildThreadSeparator(); + } + if (i == itemCount - 3) { + if (widget.headerBuilder == null) { + if (_isThreadConversation) return const Offstage(); + return const SizedBox(height: 52); + } + return const SizedBox(height: 8); + } + if (i == 0) { + if (widget.footerBuilder == null) { + return const SizedBox(height: 30); + } + return const SizedBox(height: 8); } - final message = messages[i]; - final nextMessage = messages[i - 1]; + if (i == 1 || i == itemCount - 4) return const Offstage(); + + final message = messages[i - 1]; + final nextMessage = messages[i - 2]; if (!Jiffy(message.createdAt.toLocal()).isSame( nextMessage.createdAt.toLocal(), Units.DAY, )) { final divider = widget.dateDividerBuilder != null - ? widget.dateDividerBuilder( + ? widget.dateDividerBuilder!( nextMessage.createdAt.toLocal(), ) : DateDivider( dateTime: nextMessage.createdAt.toLocal(), ); return Padding( - padding: const EdgeInsets.symmetric(vertical: 12.0), + padding: const EdgeInsets.symmetric(vertical: 12), child: divider, ); } @@ -472,75 +515,62 @@ class _MessageListViewState extends State { ); final isNextUserSame = - message.user.id == nextMessage.user?.id; - final isThread = message.replyCount > 0; + message.user!.id == nextMessage.user?.id; + final isThread = message.replyCount! > 0; final isDeleted = message.isDeleted; if (timeDiff >= 1 || !isNextUserSame || isThread || isDeleted) { - return SizedBox(height: 8); + return const SizedBox(height: 8); } - return SizedBox(height: 2); + return const SizedBox(height: 2); }, itemBuilder: (context, i) { - if (i == messages.length + 2) { - if (widget.parentMessageBuilder != null) { - return widget.parentMessageBuilder( - context, - widget.parentMessage, - ); - } else { - return buildParentMessage(widget.parentMessage); - } + if (i == itemCount - 1) { + if (widget.parentMessage == null) return const Offstage(); + return buildParentMessage(widget.parentMessage!); } - if (i == messages.length + 1) { + + if (i == itemCount - 2) { + return widget.headerBuilder?.call(context) ?? + const Offstage(); + } + + if (i == itemCount - 3) { return _buildLoadingIndicator( - streamChannel, + streamChannel!, QueryDirection.top, ); } - if (i == 0) { + + if (i == 1) { return _buildLoadingIndicator( - streamChannel, + streamChannel!, QueryDirection.bottom, ); } - final message = messages[i - 1]; + if (i == 0) { + return widget.footerBuilder?.call(context) ?? + const Offstage(); + } + + const bottomMessageIndex = 2; // 1 -> loader // 0 -> footer + + final message = messages[i - 2]; Widget messageWidget; - if (i == 1) { + if (i == bottomMessageIndex) { messageWidget = _buildBottomMessage( context, message, messages, - streamChannel, - ); - } else if (i == messages.length - 1) { - messageWidget = _buildTopMessage( - context, - message, - messages, - streamChannel, + streamChannel!, + i - 2, ); } else { - if (widget.messageBuilder != null) { - messageWidget = Builder( - key: ValueKey('MESSAGE-${message.id}'), - builder: (context) => widget.messageBuilder( - context, - MessageDetails( - context, - message, - messages, - i, - ), - messages), - ); - } else { - messageWidget = buildMessage(message, messages, i); - } + messageWidget = buildMessage(message, messages, i - 2); } return messageWidget; }, @@ -550,224 +580,174 @@ class _MessageListViewState extends State { }, ), if (widget.showScrollToBottom) _buildScrollToBottom(), - Positioned( - top: 20.0, - child: ValueListenableBuilder>( - valueListenable: _itemPositionListener.itemPositions, - builder: (context, values, _) { - final items = _itemPositionListener.itemPositions?.value; - if (items.isEmpty || messages.isEmpty) { - return SizedBox(); - } - - var index = _getTopElement(values).index; - - if (index > messages.length) { - return SizedBox(); - } - - if (index == messages.length) { - index = max(index - 1, 0); - } - - return widget.dateDividerBuilder != null - ? widget.dateDividerBuilder( - messages[index].createdAt.toLocal(), - ) - : DateDivider( - dateTime: messages[index].createdAt.toLocal(), - ); - }, - ), - ), + if (widget.showFloatingDateDivider) + _buildFloatingDateDivider(itemCount), ], ); } - Future _paginateData( - StreamChannelState channel, QueryDirection direction) { - return _messageListController.paginateData(direction: direction); - } + Widget _buildThreadSeparator() { + if (widget.threadSeparatorBuilder != null) { + return widget.threadSeparatorBuilder!.call(context); + } - ItemPosition _getTopElement(Iterable values) { - return values - .where((ItemPosition position) => position.itemLeadingEdge < 0.9) - .reduce((ItemPosition max, ItemPosition position) => - position.itemLeadingEdge > max.itemLeadingEdge ? position : max); - } - - Widget _buildScrollToBottom() { - return StreamBuilder>( - stream: Rx.combineLatest2( - streamChannel.channel.state.isUpToDateStream, - streamChannel.channel.state.unreadCountStream, - (bool isUpToDate, int unreadCount) => Tuple2(isUpToDate, unreadCount), + final replyCount = widget.parentMessage!.replyCount; + return DecoratedBox( + decoration: BoxDecoration( + gradient: _streamTheme.colorTheme.bgGradient, ), - builder: (_, snapshot) { - if (snapshot.hasError) { - return Offstage(); - } else if (!snapshot.hasData) { - return Offstage(); - } - final isUpToDate = snapshot.data.item1; - final showScrollToBottom = !isUpToDate || _showScrollToBottom; - if (!showScrollToBottom) { - return Offstage(); - } - final unreadCount = snapshot.data.item2; - final showUnreadCount = unreadCount > 0 && - streamChannel.channel.state.members.any( - (e) => e.userId == streamChannel.channel.client.state.user.id); - return Positioned( - bottom: 8, - right: 8, - width: 40, - height: 40, - child: Stack( - clipBehavior: Clip.none, - children: [ - FloatingActionButton( - backgroundColor: StreamChatTheme.of(context).colorTheme.white, - onPressed: () { - if (unreadCount > 0) { - streamChannel.channel.markRead(); - } - if (!_upToDate) { - _bottomPaginationActive = false; - _topPaginationActive = false; - streamChannel.reloadChannel(); - } else { - setState(() => _showScrollToBottom = false); - _scrollController.scrollTo( - index: 0, - duration: Duration(seconds: 1), - curve: Curves.easeInOut, - ); - } - }, - child: StreamSvgIcon.down( - color: StreamChatTheme.of(context).colorTheme.black, + child: Padding( + padding: const EdgeInsets.all(8), + child: Text( + '$replyCount ${replyCount == 1 ? 'Reply' : 'Replies'}', + textAlign: TextAlign.center, + style: _streamTheme.channelTheme.channelHeaderTheme.subtitle, + ), + ), + ); + } + + Positioned _buildFloatingDateDivider(int itemCount) => Positioned( + top: 20, + child: BetterStreamBuilder>( + initialData: _itemPositionListener.itemPositions.value, + stream: _itemPositionStream, + comparator: (a, b) { + if (a == null || b == null) { + return false; + } + final aTop = _getTopElementIndex(a); + final bTop = _getTopElementIndex(b); + return aTop == bTop; + }, + builder: (context, values) { + if (values.isEmpty || messages.isEmpty) { + return const Offstage(); + } + + final index = _getTopElementIndex(values); + + if (index == null || index <= 2 || index >= itemCount - 3) { + return const Offstage(); + } + + final message = messages[index - 2]; + return widget.dateDividerBuilder != null + ? widget.dateDividerBuilder!(message.createdAt.toLocal()) + : DateDivider(dateTime: message.createdAt.toLocal()); + }, + ), + ); + + Future _paginateData( + StreamChannelState? channel, QueryDirection direction) => + _messageListController.paginateData!(direction: direction); + + int? _getTopElementIndex(Iterable values) { + final inView = values.where((position) => position.itemLeadingEdge < 1); + if (inView.isEmpty) return null; + return inView + .reduce((max, position) => + position.itemLeadingEdge > max.itemLeadingEdge ? position : max) + .index; + } + + Widget _buildScrollToBottom() => StreamBuilder>( + stream: Rx.combineLatest2( + streamChannel!.channel.state!.isUpToDateStream.distinct(), + streamChannel!.channel.state!.unreadCountStream.distinct(), + (bool isUpToDate, int unreadCount) => Tuple2(isUpToDate, unreadCount), + ), + builder: (_, snapshot) { + if (snapshot.hasError) { + return const Offstage(); + } else if (!snapshot.hasData) { + return const Offstage(); + } + final isUpToDate = snapshot.data!.item1; + final showScrollToBottom = !isUpToDate || _showScrollToBottom; + if (!showScrollToBottom) { + return const Offstage(); + } + final unreadCount = snapshot.data!.item2; + final showUnreadCount = unreadCount > 0 && + streamChannel!.channel.state!.members.any((e) => + e.userId == streamChannel!.channel.client.state.user!.id); + return Positioned( + bottom: 8, + right: 8, + width: 40, + height: 40, + child: Stack( + clipBehavior: Clip.none, + children: [ + FloatingActionButton( + backgroundColor: _streamTheme.colorTheme.barsBg, + onPressed: () { + if (unreadCount > 0) { + streamChannel!.channel.markRead(); + } + if (!_upToDate) { + _bottomPaginationActive = false; + _topPaginationActive = false; + streamChannel!.reloadChannel(); + } else { + setState(() => _showScrollToBottom = false); + _scrollController!.scrollTo( + index: 0, + duration: const Duration(seconds: 1), + curve: Curves.easeInOut, + ); + } + }, + child: StreamSvgIcon.down( + color: _streamTheme.colorTheme.textHighEmphasis, + ), ), - ), - if (showUnreadCount) - Positioned( - width: 20, - height: 20, - left: 10, - top: -10, - child: CircleAvatar( - child: Padding( - padding: const EdgeInsets.all(3.0), - child: Text( - '$unreadCount', - style: TextStyle( - fontSize: 11, - fontWeight: FontWeight.bold, + if (showUnreadCount) + Positioned( + width: 20, + height: 20, + left: 10, + top: -10, + child: CircleAvatar( + child: Padding( + padding: const EdgeInsets.all(3), + child: Text( + '$unreadCount', + style: const TextStyle( + fontSize: 11, + fontWeight: FontWeight.bold, + ), ), ), ), ), - ), - ], - ), - ); - }, - ); - } + ], + ), + ); + }, + ); Widget _buildLoadingIndicator( StreamChannelState streamChannel, QueryDirection direction, - ) { - final stream = direction == QueryDirection.top - ? streamChannel.queryTopMessages - : streamChannel.queryBottomMessages; - return StreamBuilder( - key: Key('LOADING-INDICATOR'), - stream: stream, - initialData: false, - builder: (context, snapshot) { - if (snapshot.hasError) { - return Container( - color: StreamChatTheme.of(context) - .colorTheme - .accentRed - .withOpacity(.2), - child: Center( - child: Text('Error loading messages'), - ), - ); - } - if (!snapshot.data) { - if (!_isThreadConversation && direction == QueryDirection.top) { - return Container( - height: 52, - width: double.infinity, - ); - } - return Offstage(); - } - return Center( - child: Padding( - padding: const EdgeInsets.all(8.0), - child: const CircularProgressIndicator(), - ), - ); - }, - ); - } - - Widget _buildTopMessage( - BuildContext context, - Message message, - List messages, - StreamChannelState streamChannel, - ) { - Widget messageWidget; - if (widget.messageBuilder != null) { - messageWidget = Builder( - key: ValueKey('TOP-MESSAGE'), - builder: (_) => widget.messageBuilder( - context, - MessageDetails( - context, - message, - messages, - messages.length - 1, - ), - messages, - ), + ) => + _LoadingIndicator( + direction: direction, + streamTheme: _streamTheme, + streamChannel: streamChannel, + isThreadConversation: _isThreadConversation, ); - } else { - messageWidget = buildMessage(message, messages, messages.length - 1); - } - return messageWidget; - } Widget _buildBottomMessage( BuildContext context, Message message, List messages, StreamChannelState streamChannel, + int index, ) { - Widget messageWidget; - if (widget.messageBuilder != null) { - messageWidget = Builder( - key: ValueKey('BOTTOM-MESSAGE-${message.id}'), - builder: (_) => widget.messageBuilder( - context, - MessageDetails( - context, - message, - messages, - 0, - ), - messages, - ), - ); - } else { - messageWidget = buildMessage(message, messages, 0); - } + final messageWidget = buildMessage(message, messages, index); return VisibilityDetector( key: ValueKey('BOTTOM-MESSAGE-${message.id}'), @@ -777,12 +757,14 @@ class _MessageListViewState extends State { final channel = streamChannel.channel; if (_upToDate && channel.config?.readEvents == true && - channel.state.unreadCount > 0) { + channel.state!.unreadCount > 0) { streamChannel.channel.markRead(); } } if (mounted) { - setState(() => _showScrollToBottom = !isVisible); + if (_showScrollToBottom == isVisible) { + setState(() => _showScrollToBottom = !isVisible); + } } }, child: messageWidget, @@ -792,12 +774,14 @@ class _MessageListViewState extends State { Widget buildParentMessage( Message message, ) { - final isMyMessage = message.user.id == StreamChat.of(context).user.id; - final isOnlyEmoji = message.text.isOnlyEmoji; + final isMyMessage = message.user!.id == StreamChat.of(context).user!.id; + final isOnlyEmoji = message.text!.isOnlyEmoji; + final currentUser = StreamChat.of(context).user; + final members = StreamChannel.of(context).channel.state?.members ?? []; + final currentUserMember = + members.firstWhereOrNull((e) => e.user!.id == currentUser!.id); - return MessageWidget( - showThreadReplyIndicator: false, - showInChannelIndicator: false, + final defaultMessageWidget = MessageWidget( showReplyMessage: false, showResendMessage: false, showThreadReplyMessage: false, @@ -807,44 +791,54 @@ class _MessageListViewState extends State { message: message, reverse: isMyMessage, showUsername: !isMyMessage, - padding: const EdgeInsets.all(8.0), + padding: const EdgeInsets.all(8), showSendingIndicator: false, - onThreadTap: _onThreadTap, borderRadiusGeometry: BorderRadius.only( - topLeft: Radius.circular(16), - bottomLeft: Radius.circular(2), - topRight: Radius.circular(16), - bottomRight: Radius.circular(16), + topLeft: const Radius.circular(16), + bottomLeft: + isMyMessage ? const Radius.circular(16) : const Radius.circular(2), + topRight: const Radius.circular(16), + bottomRight: + isMyMessage ? const Radius.circular(2) : const Radius.circular(16), ), textPadding: EdgeInsets.symmetric( - vertical: 8.0, + vertical: 8, horizontal: isOnlyEmoji ? 0 : 16.0, ), borderSide: isMyMessage || isOnlyEmoji ? BorderSide.none : null, showUserAvatar: isMyMessage ? DisplayWidget.gone : DisplayWidget.show, messageTheme: isMyMessage - ? StreamChatTheme.of(context).ownMessageTheme - : StreamChatTheme.of(context).otherMessageTheme, - onShowMessage: widget.onShowMessage, + ? _streamTheme.ownMessageTheme + : _streamTheme.otherMessageTheme, onReturnAction: (action) { switch (action) { case ReturnActionType.none: break; case ReturnActionType.reply: FocusScope.of(context).unfocus(); - widget.onMessageSwiped(message); + widget.onMessageSwiped?.call(message); break; } }, - customAttachmentBuilders: widget.customAttachmentBuilders, onMessageTap: (message) { if (widget.onMessageTap != null) { - widget.onMessageTap(message); + widget.onMessageTap!(message); } FocusScope.of(context).unfocus(); }, - textBuilder: widget.textBuilder, + showPinButton: currentUserMember != null && + widget.pinPermissions.contains(currentUserMember.role), ); + + if (widget.parentMessageBuilder != null) { + return widget.parentMessageBuilder!.call( + context, + widget.parentMessage, + defaultMessageWidget, + ); + } + + return defaultMessageWidget; } Widget buildMessage( @@ -860,18 +854,18 @@ class _MessageListViewState extends State { message: message, onMessageTap: (message) { if (widget.onSystemMessageTap != null) { - widget.onSystemMessageTap(message); + widget.onSystemMessageTap!(message); } FocusScope.of(context).unfocus(); }, ); } - final userId = StreamChat.of(context).user.id; - final isMyMessage = message.user.id == userId; - final nextMessage = index - 2 >= 0 ? messages[index - 2] : null; + final userId = StreamChat.of(context).user!.id; + final isMyMessage = message.user!.id == userId; + final nextMessage = index - 1 >= 0 ? messages[index - 1] : null; final isNextUserSame = - nextMessage != null && message.user.id == nextMessage.user.id; + nextMessage != null && message.user!.id == nextMessage.user!.id; num timeDiff = 0; if (nextMessage != null) { @@ -881,27 +875,26 @@ class _MessageListViewState extends State { ); } - final channel = streamChannel.channel; + final channel = streamChannel!.channel; final readList = channel.state?.read?.where((read) { if (read.user.id == userId) return false; - return (read.lastRead.isAfter(message.createdAt) || - read.lastRead.isAtSameMomentAs(message.createdAt)); - })?.toList() ?? + return read.lastRead.isAfter(message.createdAt) || + read.lastRead.isAtSameMomentAs(message.createdAt); + }).toList() ?? []; final allRead = readList.length >= (channel.memberCount ?? 0) - 1; final hasFileAttachment = - message.attachments?.any((it) => it.type == 'file') == true; + message.attachments.any((it) => it.type == 'file') == true; final isThreadMessage = - message?.parentId != null && message?.showInChannel == true; + message.parentId != null && message.showInChannel == true; - final hasReplies = message.replyCount > 0; + final hasReplies = message.replyCount! > 0; final attachmentBorderRadius = hasFileAttachment ? 12.0 : 14.0; - final showTimeStamp = message.createdAt != null && - (!isThreadMessage || _isThreadConversation) && + final showTimeStamp = (!isThreadMessage || _isThreadConversation) && !hasReplies && (timeDiff >= 1 || !isNextUserSame); @@ -921,22 +914,27 @@ class _MessageListViewState extends State { final showInChannelIndicator = !_isThreadConversation && isThreadMessage; final showThreadReplyIndicator = !_isThreadConversation && hasReplies; - final isOnlyEmoji = message.text.isOnlyEmoji; + final isOnlyEmoji = message.text!.isOnlyEmoji; final hasUrlAttachment = - message.attachments?.any((it) => it.ogScrapeUrl != null) == true; + message.attachments.any((it) => it.ogScrapeUrl != null) == true; final borderSide = isOnlyEmoji || hasUrlAttachment || (isMyMessage && !hasFileAttachment) ? BorderSide.none : null; - Widget child = MessageWidget( + final currentUser = StreamChat.of(context).user; + final members = StreamChannel.of(context).channel.state?.members ?? []; + final currentUserMember = + members.firstWhere((e) => e.user!.id == currentUser!.id); + + Widget messageWidget = MessageWidget( key: ValueKey('MESSAGE-${message.id}'), message: message, reverse: isMyMessage, showReactions: !message.isDeleted, - padding: const EdgeInsets.symmetric(horizontal: 8.0), + padding: const EdgeInsets.symmetric(horizontal: 8), showInChannelIndicator: showInChannelIndicator, showThreadReplyIndicator: showThreadReplyIndicator, showUsername: showUsername, @@ -944,6 +942,7 @@ class _MessageListViewState extends State { showSendingIndicator: showSendingIndicator, showUserAvatar: showUserAvatar, onQuotedMessageTap: (quotedMessageId) async { + // ignore: prefer_function_declarations_over_variables final scrollToIndex = () { final index = messages.indexWhere((m) => m.id == quotedMessageId); _scrollController?.scrollTo( @@ -954,8 +953,8 @@ class _MessageListViewState extends State { if (messages.map((e) => e.id).contains(quotedMessageId)) { scrollToIndex(); } else { - await streamChannel.loadChannelAtMessage(quotedMessageId).then((_) { - WidgetsBinding.instance.addPostFrameCallback((_) { + await streamChannel!.loadChannelAtMessage(quotedMessageId).then((_) { + WidgetsBinding.instance!.addPostFrameCallback((_) { if (messages.map((e) => e.id).contains(quotedMessageId)) { scrollToIndex(); } @@ -969,74 +968,104 @@ class _MessageListViewState extends State { showFlagButton: !isMyMessage, borderSide: borderSide, onThreadTap: _onThreadTap, - onReplyTap: widget.onReplyTap, attachmentBorderRadiusGeometry: BorderRadius.only( topLeft: Radius.circular(attachmentBorderRadius), - bottomLeft: Radius.circular( - (timeDiff >= 1 || !isNextUserSame) && - !(hasReplies || isThreadMessage || hasFileAttachment) - ? 0 - : attachmentBorderRadius, - ), + bottomLeft: isMyMessage + ? Radius.circular(attachmentBorderRadius) + : Radius.circular( + (timeDiff >= 1 || !isNextUserSame) && + !(hasReplies || isThreadMessage || hasFileAttachment) + ? 0 + : attachmentBorderRadius, + ), topRight: Radius.circular(attachmentBorderRadius), - bottomRight: Radius.circular(attachmentBorderRadius), + bottomRight: isMyMessage + ? Radius.circular( + (timeDiff >= 1 || !isNextUserSame) && + !(hasReplies || isThreadMessage || hasFileAttachment) + ? 0 + : attachmentBorderRadius, + ) + : Radius.circular(attachmentBorderRadius), ), attachmentPadding: EdgeInsets.all(hasFileAttachment ? 4 : 2), borderRadiusGeometry: BorderRadius.only( - topLeft: Radius.circular(16), - bottomLeft: Radius.circular( - (timeDiff >= 1 || !isNextUserSame) && !(hasReplies || isThreadMessage) - ? 0 - : 16, - ), - topRight: Radius.circular(16), - bottomRight: Radius.circular(16), + topLeft: const Radius.circular(16), + bottomLeft: isMyMessage + ? const Radius.circular(16) + : Radius.circular( + (timeDiff >= 1 || !isNextUserSame) && + !(hasReplies || isThreadMessage) + ? 0 + : 16, + ), + topRight: const Radius.circular(16), + bottomRight: isMyMessage + ? Radius.circular( + (timeDiff >= 1 || !isNextUserSame) && + !(hasReplies || isThreadMessage) + ? 0 + : 16, + ) + : const Radius.circular(16), ), textPadding: EdgeInsets.symmetric( - vertical: 8.0, + vertical: 8, horizontal: isOnlyEmoji ? 0 : 16.0, ), messageTheme: isMyMessage - ? StreamChatTheme.of(context).ownMessageTheme - : StreamChatTheme.of(context).otherMessageTheme, + ? _streamTheme.ownMessageTheme + : _streamTheme.otherMessageTheme, readList: readList, allRead: allRead, - onShowMessage: widget.onShowMessage, onReturnAction: (action) { switch (action) { case ReturnActionType.none: break; case ReturnActionType.reply: FocusScope.of(context).unfocus(); - widget.onMessageSwiped(message); + widget.onMessageSwiped?.call(message); break; } }, - customAttachmentBuilders: widget.customAttachmentBuilders, onMessageTap: (message) { if (widget.onMessageTap != null) { - widget.onMessageTap(message); + widget.onMessageTap!(message); } FocusScope.of(context).unfocus(); }, - onAttachmentTap: widget.onAttachmentTap, - textBuilder: widget.textBuilder, + showPinButton: widget.pinPermissions.contains(currentUserMember.role), ); + if (widget.messageBuilder != null) { + messageWidget = widget.messageBuilder!( + context, + MessageDetails( + userId, + message, + messages, + index, + ), + messages, + messageWidget as MessageWidget, + ); + } + + var child = messageWidget; if (!message.isDeleted && !message.isSystem && !message.isEphemeral && widget.onMessageSwiped != null) { child = Container( - decoration: BoxDecoration(), + decoration: const BoxDecoration(), clipBehavior: Clip.hardEdge, child: Swipeable( onSwipeEnd: () { FocusScope.of(context).unfocus(); - widget.onMessageSwiped(message); + widget.onMessageSwiped?.call(message); }, backgroundIcon: StreamSvgIcon.reply( - color: StreamChatTheme.of(context).colorTheme.accentBlue, + color: _streamTheme.colorTheme.accentPrimary, ), child: child, ), @@ -1046,24 +1075,22 @@ class _MessageListViewState extends State { if (!initialMessageHighlightComplete && widget.highlightInitialMessage && _isInitialMessage(message.id)) { - final colorTheme = StreamChatTheme.of(context).colorTheme; + final colorTheme = _streamTheme.colorTheme; final highlightColor = widget.messageHighlightColor ?? colorTheme.highlight; - child = TweenAnimationBuilder( + child = TweenAnimationBuilder( tween: ColorTween( begin: highlightColor, - end: colorTheme.white.withOpacity(0), + end: colorTheme.barsBg.withOpacity(0), ), duration: const Duration(seconds: 3), onEnd: () => initialMessageHighlightComplete = true, - builder: (_, color, child) { - return Container( - color: color, - child: child, - ); - }, + builder: (_, color, child) => Container( + color: color, + child: child, + ), child: Padding( - padding: const EdgeInsets.only(top: 4.0), + padding: const EdgeInsets.only(top: 4), child: child, ), ); @@ -1071,68 +1098,80 @@ class _MessageListViewState extends State { return child; } - StreamSubscription _messageNewListener; + StreamSubscription? _messageNewListener; @override void initState() { _scrollController = widget.scrollController ?? ItemScrollController(); _itemPositionListener = widget.itemPositionListener ?? ItemPositionsListener.create(); - - streamChannel = StreamChannel.of(context); - - initialIndex = _initialIndex; - initialAlignment = _initialAlignment; - - _messageNewListener = - streamChannel.channel.on(EventType.messageNew).listen((event) { - if (_upToDate) { - _bottomPaginationActive = false; - _topPaginationActive = false; - } - if (event.message.user.id == streamChannel.channel.client.state.user.id) { - WidgetsBinding.instance.addPostFrameCallback((_) { - _scrollController?.jumpTo( - index: 0, - ); - }); - } - }); - - if (_isThreadConversation) { - streamChannel.getReplies(widget.parentMessage.id); - } + _itemPositionStream = + _valueListenableToStreamAdapter(_itemPositionListener.itemPositions); _getOnThreadTap(); super.initState(); } + @override + void didChangeDependencies() { + final newStreamChannel = StreamChannel.of(context); + _streamTheme = StreamChatTheme.of(context); + + if (newStreamChannel != streamChannel) { + streamChannel = newStreamChannel; + _messageNewListener?.cancel(); + initialIndex = _initialIndex; + initialAlignment = _initialAlignment; + + _messageNewListener = + streamChannel!.channel.on(EventType.messageNew).listen((event) { + if (_upToDate) { + _bottomPaginationActive = false; + _topPaginationActive = false; + } + if (event.message!.user!.id == + streamChannel!.channel.client.state.user!.id) { + WidgetsBinding.instance!.addPostFrameCallback((_) { + _scrollController?.jumpTo( + index: 0, + ); + }); + } + }); + + if (_isThreadConversation) { + streamChannel!.getReplies(widget.parentMessage!.id); + } + } + + super.didChangeDependencies(); + } + void _getOnThreadTap() { if (widget.onThreadTap != null) { _onThreadTap = (Message message) { - widget.onThreadTap( + widget.onThreadTap!( message, widget.threadBuilder != null - ? widget.threadBuilder(context, message) + ? widget.threadBuilder!(context, message) : null); }; } else if (widget.threadBuilder != null) { _onThreadTap = (Message message) { Navigator.push( context, - MaterialPageRoute(builder: (_) { - return StreamBuilder( - stream: streamChannel.channel.state.messagesStream.map( - (messages) => - messages.firstWhere((m) => m.id == message.id)), - initialData: message, - builder: (_, snapshot) { - return StreamChannel( - channel: streamChannel.channel, - child: widget.threadBuilder(context, snapshot.data), - ); - }); - }), + MaterialPageRoute( + builder: (_) => BetterStreamBuilder( + stream: streamChannel!.channel.state!.messagesStream.map( + (messages) => + messages!.firstWhere((m) => m.id == message.id)), + initialData: message, + builder: (_, data) => StreamChannel( + channel: streamChannel!.channel, + child: widget.threadBuilder!(context, data), + ), + ), + ), ); }; } @@ -1141,9 +1180,77 @@ class _MessageListViewState extends State { @override void dispose() { if (!_upToDate) { - streamChannel.reloadChannel(); + streamChannel!.reloadChannel(); } _messageNewListener?.cancel(); super.dispose(); } } + +class _LoadingIndicator extends StatelessWidget { + const _LoadingIndicator({ + Key? key, + required this.streamTheme, + required this.isThreadConversation, + required this.direction, + required this.streamChannel, + }) : super(key: key); + + final StreamChatThemeData streamTheme; + final bool isThreadConversation; + final QueryDirection direction; + final StreamChannelState streamChannel; + + @override + Widget build(BuildContext context) { + final stream = direction == QueryDirection.top + ? streamChannel.queryTopMessages + : streamChannel.queryBottomMessages; + return BetterStreamBuilder( + key: Key('LOADING-INDICATOR $direction'), + stream: stream, + initialData: false, + errorBuilder: (context, error) => Container( + color: streamTheme.colorTheme.accentError.withOpacity(.2), + child: const Center( + child: Text('Error loading messages'), + ), + ), + builder: (context, data) { + if (!data) return const Offstage(); + return const Center( + child: Padding( + padding: EdgeInsets.all(8), + child: CircularProgressIndicator(), + ), + ); + }, + ); + } +} + +Stream _valueListenableToStreamAdapter(ValueListenable listenable) { + // ignore: close_sinks + late StreamController _controller; + + void listener() { + _controller.add(listenable.value); + } + + void start() { + listenable.addListener(listener); + } + + void end() { + listenable.removeListener(listener); + } + + _controller = StreamController( + onListen: start, + onPause: end, + onResume: start, + onCancel: end, + ); + + return _controller.stream; +} diff --git a/packages/stream_chat_flutter/lib/src/message_reactions_modal.dart b/packages/stream_chat_flutter/lib/src/message_reactions_modal.dart index c2a13ae7..3d9a907e 100644 --- a/packages/stream_chat_flutter/lib/src/message_reactions_modal.dart +++ b/packages/stream_chat_flutter/lib/src/message_reactions_modal.dart @@ -8,48 +8,47 @@ import 'package:stream_chat_flutter/src/user_avatar.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart'; -import 'extension.dart'; -import 'message_widget.dart'; -import 'stream_chat_theme.dart'; - +/// Modal widget for displaying message reactions class MessageReactionsModal extends StatelessWidget { - final Widget Function(BuildContext, Message) editMessageInputBuilder; - final void Function(Message) onThreadTap; - final Message message; - final MessageTheme messageTheme; - final bool reverse; - final bool showReactions; - final DisplayWidget showUserAvatar; - final ShapeBorder messageShape; - final ShapeBorder attachmentShape; - final void Function(User) onUserAvatarTap; - final BorderRadius attachmentBorderRadiusGeometry; - + /// Constructor for creating a [MessageReactionsModal] reactions const MessageReactionsModal({ - Key key, - @required this.message, - @required this.messageTheme, + Key? key, + required this.message, + required this.messageWidget, + required this.messageTheme, this.showReactions = true, - this.onThreadTap, - this.editMessageInputBuilder, - this.messageShape, - this.attachmentShape, this.reverse = false, - this.showUserAvatar = DisplayWidget.show, this.onUserAvatarTap, - this.attachmentBorderRadiusGeometry, }) : super(key: key); + /// Widget that shows the message + final Widget messageWidget; + + /// Message to display reactions of + final Message message; + + /// [MessageTheme] to apply to [message] + final MessageTheme messageTheme; + + /// Flag to reverse message + final bool reverse; + + /// Flag to show reactions on message + final bool showReactions; + + /// Callback when user avatar is tapped + final void Function(User)? onUserAvatarTap; + @override Widget build(BuildContext context) { final size = MediaQuery.of(context).size; final user = StreamChat.of(context).user; final roughMaxSize = 2 * size.width / 3; - var messageTextLength = message.text.length; + var messageTextLength = message.text!.length; if (message.quotedMessage != null) { - var quotedMessageLength = message.quotedMessage.text.length + 40; - if (message.quotedMessage.attachments?.isNotEmpty == true) { + var quotedMessageLength = message.quotedMessage!.text!.length + 40; + if (message.quotedMessage!.attachments.isNotEmpty == true) { quotedMessageLength += 40; } if (quotedMessageLength > messageTextLength) { @@ -57,120 +56,93 @@ class MessageReactionsModal extends StatelessWidget { } } final roughSentenceSize = - messageTextLength * messageTheme.messageText.fontSize * 1.2; - final divFactor = message.attachments?.isNotEmpty == true + messageTextLength * (messageTheme.messageText?.fontSize ?? 1) * 1.2; + final divFactor = message.attachments.isNotEmpty == true ? 1 : (roughSentenceSize == 0 ? 1 : (roughSentenceSize / roughMaxSize)); - return TweenAnimationBuilder( - tween: Tween(begin: 0.0, end: 1.0), - duration: Duration(milliseconds: 300), - curve: Curves.easeInOutBack, - builder: (context, val, snapshot) { - final hasFileAttachment = - message.attachments?.any((it) => it.type == 'file') == true; - return GestureDetector( - behavior: HitTestBehavior.translucent, - onTap: () => Navigator.maybePop(context), - child: Stack( - children: [ - Positioned.fill( - child: BackdropFilter( - filter: ImageFilter.blur( - sigmaX: 10, - sigmaY: 10, - ), - child: Container( - color: StreamChatTheme.of(context).colorTheme.overlay, + final numberOfReactions = StreamChatTheme.of(context).reactionIcons.length; + final shiftFactor = + numberOfReactions < 5 ? (5 - numberOfReactions) * 0.1 : 0.0; + + final child = Center( + child: SingleChildScrollView( + child: Padding( + padding: const EdgeInsets.all(8), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (showReactions && + (message.status == MessageSendingStatus.sent)) + Align( + alignment: Alignment( + user!.id == message.user!.id + ? (divFactor >= 1.0 + ? -0.2 - shiftFactor + : (1.2 - divFactor)) + : (divFactor >= 1.0 + ? 0.2 + shiftFactor + : -(1.2 - divFactor)), + 0), + child: ReactionPicker( + message: message, ), ), + const SizedBox(height: 8), + IgnorePointer( + child: messageWidget, ), - Transform.scale( - scale: val, - child: Center( - child: SingleChildScrollView( - child: Padding( - padding: const EdgeInsets.all(8.0), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - if (showReactions && - (message.status == MessageSendingStatus.sent || - message.status == null)) - Align( - alignment: Alignment( - user.id == message.user.id - ? (divFactor > 1.0 - ? 0.0 - : (1.0 - divFactor)) - : (divFactor > 1.0 - ? 0.0 - : -(1.0 - divFactor)), - 0.0), - child: ReactionPicker( - message: message, - messageTheme: messageTheme, - ), - ), - const SizedBox(height: 8), - IgnorePointer( - child: MessageWidget( - key: Key('MessageWidget'), - reverse: reverse, - message: message.copyWith( - text: message.text.length > 200 - ? '${message.text.substring(0, 200)}...' - : message.text, - ), - messageTheme: messageTheme, - showReactions: false, - showUsername: false, - showUserAvatar: showUserAvatar, - showThreadReplyIndicator: false, - showTimestamp: false, - translateUserAvatar: false, - showSendingIndicator: false, - shape: messageShape, - attachmentShape: attachmentShape, - padding: const EdgeInsets.all(0), - attachmentBorderRadiusGeometry: - attachmentBorderRadiusGeometry, - attachmentPadding: EdgeInsets.all( - hasFileAttachment ? 4 : 2, - ), - showInChannelIndicator: false, - textPadding: EdgeInsets.symmetric( - vertical: 8.0, - horizontal: message.text.isOnlyEmoji ? 0 : 16.0, - ), - showReactionPickerIndicator: showReactions && - (message.status == - MessageSendingStatus.sent || - message.status == null), - ), - ), - if (message.latestReactions?.isNotEmpty == true) ...[ - const SizedBox(height: 8), - _buildReactionCard(context), - ] - ], - ), - ), - ), + if (message.latestReactions?.isNotEmpty == true) ...[ + const SizedBox(height: 8), + _buildReactionCard( + context, + user, ), - ), + ] ], ), - ); - }, + ), + ), + ); + + return GestureDetector( + behavior: HitTestBehavior.translucent, + onTap: () => Navigator.maybePop(context), + child: Stack( + children: [ + Positioned.fill( + child: BackdropFilter( + filter: ImageFilter.blur( + sigmaX: 10, + sigmaY: 10, + ), + child: DecoratedBox( + decoration: BoxDecoration( + color: StreamChatTheme.of(context).colorTheme.overlay, + ), + ), + ), + ), + TweenAnimationBuilder( + tween: Tween(begin: 0, end: 1), + duration: const Duration(milliseconds: 300), + curve: Curves.easeInOutBack, + builder: (context, val, widget) => Transform.scale( + scale: val, + child: widget, + ), + child: child, + ), + ], + ), ); } - Widget _buildReactionCard(BuildContext context) { - final currentUser = StreamChat.of(context).user; + Widget _buildReactionCard(BuildContext context, User? user) { + final chatThemeData = StreamChatTheme.of(context); return Card( - color: StreamChatTheme.of(context).colorTheme.white, + color: chatThemeData.colorTheme.barsBg, clipBehavior: Clip.hardEdge, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(16), @@ -183,7 +155,7 @@ class MessageReactionsModal extends StatelessWidget { children: [ Text( 'Message Reactions', - style: StreamChatTheme.of(context).textTheme.headlineBold, + style: chatThemeData.textTheme.headlineBold, ), const SizedBox(height: 16), Flexible( @@ -191,11 +163,10 @@ class MessageReactionsModal extends StatelessWidget { child: Wrap( spacing: 16, runSpacing: 16, - alignment: WrapAlignment.start, - children: message.latestReactions + children: message.latestReactions! .map((e) => _buildReaction( e, - currentUser, + user!, context, )) .toList(), @@ -213,28 +184,27 @@ class MessageReactionsModal extends StatelessWidget { User currentUser, BuildContext context, ) { - final isCurrentUser = reaction.user.id == currentUser.id; + final isCurrentUser = reaction.user?.id == currentUser.id; + final chatThemeData = StreamChatTheme.of(context); return ConstrainedBox( - constraints: BoxConstraints.loose(Size( + constraints: BoxConstraints.loose(const Size( 64, 98, )), child: Column( mainAxisSize: MainAxisSize.min, - mainAxisAlignment: MainAxisAlignment.start, - crossAxisAlignment: CrossAxisAlignment.center, children: [ Stack( clipBehavior: Clip.none, children: [ UserAvatar( onTap: onUserAvatarTap, - user: reaction.user, - constraints: BoxConstraints.tightFor( + user: reaction.user!, + constraints: const BoxConstraints.tightFor( height: 64, width: 64, ), - onlineIndicatorConstraints: BoxConstraints.tightFor( + onlineIndicatorConstraints: const BoxConstraints.tightFor( height: 12, width: 12, ), @@ -250,9 +220,11 @@ class MessageReactionsModal extends StatelessWidget { child: ReactionBubble( reactions: [reaction], flipTail: !reverse, - borderColor: messageTheme.reactionsBorderColor, - backgroundColor: messageTheme.reactionsBackgroundColor, - maskColor: StreamChatTheme.of(context).colorTheme.white, + borderColor: + messageTheme.reactionsBorderColor ?? Colors.transparent, + backgroundColor: messageTheme.reactionsBackgroundColor ?? + Colors.transparent, + maskColor: chatThemeData.colorTheme.barsBg, tailCirclesSpacing: 1, highlightOwnReactions: false, ), @@ -262,8 +234,8 @@ class MessageReactionsModal extends StatelessWidget { ), const SizedBox(height: 8), Text( - reaction.user.name.split(' ')[0], - style: StreamChatTheme.of(context).textTheme.footnoteBold, + reaction.user!.name.split(' ')[0], + style: chatThemeData.textTheme.footnoteBold, textAlign: TextAlign.center, ), ], diff --git a/packages/stream_chat_flutter/lib/src/message_search_item.dart b/packages/stream_chat_flutter/lib/src/message_search_item.dart index 4cf7b5ed..1f8a31ea 100644 --- a/packages/stream_chat_flutter/lib/src/message_search_item.dart +++ b/packages/stream_chat_flutter/lib/src/message_search_item.dart @@ -5,15 +5,17 @@ import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart'; /// It shows the current [Message] preview. /// -/// Usually you don't use this widget as it's the default item used by [MessageSearchListView]. +/// Usually you don't use this widget as it's the default item used by +/// [MessageSearchListView]. /// -/// The widget renders the ui based on the first ancestor of type [StreamChatTheme]. +/// The widget renders the ui based on the first ancestor of type +/// [StreamChatTheme]. /// Modify it to change the widget appearance. class MessageSearchItem extends StatelessWidget { /// Instantiate a new MessageSearchItem const MessageSearchItem({ - Key key, - @required this.getMessageResponse, + Key? key, + required this.getMessageResponse, this.onTap, this.showOnlineStatus = true, }) : super(key: key); @@ -22,7 +24,7 @@ class MessageSearchItem extends StatelessWidget { final GetMessageResponse getMessageResponse; /// Function called when tapping this widget - final VoidCallback onTap; + final VoidCallback? onTap; /// If true the [MessageSearchItem] will show the current online Status final bool showOnlineStatus; @@ -31,14 +33,15 @@ class MessageSearchItem extends StatelessWidget { Widget build(BuildContext context) { final message = getMessageResponse.message; final channel = getMessageResponse.channel; - final channelName = channel.extraData['name']; - final user = message.user; + final channelName = channel?.extraData['name']; + final user = message.user!; + final chatThemeData = StreamChatTheme.of(context); return ListTile( onTap: onTap, leading: UserAvatar( user: user, showOnlineStatus: showOnlineStatus, - constraints: BoxConstraints.tightFor( + constraints: const BoxConstraints.tightFor( height: 40, width: 40, ), @@ -46,22 +49,19 @@ class MessageSearchItem extends StatelessWidget { title: Row( children: [ Text( - user.id == StreamChat.of(context).user.id ? 'You' : user.name, - style: StreamChatTheme.of(context).channelPreviewTheme.title, + user.id == StreamChat.of(context).user?.id ? 'You' : user.name, + style: chatThemeData.channelPreviewTheme.title, ), if (channelName != null) ...[ Text( ' in ', - style: StreamChatTheme.of(context) - .channelPreviewTheme - .title - .copyWith( - fontWeight: FontWeight.normal, - ), + style: chatThemeData.channelPreviewTheme.title?.copyWith( + fontWeight: FontWeight.normal, + ), ), Text( - channelName, - style: StreamChatTheme.of(context).channelPreviewTheme.title, + channelName as String, + style: chatThemeData.channelPreviewTheme.title, ), ], ], @@ -69,7 +69,7 @@ class MessageSearchItem extends StatelessWidget { subtitle: Row( children: [ Expanded(child: _buildSubtitle(context, message)), - SizedBox(width: 16), + const SizedBox(width: 16), _buildDate(context, message), ], ), @@ -96,14 +96,10 @@ class MessageSearchItem extends StatelessWidget { } Widget _buildSubtitle(BuildContext context, Message message) { - if (message == null) { - return SizedBox(); - } - var text = message.text; if (message.isDeleted) { text = 'This message was deleted.'; - } else if (message.attachments != null) { + } else if (message.attachments.isNotEmpty) { final parts = [ ...message.attachments.map((e) { if (e.type == 'image') { @@ -116,29 +112,30 @@ class MessageSearchItem extends StatelessWidget { return e == message.attachments.last ? (e.title ?? 'File') : '${e.title ?? 'File'} , '; - }).where((e) => e != null), + }), message.text ?? '', ]; text = parts.join(' '); } + final chatThemeData = StreamChatTheme.of(context); return Text.rich( _getDisplayText( - text, + text!, message.mentionedUsers, message.attachments, - StreamChatTheme.of(context).channelPreviewTheme.subtitle.copyWith( - fontStyle: (message.isSystem || message.isDeleted) - ? FontStyle.italic - : FontStyle.normal, - ), - StreamChatTheme.of(context).channelPreviewTheme.subtitle.copyWith( - fontStyle: (message.isSystem || message.isDeleted) - ? FontStyle.italic - : FontStyle.normal, - fontWeight: FontWeight.bold, - ), + chatThemeData.channelPreviewTheme.subtitle?.copyWith( + fontStyle: (message.isSystem || message.isDeleted) + ? FontStyle.italic + : FontStyle.normal, + ), + chatThemeData.channelPreviewTheme.subtitle?.copyWith( + fontStyle: (message.isSystem || message.isDeleted) + ? FontStyle.italic + : FontStyle.normal, + fontWeight: FontWeight.bold, + ), ), maxLines: 1, overflow: TextOverflow.ellipsis, @@ -149,30 +146,28 @@ class MessageSearchItem extends StatelessWidget { String text, List mentions, List attachments, - TextStyle normalTextStyle, - TextStyle mentionsTextStyle) { - var textList = text.split(' '); - var resList = []; - for (var e in textList) { - if (mentions != null && - mentions.isNotEmpty && + TextStyle? normalTextStyle, + TextStyle? mentionsTextStyle) { + final textList = text.split(' '); + final resList = []; + for (final e in textList) { + if (mentions.isNotEmpty && mentions.any((element) => '@${element.name}' == e)) { resList.add(TextSpan( text: '$e ', style: mentionsTextStyle, )); - } else if (attachments != null && - attachments.isNotEmpty && + } else if (attachments.isNotEmpty && attachments .where((e) => e.title != null) .any((element) => element.title == e)) { resList.add(TextSpan( text: '$e ', - style: normalTextStyle.copyWith(fontStyle: FontStyle.italic), + style: normalTextStyle?.copyWith(fontStyle: FontStyle.italic), )); } else { resList.add(TextSpan( - text: e == textList.last ? '$e' : '$e ', + text: e == textList.last ? e : '$e ', style: normalTextStyle, )); } diff --git a/packages/stream_chat_flutter/lib/src/message_search_list_view.dart b/packages/stream_chat_flutter/lib/src/message_search_list_view.dart index 6431a311..f364d0f2 100644 --- a/packages/stream_chat_flutter/lib/src/message_search_list_view.dart +++ b/packages/stream_chat_flutter/lib/src/message_search_list_view.dart @@ -1,20 +1,23 @@ import 'package:flutter/material.dart'; import 'package:stream_chat_flutter/src/info_tile.dart'; import 'package:stream_chat_flutter/src/message_search_item.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart'; -import '../stream_chat_flutter.dart'; - /// Callback called when tapping on a user typedef MessageSearchItemTapCallback = void Function(GetMessageResponse); /// Builder used to create a custom [ListUserItem] from a [User] typedef MessageSearchItemBuilder = Widget Function( - BuildContext, GetMessageResponse); + BuildContext, + GetMessageResponse, +); /// Builder used when [MessageSearchListView] is empty typedef EmptyMessageSearchBuilder = Widget Function( - BuildContext context, String searchQuery); + BuildContext context, + String searchQuery, +); /// /// It shows the list of searched messages. @@ -39,17 +42,19 @@ typedef EmptyMessageSearchBuilder = Widget Function( /// ``` /// /// -/// Make sure to have a [MessageSearchBloc] ancestor in order to provide the information about the messages. +/// Make sure to have a [MessageSearchBloc] ancestor in order to provide the +/// information about the messages. /// The widget uses a [ListView.separated] to render the list of messages. /// -/// The widget components render the ui based on the first ancestor of type [StreamChatTheme]. +/// The widget components render the ui based on the first ancestor of type +/// [StreamChatTheme]. /// Modify it to change the widget appearance. class MessageSearchListView extends StatefulWidget { /// Instantiate a new MessageSearchListView const MessageSearchListView({ - Key key, + Key? key, + required this.filters, this.messageQuery, - this.filters, this.sortOptions, this.paginationParams, this.messageFilters, @@ -63,41 +68,44 @@ class MessageSearchListView extends StatefulWidget { this.errorBuilder, this.loadingBuilder, this.childBuilder, + this.messageSearchListController, }) : super(key: key); /// Message String to search on - final String messageQuery; + final String? messageQuery; /// The query filters to use. /// You can query on any of the custom fields you've defined on the [Channel]. /// You can also filter other built-in channel fields. - final Map filters; + final Filter filters; /// The sorting used for the channels matching the filters. - /// Sorting is based on field and direction, multiple sorting options can be provided. - /// You can sort based on last_updated, last_message_at, updated_at, created_at or member_count. + /// Sorting is based on field and direction, multiple sorting options + /// can be provided. + /// You can sort based on last_updated, last_message_at, updated_at, + /// created_at or member_count. /// Direction can be ascending or descending. - final List sortOptions; + final List? sortOptions; /// Pagination parameters /// limit: the number of users to return (max is 30) /// offset: the offset (max is 1000) /// message_limit: how many messages should be included to each channel - final PaginationParams paginationParams; + final PaginationParams? paginationParams; /// The message query filters to use. /// You can query on any of the custom fields you've defined on the [Channel]. /// You can also filter other built-in channel fields. - final Map messageFilters; + final Filter? messageFilters; /// Builder used to create a custom item preview - final MessageSearchItemBuilder itemBuilder; + final MessageSearchItemBuilder? itemBuilder; /// Function called when tapping on a [MessageSearchItem] - final MessageSearchItemTapCallback onItemTap; + final MessageSearchItemTapCallback? onItemTap; /// Builder used to create a custom item separator - final IndexedWidgetBuilder separatorBuilder; + final IndexedWidgetBuilder? separatorBuilder; /// Set it to false to hide total results text final bool showResultCount; @@ -105,108 +113,103 @@ class MessageSearchListView extends StatefulWidget { /// Set it to false to disable the pull-to-refresh widget final bool pullToRefresh; + /// Show error tile on top final bool showErrorTile; /// The builder that is used when the search messages are fetched - final Widget Function(List) childBuilder; + final Widget Function(List)? childBuilder; /// The builder used when the channel list is empty. - final WidgetBuilder emptyBuilder; + final WidgetBuilder? emptyBuilder; /// The builder that will be used in case of error - final ErrorBuilder errorBuilder; + final ErrorBuilder? errorBuilder; /// The builder that will be used in case of loading - final WidgetBuilder loadingBuilder; + final WidgetBuilder? loadingBuilder; + + /// A [MessageSearchListController] allows reloading and pagination. + /// Use [MessageSearchListController.loadData] and + /// [MessageSearchListController.paginateData] respectively for reloading and + /// pagination. + final MessageSearchListController? messageSearchListController; @override _MessageSearchListViewState createState() => _MessageSearchListViewState(); } class _MessageSearchListViewState extends State { - final MessageSearchListController _messageSearchListController = - MessageSearchListController(); + late final _defaultController = MessageSearchListController(); + MessageSearchListController get _messageSearchListController => + widget.messageSearchListController ?? _defaultController; @override - Widget build(BuildContext context) { - return MessageSearchListCore( - filters: widget.filters, - sortOptions: widget.sortOptions, - messageQuery: widget.messageQuery, - paginationParams: widget.paginationParams, - messageFilters: widget.messageFilters, - messageSearchListController: _messageSearchListController, - emptyBuilder: widget.emptyBuilder ?? - (context) { - return LayoutBuilder( - builder: (context, viewportConstraints) { - return SingleChildScrollView( - physics: AlwaysScrollableScrollPhysics(), - child: ConstrainedBox( - constraints: BoxConstraints( - minHeight: viewportConstraints.maxHeight, - ), - child: Center( - child: Text('There are no messages currently'), + Widget build(BuildContext context) => MessageSearchListCore( + filters: widget.filters, + sortOptions: widget.sortOptions, + messageQuery: widget.messageQuery, + paginationParams: widget.paginationParams, + messageFilters: widget.messageFilters, + messageSearchListController: _messageSearchListController, + emptyBuilder: widget.emptyBuilder ?? + (context) => LayoutBuilder( + builder: (context, viewportConstraints) => + SingleChildScrollView( + physics: const AlwaysScrollableScrollPhysics(), + child: ConstrainedBox( + constraints: BoxConstraints( + minHeight: viewportConstraints.maxHeight, + ), + child: const Center( + child: Text('There are no messages currently'), + ), ), ), - ); - }, - ); - }, - errorBuilder: widget.errorBuilder ?? - (BuildContext context, dynamic error) { - if (error is Error) { - print(error.stackTrace); - } - return InfoTile( - showMessage: widget.showErrorTile, - tileAnchor: Alignment.topCenter, - childAnchor: Alignment.topCenter, - message: 'An error occurred.', - child: Container(), - ); - }, - loadingBuilder: widget.loadingBuilder ?? - (context) { - return LayoutBuilder( - builder: (context, viewportConstraints) { - return SingleChildScrollView( - physics: AlwaysScrollableScrollPhysics(), - child: ConstrainedBox( - constraints: BoxConstraints( - minHeight: viewportConstraints.maxHeight, - ), - child: Center( - child: CircularProgressIndicator(), + ), + errorBuilder: widget.errorBuilder ?? + (BuildContext context, dynamic error) { + if (error is Error) { + print(error.stackTrace); + } + return InfoTile( + showMessage: widget.showErrorTile, + tileAnchor: Alignment.topCenter, + childAnchor: Alignment.topCenter, + message: 'An error occurred.', + child: Container(), + ); + }, + loadingBuilder: widget.loadingBuilder ?? + (context) => LayoutBuilder( + builder: (context, viewportConstraints) => + SingleChildScrollView( + physics: const AlwaysScrollableScrollPhysics(), + child: ConstrainedBox( + constraints: BoxConstraints( + minHeight: viewportConstraints.maxHeight, + ), + child: const Center( + child: CircularProgressIndicator(), + ), ), ), - ); - }, - ); - }, - childBuilder: widget.childBuilder ?? - (list) { - return _buildListView(list); - }, - ); - } + ), + childBuilder: widget.childBuilder ?? _buildListView, + ); - Widget _separatorBuilder(BuildContext context, int index) { - return Container( - height: 1, - color: StreamChatTheme.of(context).colorTheme.greyWhisper, - ); - } + Widget _separatorBuilder(BuildContext context, int index) => Container( + height: 1, + color: StreamChatTheme.of(context).colorTheme.borders, + ); Widget _listItemBuilder( BuildContext context, GetMessageResponse getMessageResponse) { if (widget.itemBuilder != null) { - return widget.itemBuilder(context, getMessageResponse); + return widget.itemBuilder!(context, getMessageResponse); } return MessageSearchItem( getMessageResponse: getMessageResponse, - onTap: () => widget.onItemTap(getMessageResponse), + onTap: () => widget.onItemTap!(getMessageResponse), ); } @@ -221,10 +224,10 @@ class _MessageSearchListViewState extends State { return Container( color: StreamChatTheme.of(context) .colorTheme - .accentRed + .accentError .withOpacity(.2), - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 16.0), + child: const Padding( + padding: EdgeInsets.symmetric(vertical: 16), child: Center( child: Text('Error loading messages'), ), @@ -233,9 +236,11 @@ class _MessageSearchListViewState extends State { } return Container( height: 100, - padding: EdgeInsets.all(32), + padding: const EdgeInsets.all(32), child: Center( - child: snapshot.data ? CircularProgressIndicator() : Container(), + child: snapshot.data! + ? const CircularProgressIndicator() + : Container(), ), ); }); @@ -245,11 +250,11 @@ class _MessageSearchListViewState extends State { final items = data; Widget child = ListView.separated( - physics: AlwaysScrollableScrollPhysics(), + physics: const AlwaysScrollableScrollPhysics(), itemCount: items.isNotEmpty ? items.length + 1 : items.length, separatorBuilder: (_, index) { if (widget.separatorBuilder != null) { - return widget.separatorBuilder(context, index); + return widget.separatorBuilder!(context, index); } return _separatorBuilder(context, index); }, @@ -262,23 +267,24 @@ class _MessageSearchListViewState extends State { ); if (widget.pullToRefresh) { child = RefreshIndicator( - onRefresh: () => _messageSearchListController.loadData(), + onRefresh: () => _messageSearchListController.loadData!(), child: child, ); } child = LazyLoadScrollView( - onEndOfPage: () => _messageSearchListController.paginateData(), + onEndOfPage: () => _messageSearchListController.paginateData!(), child: child, ); if (widget.showResultCount) { + final chatThemeData = StreamChatTheme.of(context); child = Column( children: [ Container( width: double.maxFinite, decoration: BoxDecoration( - gradient: StreamChatTheme.of(context).colorTheme.bgGradient, + gradient: chatThemeData.colorTheme.bgGradient, ), child: Padding( padding: const EdgeInsets.symmetric( @@ -288,7 +294,7 @@ class _MessageSearchListViewState extends State { child: Text( '${items.length} results', style: TextStyle( - color: StreamChatTheme.of(context).colorTheme.grey, + color: chatThemeData.colorTheme.textLowEmphasis, ), ), ), diff --git a/packages/stream_chat_flutter/lib/src/message_text.dart b/packages/stream_chat_flutter/lib/src/message_text.dart index 3afa60f9..6d006085 100644 --- a/packages/stream_chat_flutter/lib/src/message_text.dart +++ b/packages/stream_chat_flutter/lib/src/message_text.dart @@ -1,63 +1,74 @@ +import 'package:collection/collection.dart' show IterableExtension; import 'package:flutter/material.dart'; import 'package:flutter_markdown/flutter_markdown.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart'; -import 'stream_chat_theme.dart'; -import 'utils.dart'; - +/// Text widget to display in message class MessageText extends StatelessWidget { + /// Constructor for creating a [MessageText] widget const MessageText({ - Key key, - @required this.message, - @required this.messageTheme, + Key? key, + required this.message, + required this.messageTheme, this.onMentionTap, this.onLinkTap, }) : super(key: key); + /// Message whose text is to be displayed final Message message; - final void Function(User) onMentionTap; - final void Function(String) onLinkTap; + + /// Callback for when mention is tapped + final void Function(User)? onMentionTap; + + /// Callback for when link is tapped + final void Function(String)? onLinkTap; + + /// [MessageTheme] whose text theme is to be applied final MessageTheme messageTheme; @override Widget build(BuildContext context) { - final text = _replaceMentions(message.text).replaceAll('\n', '\\\n'); + final text = _replaceMentions(message.text ?? '').replaceAll('\n', '\n\n'); + final themeData = Theme.of(context); return MarkdownBody( data: text, onTapLink: ( String link, - String href, + String? href, String title, ) { if (link.startsWith('@')) { - final mentionedUser = message.mentionedUsers.firstWhere( + final mentionedUser = message.mentionedUsers.firstWhereOrNull( (u) => '@${u.name}' == link, - orElse: () => null, ); + if (mentionedUser == null) { + return; + } if (onMentionTap != null) { - onMentionTap(mentionedUser); + onMentionTap!(mentionedUser); } else { print('tap on ${mentionedUser.name}'); } } else { if (onLinkTap != null) { - onLinkTap(link); + onLinkTap!(link); } else { launchURL(context, link); } } }, styleSheet: MarkdownStyleSheet.fromTheme( - Theme.of(context).copyWith( - textTheme: Theme.of(context).textTheme.apply( - bodyColor: messageTheme.messageText.color, - decoration: messageTheme.messageText.decoration, - decorationColor: messageTheme.messageText.decorationColor, - decorationStyle: messageTheme.messageText.decorationStyle, - fontFamily: messageTheme.messageText.fontFamily, - ), + themeData.copyWith( + textTheme: themeData.textTheme.apply( + bodyColor: messageTheme.messageText?.color, + decoration: messageTheme.messageText?.decoration, + decorationColor: messageTheme.messageText?.decorationColor, + decorationStyle: messageTheme.messageText?.decorationStyle, + fontFamily: messageTheme.messageText?.fontFamily, + ), ), ).copyWith( a: messageTheme.messageLinks, @@ -67,7 +78,8 @@ class MessageText extends StatelessWidget { } String _replaceMentions(String text) { - message.mentionedUsers?.map((u) => u.name)?.toSet()?.forEach((userName) { + message.mentionedUsers.map((u) => u.name).toSet().forEach((userName) { + // ignore: parameter_assignments text = text.replaceAll( '@$userName', '[@$userName](@${userName.replaceAll(' ', '')})'); }); diff --git a/packages/stream_chat_flutter/lib/src/message_widget.dart b/packages/stream_chat_flutter/lib/src/message_widget.dart index af6ea1ec..c9dfd9d4 100644 --- a/packages/stream_chat_flutter/lib/src/message_widget.dart +++ b/packages/stream_chat_flutter/lib/src/message_widget.dart @@ -1,12 +1,14 @@ -import 'dart:math'; import 'dart:ui'; import 'package:flutter/cupertino.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_portal/flutter_portal.dart'; import 'package:jiffy/jiffy.dart'; +import 'package:stream_chat_flutter/src/extension.dart'; +import 'package:stream_chat_flutter/src/image_group.dart'; import 'package:stream_chat_flutter/src/message_action.dart'; import 'package:stream_chat_flutter/src/message_actions_modal.dart'; import 'package:stream_chat_flutter/src/message_reactions_modal.dart'; @@ -15,17 +17,15 @@ import 'package:stream_chat_flutter/src/reaction_bubble.dart'; import 'package:stream_chat_flutter/src/url_attachment.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; -import 'attachment/attachment.dart'; -import 'extension.dart'; -import 'image_group.dart'; -import 'message_text.dart'; - +/// Widget builder for building attachments typedef AttachmentBuilder = Widget Function( BuildContext, Message, List, ); -typedef OnQuotedMessageTap = void Function(String); + +/// Callback for when quoted message is tapped +typedef OnQuotedMessageTap = void Function(String?); /// The display behaviour of a widget enum DisplayWidget { @@ -44,124 +44,18 @@ enum DisplayWidget { /// /// It shows a message with reactions, replies and user avatar. /// -/// Usually you don't use this widget as it's the default message widget used by [MessageListView]. +/// Usually you don't use this widget as it's the default message widget used by +/// [MessageListView]. /// -/// The widget components render the ui based on the first ancestor of type [StreamChatTheme]. +/// The widget components render the ui based on the first ancestor of type +/// [StreamChatTheme]. /// Modify it to change the widget appearance. class MessageWidget extends StatefulWidget { - /// Function called on mention tap - final void Function(User) onMentionTap; - - /// The function called when tapping on replies - final void Function(Message) onThreadTap; - final void Function(Message) onReplyTap; - final Widget Function(BuildContext, Message) editMessageInputBuilder; - final Widget Function(BuildContext, Message) textBuilder; - - /// Function called on long press - final void Function(BuildContext, Message) onMessageActions; - - /// The message - final Message message; - - /// The message theme - final MessageTheme messageTheme; - - /// If true the widget will be mirrored - final bool reverse; - - /// The shape of the message text - final ShapeBorder shape; - - /// The shape of an attachment - final ShapeBorder attachmentShape; - - /// The borderside of the message text - final BorderSide borderSide; - - /// The borderside of an attachment - final BorderSide attachmentBorderSide; - - /// The border radius of the message text - final BorderRadiusGeometry borderRadiusGeometry; - - /// The border radius of an attachment - final BorderRadiusGeometry attachmentBorderRadiusGeometry; - - /// The padding of the widget - final EdgeInsetsGeometry padding; - - /// The internal padding of the message text - final EdgeInsetsGeometry textPadding; - - /// The internal padding of an attachment - final EdgeInsetsGeometry attachmentPadding; - - /// It controls the display behaviour of the user avatar - final DisplayWidget showUserAvatar; - - /// It controls the display behaviour of the sending indicator - final bool showSendingIndicator; - - /// If true the widget will show the reactions - final bool showReactions; - - final bool allRead; - - /// If true the widget will show the thread reply indicator - final bool showThreadReplyIndicator; - - /// If true the widget will show the show in channel indicator - final bool showInChannelIndicator; - - /// The function called when tapping on UserAvatar - final void Function(User) onUserAvatarTap; - - /// The function called when tapping on a link - final void Function(String) onLinkTap; - - /// Used in [MessageReactionsModal] and [MessageActionsModal] - final bool showReactionPickerIndicator; - - final List readList; - - final ShowMessageCallback onShowMessage; - final ValueChanged onReturnAction; - - /// If true show the users username next to the timestamp of the message - final bool showUsername; - final bool showTimestamp; - - final bool showReplyMessage; - final bool showThreadReplyMessage; - final bool showEditMessage; - final bool showCopyMessage; - final bool showDeleteMessage; - final bool showResendMessage; - - final bool showFlagButton; - final Map attachmentBuilders; - - /// Center user avatar with bottom of the message - final bool translateUserAvatar; - - /// Function called when quotedMessage is tapped - final OnQuotedMessageTap onQuotedMessageTap; - - /// Function called when message is tapped - final void Function(Message) onMessageTap; - - /// List of custom actions shown on message long tap - final List customActions; - - // Customize onTap on attachment - final void Function(Message message, Attachment attachment) onAttachmentTap; - /// MessageWidget({ - Key key, - @required this.message, - @required this.messageTheme, + Key? key, + required this.message, + required this.messageTheme, this.reverse = false, this.translateUserAvatar = true, this.shape, @@ -189,32 +83,36 @@ class MessageWidget extends StatefulWidget { this.showResendMessage = true, this.showCopyMessage = true, this.showFlagButton = true, + this.showPinButton = true, + this.showPinHighlight = true, this.onUserAvatarTap, this.onLinkTap, this.onMessageActions, this.onShowMessage, + this.userAvatarBuilder, this.editMessageInputBuilder, this.textBuilder, this.onReturnAction, - Map customAttachmentBuilders, + Map? customAttachmentBuilders, this.readList, this.padding, this.textPadding = const EdgeInsets.symmetric( - horizontal: 16.0, - vertical: 8.0, + horizontal: 16, + vertical: 8, ), this.attachmentPadding = EdgeInsets.zero, this.allRead = false, this.onQuotedMessageTap, this.customActions = const [], this.onAttachmentTap, + this.usernameBuilder, }) : attachmentBuilders = { 'image': (context, message, attachments) { - var border = RoundedRectangleBorder( - side: BorderSide.none, + final border = RoundedRectangleBorder( borderRadius: attachmentBorderRadiusGeometry ?? BorderRadius.zero, ); + final mediaQueryData = MediaQuery.of(context); if (attachments.length > 1) { return Padding( padding: attachmentPadding, @@ -224,18 +122,18 @@ class MessageWidget extends StatefulWidget { color: messageTheme.messageBackgroundColor, child: ImageGroup( size: Size( - MediaQuery.of(context).size.width * 0.8, - MediaQuery.of(context).size.height * 0.3, + mediaQueryData.size.width * 0.8, + mediaQueryData.size.height * 0.3, ), images: attachments, message: message, messageTheme: messageTheme, onShowMessage: onShowMessage, + onReturnAction: onReturnAction, ), ), border, reverse, - attachmentBorderRadiusGeometry ?? BorderRadius.zero, ), ); } @@ -247,25 +145,23 @@ class MessageWidget extends StatefulWidget { message: message, messageTheme: messageTheme, size: Size( - MediaQuery.of(context).size.width * 0.8, - MediaQuery.of(context).size.height * 0.3, + mediaQueryData.size.width * 0.8, + mediaQueryData.size.height * 0.3, ), onShowMessage: onShowMessage, onReturnAction: onReturnAction, onAttachmentTap: onAttachmentTap != null ? () { - onAttachmentTap?.call(message, attachments[0]); + onAttachmentTap.call(message, attachments[0]); } : null, ), border, reverse, - attachmentBorderRadiusGeometry ?? BorderRadius.zero, ); }, 'video': (context, message, attachments) { - var border = RoundedRectangleBorder( - side: BorderSide.none, + final border = RoundedRectangleBorder( borderRadius: attachmentBorderRadiusGeometry ?? BorderRadius.zero, ); @@ -273,19 +169,20 @@ class MessageWidget extends StatefulWidget { context, Column( children: attachments.map((attachment) { + final mediaQueryData = MediaQuery.of(context); return VideoAttachment( attachment: attachment, messageTheme: messageTheme, size: Size( - MediaQuery.of(context).size.width * 0.8, - MediaQuery.of(context).size.height * 0.3, + mediaQueryData.size.width * 0.8, + mediaQueryData.size.height * 0.3, ), message: message, onShowMessage: onShowMessage, onReturnAction: onReturnAction, onAttachmentTap: onAttachmentTap != null ? () { - onAttachmentTap?.call(message, attachment); + onAttachmentTap(message, attachment); } : null, ); @@ -293,12 +190,10 @@ class MessageWidget extends StatefulWidget { ), border, reverse, - attachmentBorderRadiusGeometry ?? BorderRadius.zero, ); }, 'giphy': (context, message, attachments) { - var border = RoundedRectangleBorder( - side: BorderSide.none, + final border = RoundedRectangleBorder( borderRadius: attachmentBorderRadiusGeometry ?? BorderRadius.zero, ); @@ -306,13 +201,13 @@ class MessageWidget extends StatefulWidget { context, Column( children: attachments.map((attachment) { + final mediaQueryData = MediaQuery.of(context); return GiphyAttachment( attachment: attachment, - messageTheme: messageTheme, message: message, size: Size( - MediaQuery.of(context).size.width * 0.8, - MediaQuery.of(context).size.height * 0.3, + mediaQueryData.size.width * 0.8, + mediaQueryData.size.height * 0.3, ), onShowMessage: onShowMessage, onReturnAction: onReturnAction, @@ -321,14 +216,13 @@ class MessageWidget extends StatefulWidget { ), border, reverse, - attachmentBorderRadiusGeometry ?? BorderRadius.zero, ); }, 'file': (context, message, attachments) { - var border = RoundedRectangleBorder( + final border = RoundedRectangleBorder( side: attachmentBorderSide ?? BorderSide( - color: StreamChatTheme.of(context).colorTheme.greyWhisper, + color: StreamChatTheme.of(context).colorTheme.borders, ), borderRadius: attachmentBorderRadiusGeometry ?? BorderRadius.zero, ); @@ -336,19 +230,19 @@ class MessageWidget extends StatefulWidget { return Column( children: attachments .map((attachment) { + final mediaQueryData = MediaQuery.of(context); return wrapAttachmentWidget( context, FileAttachment( message: message, attachment: attachment, size: Size( - MediaQuery.of(context).size.width * 0.8, - MediaQuery.of(context).size.height * 0.3, + mediaQueryData.size.width * 0.8, + mediaQueryData.size.height * 0.3, ), ), border, reverse, - attachmentBorderRadiusGeometry ?? BorderRadius.zero, ); }) .insertBetween(SizedBox( @@ -360,6 +254,266 @@ class MessageWidget extends StatefulWidget { }..addAll(customAttachmentBuilders ?? {}), super(key: key); + /// Function called on mention tap + final void Function(User)? onMentionTap; + + /// The function called when tapping on threads + final void Function(Message)? onThreadTap; + + /// The function called when tapping on replies + final void Function(Message)? onReplyTap; + + /// Widget builder for edit message layout + final Widget Function(BuildContext, Message)? editMessageInputBuilder; + + /// Widget builder for building text + final Widget Function(BuildContext, Message)? textBuilder; + + /// Widget builder for building username + final Widget Function(BuildContext, Message)? usernameBuilder; + + /// Function called on long press + final void Function(BuildContext, Message)? onMessageActions; + + /// Widget builder for building user avatar + final Widget Function(BuildContext, User)? userAvatarBuilder; + + /// The message + final Message message; + + /// The message theme + final MessageTheme messageTheme; + + /// If true the widget will be mirrored + final bool reverse; + + /// The shape of the message text + final ShapeBorder? shape; + + /// The shape of an attachment + final ShapeBorder? attachmentShape; + + /// The borderside of the message text + final BorderSide? borderSide; + + /// The borderside of an attachment + final BorderSide? attachmentBorderSide; + + /// The border radius of the message text + final BorderRadiusGeometry? borderRadiusGeometry; + + /// The border radius of an attachment + final BorderRadiusGeometry? attachmentBorderRadiusGeometry; + + /// The padding of the widget + final EdgeInsetsGeometry? padding; + + /// The internal padding of the message text + final EdgeInsets textPadding; + + /// The internal padding of an attachment + final EdgeInsetsGeometry attachmentPadding; + + /// It controls the display behaviour of the user avatar + final DisplayWidget showUserAvatar; + + /// It controls the display behaviour of the sending indicator + final bool showSendingIndicator; + + /// If true the widget will show the reactions + final bool showReactions; + + /// + final bool allRead; + + /// If true the widget will show the thread reply indicator + final bool showThreadReplyIndicator; + + /// If true the widget will show the show in channel indicator + final bool showInChannelIndicator; + + /// The function called when tapping on UserAvatar + final void Function(User)? onUserAvatarTap; + + /// The function called when tapping on a link + final void Function(String)? onLinkTap; + + /// Used in [MessageReactionsModal] and [MessageActionsModal] + final bool showReactionPickerIndicator; + + /// List of users who read + final List? readList; + + /// Callback when show message is tapped + final ShowMessageCallback? onShowMessage; + + /// Handle return actions like reply message + final ValueChanged? onReturnAction; + + /// If true show the users username next to the timestamp of the message + final bool showUsername; + + /// Show message timestamp + final bool showTimestamp; + + /// Show reply action + final bool showReplyMessage; + + /// Show thread reply action + final bool showThreadReplyMessage; + + /// Show edit action + final bool showEditMessage; + + /// Show copy action + final bool showCopyMessage; + + /// Show delete action + final bool showDeleteMessage; + + /// Show resend action + final bool showResendMessage; + + /// Show flag action + final bool showFlagButton; + + /// Show flag action + final bool showPinButton; + + /// Display Pin Highlight + final bool showPinHighlight; + + /// Builder for respective attachment types + final Map attachmentBuilders; + + /// Center user avatar with bottom of the message + final bool translateUserAvatar; + + /// Function called when quotedMessage is tapped + final OnQuotedMessageTap? onQuotedMessageTap; + + /// Function called when message is tapped + final void Function(Message)? onMessageTap; + + /// List of custom actions shown on message long tap + final List customActions; + + /// Customize onTap on attachment + final void Function(Message message, Attachment attachment)? onAttachmentTap; + + /// Creates a copy of [MessageWidget] with specified attributes overridden. + MessageWidget copyWith({ + Key? key, + void Function(User)? onMentionTap, + void Function(Message)? onThreadTap, + void Function(Message)? onReplyTap, + Widget Function(BuildContext, Message)? editMessageInputBuilder, + Widget Function(BuildContext, Message)? textBuilder, + Widget Function(BuildContext, Message)? usernameBuilder, + void Function(BuildContext, Message)? onMessageActions, + Message? message, + MessageTheme? messageTheme, + bool? reverse, + ShapeBorder? shape, + ShapeBorder? attachmentShape, + BorderSide? borderSide, + BorderSide? attachmentBorderSide, + BorderRadiusGeometry? borderRadiusGeometry, + BorderRadiusGeometry? attachmentBorderRadiusGeometry, + EdgeInsetsGeometry? padding, + EdgeInsets? textPadding, + EdgeInsetsGeometry? attachmentPadding, + DisplayWidget? showUserAvatar, + bool? showSendingIndicator, + bool? showReactions, + bool? allRead, + bool? showThreadReplyIndicator, + bool? showInChannelIndicator, + void Function(User)? onUserAvatarTap, + void Function(String)? onLinkTap, + bool? showReactionPickerIndicator, + List? readList, + ShowMessageCallback? onShowMessage, + ValueChanged? onReturnAction, + bool? showUsername, + bool? showTimestamp, + bool? showReplyMessage, + bool? showThreadReplyMessage, + bool? showEditMessage, + bool? showCopyMessage, + bool? showDeleteMessage, + bool? showResendMessage, + bool? showFlagButton, + bool? showPinButton, + bool? showPinHighlight, + Map? customAttachmentBuilders, + bool? translateUserAvatar, + OnQuotedMessageTap? onQuotedMessageTap, + void Function(Message)? onMessageTap, + List? customActions, + void Function(Message message, Attachment attachment)? onAttachmentTap, + Widget Function(BuildContext, User)? userAvatarBuilder, + }) => + MessageWidget( + key: key ?? this.key, + onMentionTap: onMentionTap ?? this.onMentionTap, + onThreadTap: onThreadTap ?? this.onThreadTap, + onReplyTap: onReplyTap ?? this.onReplyTap, + editMessageInputBuilder: + editMessageInputBuilder ?? this.editMessageInputBuilder, + textBuilder: textBuilder ?? this.textBuilder, + usernameBuilder: usernameBuilder ?? this.usernameBuilder, + onMessageActions: onMessageActions ?? this.onMessageActions, + message: message ?? this.message, + messageTheme: messageTheme ?? this.messageTheme, + reverse: reverse ?? this.reverse, + shape: shape ?? this.shape, + attachmentShape: attachmentShape ?? this.attachmentShape, + borderSide: borderSide ?? this.borderSide, + attachmentBorderSide: attachmentBorderSide ?? this.attachmentBorderSide, + borderRadiusGeometry: borderRadiusGeometry ?? this.borderRadiusGeometry, + attachmentBorderRadiusGeometry: attachmentBorderRadiusGeometry ?? + this.attachmentBorderRadiusGeometry, + padding: padding ?? this.padding, + textPadding: textPadding ?? this.textPadding, + attachmentPadding: attachmentPadding ?? this.attachmentPadding, + showUserAvatar: showUserAvatar ?? this.showUserAvatar, + showSendingIndicator: showSendingIndicator ?? this.showSendingIndicator, + showReactions: showReactions ?? this.showReactions, + allRead: allRead ?? this.allRead, + showThreadReplyIndicator: + showThreadReplyIndicator ?? this.showThreadReplyIndicator, + showInChannelIndicator: + showInChannelIndicator ?? this.showInChannelIndicator, + onUserAvatarTap: onUserAvatarTap ?? this.onUserAvatarTap, + onLinkTap: onLinkTap ?? this.onLinkTap, + showReactionPickerIndicator: + showReactionPickerIndicator ?? this.showReactionPickerIndicator, + readList: readList ?? this.readList, + onShowMessage: onShowMessage ?? this.onShowMessage, + onReturnAction: onReturnAction ?? this.onReturnAction, + showUsername: showUsername ?? this.showUsername, + showTimestamp: showTimestamp ?? this.showTimestamp, + showReplyMessage: showReplyMessage ?? this.showReplyMessage, + showThreadReplyMessage: + showThreadReplyMessage ?? this.showThreadReplyMessage, + showEditMessage: showEditMessage ?? this.showEditMessage, + showCopyMessage: showCopyMessage ?? this.showCopyMessage, + showDeleteMessage: showDeleteMessage ?? this.showDeleteMessage, + showResendMessage: showResendMessage ?? this.showResendMessage, + showFlagButton: showFlagButton ?? this.showFlagButton, + showPinButton: showPinButton ?? this.showPinButton, + showPinHighlight: showPinHighlight ?? this.showPinHighlight, + customAttachmentBuilders: + customAttachmentBuilders ?? attachmentBuilders, + translateUserAvatar: translateUserAvatar ?? this.translateUserAvatar, + onQuotedMessageTap: onQuotedMessageTap ?? this.onQuotedMessageTap, + onMessageTap: onMessageTap ?? this.onMessageTap, + customActions: customActions ?? this.customActions, + onAttachmentTap: onAttachmentTap ?? this.onAttachmentTap, + userAvatarBuilder: userAvatarBuilder ?? this.userAvatarBuilder, + ); + @override _MessageWidgetState createState() => _MessageWidgetState(); } @@ -380,7 +534,7 @@ class _MessageWidgetState extends State bool get showInChannel => widget.showInChannelIndicator; - bool get hasQuotedMessage => widget.message?.quotedMessage != null; + bool get hasQuotedMessage => widget.message.quotedMessage != null; bool get isSendFailed => widget.message.status == MessageSendingStatus.failed; @@ -393,17 +547,17 @@ class _MessageWidgetState extends State bool get isFailedState => isSendFailed || isUpdateFailed || isDeleteFailed; bool get isGiphy => - widget.message.attachments?.any((element) => element.type == 'giphy') == + widget.message.attachments.any((element) => element.type == 'giphy') == true; - bool get hasNonUrlAttachments => - widget.message.attachments - ?.where((it) => it.ogScrapeUrl == null) - ?.isNotEmpty == - true; + bool get isOnlyEmoji => widget.message.text?.isOnlyEmoji == true; + + bool get hasNonUrlAttachments => widget.message.attachments + .where((it) => it.ogScrapeUrl == null) + .isNotEmpty; bool get hasUrlAttachments => - widget.message.attachments?.any((it) => it.ogScrapeUrl != null) == true; + widget.message.attachments.any((it) => it.ogScrapeUrl != null) == true; bool get showBottomRow => showThreadReplyIndicator || @@ -414,68 +568,94 @@ class _MessageWidgetState extends State isDeleted; @override - bool get wantKeepAlive => widget.message.attachments?.isNotEmpty == true; + bool get wantKeepAlive => widget.message.attachments.isNotEmpty == true; + + late StreamChatThemeData _streamChatTheme; + late StreamChatState _streamChat; @override Widget build(BuildContext context) { super.build(context); - final avatarWidth = widget.messageTheme.avatarTheme.constraints.maxWidth; - var leftPadding = + final avatarWidth = + widget.messageTheme.avatarTheme?.constraints.maxWidth ?? 40; + final bottomRowPadding = widget.showUserAvatar != DisplayWidget.gone ? avatarWidth + 8.5 : 0.5; return Material( - type: MaterialType.transparency, + type: widget.message.pinned && widget.showPinHighlight + ? MaterialType.card + : MaterialType.transparency, + color: widget.message.pinned && widget.showPinHighlight + ? _streamChatTheme.colorTheme.highlight + : null, child: Portal( child: InkWell( onTap: () { - widget.onMessageTap(widget.message); + widget.onMessageTap!(widget.message); }, onLongPress: widget.message.isDeleted && !isFailedState ? null : () => onLongPress(context), child: Padding( - padding: widget.padding ?? EdgeInsets.all(8), - child: Transform( - alignment: Alignment.center, - transform: Matrix4.rotationY(widget.reverse ? pi : 0), - child: FractionallySizedBox( - alignment: Alignment.centerLeft, - widthFactor: 0.75, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - Stack( - clipBehavior: Clip.none, - alignment: AlignmentDirectional.bottomStart, - children: [ - Column( - crossAxisAlignment: CrossAxisAlignment.start, + padding: widget.padding ?? const EdgeInsets.all(8), + child: FractionallySizedBox( + alignment: + widget.reverse ? Alignment.centerRight : Alignment.centerLeft, + widthFactor: 0.78, + child: Column( + crossAxisAlignment: widget.reverse + ? CrossAxisAlignment.end + : CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Stack( + clipBehavior: Clip.none, + alignment: widget.reverse + ? AlignmentDirectional.bottomEnd + : AlignmentDirectional.bottomStart, + children: [ + Padding( + padding: EdgeInsets.only( + bottom: + isPinned && widget.showPinHighlight ? 8.0 : 0.0, + ), + child: Column( + crossAxisAlignment: widget.reverse + ? CrossAxisAlignment.end + : CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ + if (widget.message.pinned && + widget.message.pinnedBy != null && + widget.showPinHighlight) + _buildPinnedMessage(widget.message), Row( - mainAxisAlignment: MainAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.end, mainAxisSize: MainAxisSize.min, children: [ - if (widget.showUserAvatar == - DisplayWidget.show) ...[ + if (!widget.reverse && + widget.showUserAvatar == + DisplayWidget.show && + widget.message.user != null) ...[ _buildUserAvatar(), - SizedBox(width: 4), + const SizedBox(width: 4), ], if (widget.showUserAvatar == DisplayWidget.hide) SizedBox(width: avatarWidth + 4), Flexible( child: PortalEntry( portal: Container( - transform: - Matrix4.translationValues(-12, 0, 0), - constraints: - BoxConstraints(maxWidth: 22 * 6.0), + transform: Matrix4.translationValues( + widget.reverse ? 12 : -12, 0, 0), + constraints: const BoxConstraints( + maxWidth: 22 * 6.0, + ), child: _buildReactionIndicator(context), ), - portalAnchor: Alignment(-1.0, -1.0), - childAnchor: Alignment(1, -1.0), + portalAnchor: + Alignment(widget.reverse ? 1 : -1, -1), + childAnchor: + Alignment(widget.reverse ? -1 : 1, -1), child: Stack( clipBehavior: Clip.none, children: [ @@ -493,37 +673,34 @@ class _MessageWidgetState extends State : EdgeInsets.zero, child: (widget.message.isDeleted && !isFailedState) - ? Transform( - alignment: Alignment.center, - transform: Matrix4.rotationY( - widget.reverse ? pi : 0), - child: Container( - margin: EdgeInsets.symmetric( - horizontal: - widget.showUserAvatar == - DisplayWidget - .gone - ? 0 - : 4.0), - child: DeletedMessage( - reverse: widget.reverse, - borderRadiusGeometry: widget - .borderRadiusGeometry, - borderSide: - widget.borderSide, - shape: widget.shape, - messageTheme: - widget.messageTheme, - ), + ? Container( + // ignore: lines_longer_than_80_chars + margin: EdgeInsets.symmetric( + horizontal: + // ignore: lines_longer_than_80_chars + widget.showUserAvatar == + // ignore: lines_longer_than_80_chars + DisplayWidget.gone + ? 0 + : 4.0), + child: DeletedMessage( + borderRadiusGeometry: widget + .borderRadiusGeometry, + borderSide: + widget.borderSide, + shape: widget.shape, + messageTheme: + widget.messageTheme, ), ) : Card( - clipBehavior: Clip.antiAlias, - elevation: 0.0, + clipBehavior: Clip.hardEdge, + elevation: 0, margin: EdgeInsets.symmetric( horizontal: (isFailedState ? 15.0 : 0.0) + + // ignore: lines_longer_than_80_chars (widget.showUserAvatar == DisplayWidget .gone @@ -532,14 +709,18 @@ class _MessageWidgetState extends State ), shape: widget.shape ?? RoundedRectangleBorder( - side: - widget.borderSide ?? - BorderSide( - color: widget + side: widget + .borderSide ?? + BorderSide( + color: widget + // ignore: lines_longer_than_80_chars .messageTheme - .messageBorderColor, - ), + // ignore: lines_longer_than_80_chars + .messageBorderColor ?? + Colors.grey, + ), borderRadius: widget + // ignore: lines_longer_than_80_chars .borderRadiusGeometry ?? BorderRadius.zero, ), @@ -562,20 +743,16 @@ class _MessageWidgetState extends State ), if (widget.showReactionPickerIndicator) Positioned( - right: 4, + right: widget.reverse ? null : 4, + left: widget.reverse ? 4 : null, top: -8, - child: Transform( - transform: Matrix4.rotationY( - widget.reverse ? pi : 0), - child: CustomPaint( - painter: ReactionBubblePainter( - StreamChatTheme.of(context) - .colorTheme - .white, - Colors.transparent, - Colors.transparent, - tailCirclesSpace: 1, - ), + child: CustomPaint( + painter: ReactionBubblePainter( + _streamChatTheme + .colorTheme.barsBg, + Colors.transparent, + Colors.transparent, + tailCirclesSpace: 1, ), ), ), @@ -583,28 +760,40 @@ class _MessageWidgetState extends State ), ), ), + if (widget.reverse && + widget.showUserAvatar == + DisplayWidget.show && + widget.message.user != null) ...[ + _buildUserAvatar(), + const SizedBox(width: 4), + ] ], ), if (showBottomRow) SizedBox(height: context.textScaleFactor * 18.0), ], ), - if (showBottomRow) - Padding( - padding: EdgeInsets.only(left: leftPadding), - child: _bottomRow, + ), + if (showBottomRow) + Padding( + padding: EdgeInsets.only( + left: !widget.reverse ? bottomRowPadding : 0, + right: widget.reverse ? bottomRowPadding : 0, + bottom: + isPinned && widget.showPinHighlight ? 6.0 : 0.0, ), - if (isFailedState) - Positioned( - left: widget.reverse ? 0 : null, - right: widget.reverse ? null : 0, - bottom: showBottomRow ? 18 : -2, - child: StreamSvgIcon.error(size: 20), - ), - ], - ), - ], - ), + child: _bottomRow, + ), + if (isFailedState) + Positioned( + left: widget.reverse ? 0 : null, + right: widget.reverse ? null : 0, + bottom: showBottomRow ? 18 : -2, + child: StreamSvgIcon.error(size: 20), + ), + ], + ), + ], ), ), ), @@ -613,19 +802,26 @@ class _MessageWidgetState extends State ); } + @override + void didChangeDependencies() { + _streamChatTheme = StreamChatTheme.of(context); + _streamChat = StreamChat.of(context); + super.didChangeDependencies(); + } + Widget _buildQuotedMessage() { - final isMyMessage = - widget.message.user.id == StreamChat.of(context).user.id; - final onTap = widget.message?.quotedMessage?.isDeleted != true && + final isMyMessage = widget.message.user?.id == _streamChat.user?.id; + final onTap = widget.message.quotedMessage?.isDeleted != true && widget.onQuotedMessageTap != null - ? () => widget.onQuotedMessageTap(widget.message.quotedMessageId) + ? () => widget.onQuotedMessageTap!(widget.message.quotedMessageId) : null; + final chatThemeData = _streamChatTheme; return QuotedMessageWidget( onTap: onTap, - message: widget.message.quotedMessage, + message: widget.message.quotedMessage!, messageTheme: isMyMessage - ? StreamChatTheme.of(context).otherMessageTheme - : StreamChatTheme.of(context).ownMessageTheme, + ? chatThemeData.otherMessageTheme + : chatThemeData.ownMessageTheme, reverse: widget.reverse, padding: EdgeInsets.only( right: 8, left: 8, top: 8, bottom: hasNonUrlAttachments ? 8 : 0), @@ -634,51 +830,48 @@ class _MessageWidgetState extends State Widget get _bottomRow { if (isDeleted) { - return Transform( - transform: Matrix4.rotationY(widget.reverse ? pi : 0), - alignment: Alignment.center, - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - StreamSvgIcon.eye( - color: StreamChatTheme.of(context).colorTheme.grey, - size: 16.0, - ), - SizedBox(width: 8.0), - Text( - 'Only visible to you', - style: StreamChatTheme.of(context) - .textTheme - .footnote - .copyWith(color: StreamChatTheme.of(context).colorTheme.grey), - ), - ], - ), + final chatThemeData = _streamChatTheme; + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + StreamSvgIcon.eye( + color: chatThemeData.colorTheme.textLowEmphasis, + size: 16, + ), + const SizedBox(width: 8), + Text( + 'Only visible to you', + style: chatThemeData.textTheme.footnote + .copyWith(color: chatThemeData.colorTheme.textLowEmphasis), + ), + ], ); } - var children = []; + final children = []; - final threadParticipants = widget.message?.threadParticipants?.take(2); + final threadParticipants = widget.message.threadParticipants?.take(2); final showThreadParticipants = threadParticipants?.isNotEmpty == true; final replyCount = widget.message.replyCount; var msg = 'Thread Reply'; - if (showThreadReplyIndicator && replyCount > 1) { + if (showThreadReplyIndicator && replyCount! > 1) { msg = '$replyCount Thread Replies'; } + // ignore: prefer_function_declarations_over_variables final onThreadTap = () async { try { var message = widget.message; if (showInChannel) { final channel = StreamChannel.of(context); - message = await channel.getMessage(widget.message.parentId); + message = await channel.getMessage(widget.message.parentId!); } - return widget.onThreadTap(message); + return widget.onThreadTap!(message); } catch (e, stk) { print(e); print(stk); + // ignore: avoid_returning_null_for_void return null; } }; @@ -689,22 +882,15 @@ class _MessageWidgetState extends State if (showInChannel || showThreadReplyIndicator) ...[ if (showThreadParticipants) SizedBox.fromSize( - size: Size((threadParticipants.length * 8.0) + 8, 16), + size: Size((threadParticipants!.length * 8.0) + 8, 16), child: _buildThreadParticipantsIndicator(threadParticipants), ), InkWell( onTap: widget.onThreadTap != null ? onThreadTap : null, - child: Text(msg, style: widget.messageTheme?.replies), + child: Text(msg, style: widget.messageTheme.replies), ), ], - if (showUsername) - Text( - widget.message.user.name, - maxLines: 1, - key: usernameKey, - style: widget.messageTheme.messageAuthor, - overflow: TextOverflow.ellipsis, - ), + if (showUsername) _buildUsername(usernameKey), if (showTimeStamp) Text( Jiffy(widget.message.createdAt.toLocal()).jm, @@ -718,30 +904,29 @@ class _MessageWidgetState extends State return Row( crossAxisAlignment: CrossAxisAlignment.end, + mainAxisAlignment: + widget.reverse ? MainAxisAlignment.end : MainAxisAlignment.start, children: [ - if (showThreadTail) + if (showThreadTail && !widget.reverse) Container( margin: EdgeInsets.only( bottom: context.textScaleFactor * - (widget.messageTheme.replies.fontSize / 2), + ((widget.messageTheme.replies?.fontSize ?? 1) / 2), ), child: CustomPaint( - size: Size(16, 32) * context.textScaleFactor, + size: const Size(16, 32) * context.textScaleFactor, painter: _ThreadReplyPainter( context: context, color: widget.messageTheme.messageBorderColor, + reverse: widget.reverse, ), ), ), ...children.map( (child) { - Widget mappedChild = Transform( - transform: Matrix4.rotationY(widget.reverse ? pi : 0), - alignment: Alignment.center, - child: Container( - height: context.textScaleFactor * 14, - child: child, - ), + Widget mappedChild = SizedBox( + height: context.textScaleFactor * 14, + child: child, ); if (child.key == usernameKey) { mappedChild = Flexible(child: mappedChild); @@ -749,18 +934,46 @@ class _MessageWidgetState extends State return mappedChild; }, ), - ].insertBetween(const SizedBox(width: 8.0)), + if (showThreadTail && widget.reverse) + Container( + margin: EdgeInsets.only( + bottom: context.textScaleFactor * + ((widget.messageTheme.replies?.fontSize ?? 1) / 2), + ), + child: CustomPaint( + size: const Size(16, 32) * context.textScaleFactor, + painter: _ThreadReplyPainter( + context: context, + color: widget.messageTheme.messageBorderColor, + reverse: widget.reverse, + ), + ), + ), + ].insertBetween(const SizedBox(width: 8)), + ); + } + + Widget _buildUsername(Key usernameKey) { + if (widget.usernameBuilder != null) { + return widget.usernameBuilder!(context, widget.message); + } + return Text( + widget.message.user!.name, + maxLines: 1, + key: usernameKey, + style: widget.messageTheme.messageAuthor, + overflow: TextOverflow.ellipsis, ); } Widget _buildUrlAttachment() { - var urlAttachment = widget.message.attachments + final urlAttachment = widget.message.attachments .firstWhere((element) => element.ogScrapeUrl != null); - var host = Uri.parse(urlAttachment.ogScrapeUrl).host; - var splitList = host.split('.'); - var hostName = splitList.length == 3 ? splitList[1] : splitList[0]; - var hostDisplayName = urlAttachment.authorName?.capitalize() ?? + final host = Uri.parse(urlAttachment.ogScrapeUrl!).host; + final splitList = host.split('.'); + final hostName = splitList.length == 3 ? splitList[1] : splitList[0]; + final hostDisplayName = urlAttachment.authorName?.capitalize() ?? getWebsiteName(hostName.toLowerCase()) ?? hostName.capitalize(); @@ -771,47 +984,28 @@ class _MessageWidgetState extends State ); } - Widget _buildThreadParticipantsIndicator(Iterable threadParticipants) { - var padding = 0.0; - return Stack( - children: threadParticipants.map((user) { - padding += 8.0; - return Positioned( - right: padding - 8, - bottom: 0, - top: 0, - child: Container( - decoration: BoxDecoration( - shape: BoxShape.circle, - color: StreamChatTheme.of(context).colorTheme.white, - ), - padding: const EdgeInsets.all(1), - child: UserAvatar( - user: user, - constraints: BoxConstraints.loose(Size.fromRadius(7)), - showOnlineStatus: false, - ), - ), - ); - }).toList(), - ); - } + Widget _buildThreadParticipantsIndicator(Iterable threadParticipants) => + _ThreadParticipants( + streamChatTheme: _streamChatTheme, + threadParticipants: threadParticipants, + ); Widget _buildReactionIndicator( BuildContext context, ) { - final ownId = StreamChat.of(context).user.id; + final ownId = _streamChat.user!.id; final reactionsMap = {}; widget.message.latestReactions?.forEach((element) { - if (!reactionsMap.containsKey(element.type) || element.user.id == ownId) { + if (!reactionsMap.containsKey(element.type) || + element.user!.id == ownId) { reactionsMap[element.type] = element; } }); final reactionsList = reactionsMap.values.toList() - ..sort((a, b) => a.user.id == ownId ? 1 : -1); + ..sort((a, b) => a.user!.id == ownId ? 1 : -1); return AnimatedSwitcher( - duration: Duration(milliseconds: 300), + duration: const Duration(milliseconds: 300), child: (widget.showReactions && (widget.message.reactionCounts?.isNotEmpty == true) && !widget.message.isDeleted) @@ -821,13 +1015,16 @@ class _MessageWidgetState extends State key: ValueKey('${widget.message.id}.reactions'), reverse: widget.reverse, flipTail: widget.reverse, - backgroundColor: widget.messageTheme.reactionsBackgroundColor, - borderColor: widget.messageTheme.reactionsBorderColor, - maskColor: widget.messageTheme.reactionsMaskColor, + backgroundColor: widget.messageTheme.reactionsBackgroundColor ?? + Colors.transparent, + borderColor: widget.messageTheme.reactionsBorderColor ?? + Colors.transparent, + maskColor: widget.messageTheme.reactionsMaskColor ?? + Colors.transparent, reactions: reactionsList, ), ) - : SizedBox(), + : const SizedBox(), ); } @@ -835,103 +1032,103 @@ class _MessageWidgetState extends State final channel = StreamChannel.of(context).channel; showDialog( + useRootNavigator: false, context: context, - barrierColor: StreamChatTheme.of(context).colorTheme.overlay, - builder: (context) { - return StreamChannel( - channel: channel, - child: MessageActionsModal( - attachmentBorderRadiusGeometry: - widget.attachmentBorderRadiusGeometry, - showUserAvatar: - widget.message.user.id == channel.client.state.user.id - ? DisplayWidget.gone - : DisplayWidget.show, - messageTheme: widget.messageTheme, - messageShape: widget.shape ?? _getDefaultShape(context), - attachmentShape: - widget.attachmentShape ?? _getDefaultAttachmentShape(context), - reverse: widget.reverse, - showDeleteMessage: widget.showDeleteMessage || isDeleteFailed, - message: widget.message, - editMessageInputBuilder: widget.editMessageInputBuilder, - onReplyTap: widget.onReplyTap, - onThreadReplyTap: widget.onThreadTap, - showResendMessage: - widget.showResendMessage && (isSendFailed || isUpdateFailed), - showCopyMessage: widget.showCopyMessage && - !isFailedState && - widget.message.text?.trim()?.isNotEmpty == true, - showEditMessage: widget.showEditMessage && - !isDeleteFailed && - widget.message.attachments - ?.any((element) => element.type == 'giphy') != - true, - showReactions: widget.showReactions, - showReplyMessage: widget.showReplyMessage && - !isFailedState && - widget.onReplyTap != null, - showThreadReplyMessage: widget.showThreadReplyMessage && - !isFailedState && - widget.onThreadTap != null, - showFlagButton: widget.showFlagButton, - customActions: widget.customActions, - ), - ); - }); + barrierColor: _streamChatTheme.colorTheme.overlay, + builder: (context) => StreamChannel( + channel: channel, + child: MessageActionsModal( + messageWidget: widget.copyWith( + key: const Key('MessageWidget'), + message: widget.message.copyWith( + text: widget.message.text!.length > 200 + ? '${widget.message.text!.substring(0, 200)}...' + : widget.message.text, + ), + showReactions: false, + showUsername: false, + showTimestamp: false, + translateUserAvatar: false, + showSendingIndicator: false, + padding: const EdgeInsets.all(0), + showReactionPickerIndicator: widget.showReactions && + (widget.message.status == MessageSendingStatus.sent), + showPinHighlight: false, + showUserAvatar: + widget.message.user!.id == channel.client.state.user!.id + ? DisplayWidget.gone + : DisplayWidget.show, + ), + onCopyTap: (message) => + Clipboard.setData(ClipboardData(text: message.text)), + messageTheme: widget.messageTheme, + reverse: widget.reverse, + showDeleteMessage: widget.showDeleteMessage || isDeleteFailed, + message: widget.message, + editMessageInputBuilder: widget.editMessageInputBuilder, + onReplyTap: widget.onReplyTap, + onThreadReplyTap: widget.onThreadTap, + showResendMessage: widget.showResendMessage && + (isSendFailed || isUpdateFailed), + showCopyMessage: widget.showCopyMessage && + !isFailedState && + widget.message.text?.trim().isNotEmpty == true, + showEditMessage: widget.showEditMessage && + !isDeleteFailed && + widget.message.attachments + .any((element) => element.type == 'giphy') != + true, + showReactions: widget.showReactions, + showReplyMessage: widget.showReplyMessage && + !isFailedState && + widget.onReplyTap != null, + showThreadReplyMessage: widget.showThreadReplyMessage && + !isFailedState && + widget.onThreadTap != null, + showFlagButton: widget.showFlagButton, + showPinButton: widget.showPinButton, + customActions: widget.customActions, + ), + )); } void _showMessageReactionsModalBottomSheet(BuildContext context) { final channel = StreamChannel.of(context).channel; showDialog( - context: context, - barrierColor: StreamChatTheme.of(context).colorTheme.overlay, - builder: (context) { - return StreamChannel( - channel: channel, - child: MessageReactionsModal( - attachmentBorderRadiusGeometry: - widget.attachmentBorderRadiusGeometry, - showUserAvatar: - widget.message.user.id == channel.client.state.user.id - ? DisplayWidget.gone - : DisplayWidget.show, - onUserAvatarTap: widget.onUserAvatarTap, - messageTheme: widget.messageTheme, - messageShape: widget.shape ?? _getDefaultShape(context), - attachmentShape: - widget.attachmentShape ?? _getDefaultAttachmentShape(context), - reverse: widget.reverse, - message: widget.message, - editMessageInputBuilder: widget.editMessageInputBuilder, - onThreadTap: widget.onThreadTap, - showReactions: widget.showReactions, + useRootNavigator: false, + context: context, + barrierColor: _streamChatTheme.colorTheme.overlay, + builder: (context) => StreamChannel( + channel: channel, + child: MessageReactionsModal( + messageWidget: widget.copyWith( + key: const Key('MessageWidget'), + message: widget.message.copyWith( + text: widget.message.text!.length > 200 + ? '${widget.message.text!.substring(0, 200)}...' + : widget.message.text, ), - ); - }); - } - - ShapeBorder _getDefaultAttachmentShape(BuildContext context) { - final hasFiles = - widget.message.attachments?.any((it) => it.type == 'file') == true; - return RoundedRectangleBorder( - side: hasFiles - ? widget.attachmentBorderSide ?? - BorderSide( - color: StreamChatTheme.of(context).colorTheme.greyWhisper, - ) - : BorderSide.none, - borderRadius: widget.attachmentBorderRadiusGeometry ?? BorderRadius.zero, - ); - } - - ShapeBorder _getDefaultShape(BuildContext context) { - return RoundedRectangleBorder( - side: widget.borderSide ?? - BorderSide( - color: StreamChatTheme.of(context).colorTheme.greyWhisper, + showReactions: false, + showUsername: false, + showTimestamp: false, + translateUserAvatar: false, + showSendingIndicator: false, + padding: const EdgeInsets.all(0), + showReactionPickerIndicator: widget.showReactions && + (widget.message.status == MessageSendingStatus.sent), + showPinHighlight: false, + showUserAvatar: + widget.message.user!.id == channel.client.state.user!.id + ? DisplayWidget.gone + : DisplayWidget.show, ), - borderRadius: widget.borderRadiusGeometry ?? BorderRadius.zero, + onUserAvatarTap: widget.onUserAvatarTap, + messageTheme: widget.messageTheme, + reverse: widget.reverse, + message: widget.message, + showReactions: widget.showReactions, + ), + ), ); } @@ -939,13 +1136,13 @@ class _MessageWidgetState extends State final attachmentGroups = >{}; widget.message.attachments - .where((element) => element.ogScrapeUrl == null) + .where((element) => element.ogScrapeUrl == null && element.type != null) .forEach((e) { if (attachmentGroups[e.type] == null) { - attachmentGroups[e.type] = []; + attachmentGroups[e.type!] = []; } - attachmentGroups[e.type].add(e); + attachmentGroups[e.type]?.add(e); }); final attachmentList = []; @@ -953,7 +1150,7 @@ class _MessageWidgetState extends State attachmentGroups.forEach((type, attachments) { final attachmentBuilder = widget.attachmentBuilders[type]; - if (attachmentBuilder == null) return SizedBox(); + if (attachmentBuilder == null) return; final attachmentWidget = attachmentBuilder( context, widget.message, @@ -966,10 +1163,9 @@ class _MessageWidgetState extends State padding: widget.attachmentPadding, child: Column( mainAxisSize: MainAxisSize.min, - children: attachmentList?.insertBetween(SizedBox( - height: widget.attachmentPadding.vertical / 2, - )) ?? - [], + children: attachmentList.insertBetween(SizedBox( + height: widget.attachmentPadding.vertical / 2, + )), ), ); } @@ -981,7 +1177,7 @@ class _MessageWidgetState extends State } if (widget.onMessageActions != null) { - widget.onMessageActions(context, widget.message); + widget.onMessageActions!(context, widget.message); } else { _showMessageActionModalBottomSheet(context); } @@ -996,13 +1192,12 @@ class _MessageWidgetState extends State (message.status == MessageSendingStatus.sending || message.status == MessageSendingStatus.updating)) { final totalAttachments = message.attachments.length; - final uploadRemaining = message.attachments.where((it) { - return !it.uploadState.isSuccess; - }).length; + final uploadRemaining = + message.attachments.where((it) => !it.uploadState.isSuccess).length; if (uploadRemaining == 0) { return StreamSvgIcon.check( - size: style.fontSize, - color: IconTheme.of(context).color.withOpacity(0.5), + size: style!.fontSize, + color: IconTheme.of(context).color!.withOpacity(0.5), ); } return Text( @@ -1014,19 +1209,19 @@ class _MessageWidgetState extends State Widget child = SendingIndicator( message: message, isMessageRead: isMessageRead, - size: style.fontSize, + size: style!.fontSize, ); if (isMessageRead) { child = Row( children: [ - if (StreamChannel.of(context).channel.memberCount > 2) + if (StreamChannel.of(context).channel.memberCount! > 2) Text( - widget.readList.length.toString(), + widget.readList!.length.toString(), style: style.copyWith( - color: StreamChatTheme.of(context).colorTheme.accentBlue, + color: _streamChatTheme.colorTheme.accentPrimary, ), ), - SizedBox(width: 2), + const SizedBox(width: 2), child, ], ); @@ -1034,66 +1229,88 @@ class _MessageWidgetState extends State return child; } - Widget _buildUserAvatar() => Transform( - transform: Matrix4.rotationY(widget.reverse ? pi : 0), - alignment: Alignment.center, - child: Transform.translate( - offset: Offset( - 0, - widget.translateUserAvatar - ? widget.messageTheme.avatarTheme.constraints.maxHeight / 2 - : 0, - ), - child: UserAvatar( - user: widget.message.user, - onTap: widget.onUserAvatarTap, - constraints: widget.messageTheme.avatarTheme.constraints, - borderRadius: widget.messageTheme.avatarTheme.borderRadius, - showOnlineStatus: false, - ), + Widget _buildUserAvatar() => Transform.translate( + offset: Offset( + 0, + widget.translateUserAvatar + ? (widget.messageTheme.avatarTheme?.constraints.maxHeight ?? 40) / + 2 + : 0, ), + child: widget.userAvatarBuilder?.call(context, widget.message.user!) ?? + UserAvatar( + user: widget.message.user!, + onTap: widget.onUserAvatarTap, + constraints: widget.messageTheme.avatarTheme!.constraints, + borderRadius: widget.messageTheme.avatarTheme!.borderRadius, + showOnlineStatus: false, + ), ); Widget _buildTextBubble() { - if (widget.message.text.trim().isEmpty) return Offstage(); - return Transform( - transform: Matrix4.rotationY(widget.reverse ? pi : 0), - alignment: Alignment.center, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + if (widget.message.text!.trim().isEmpty) return const Offstage(); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: isOnlyEmoji ? EdgeInsets.zero : widget.textPadding, + child: widget.textBuilder != null + ? widget.textBuilder!(context, widget.message) + : MessageText( + onLinkTap: widget.onLinkTap, + message: widget.message, + onMentionTap: widget.onMentionTap, + messageTheme: isOnlyEmoji + ? widget.messageTheme.copyWith( + messageText: + widget.messageTheme.messageText!.copyWith( + fontSize: 42, + )) + : widget.messageTheme, + ), + ), + if (hasUrlAttachments && !hasQuotedMessage) _buildUrlAttachment(), + ], + ); + } + + Widget _buildPinnedMessage(Message message) { + final pinnedBy = message.pinnedBy; + final pinnedByMe = _streamChat.user!.id == pinnedBy!.id; + + return Padding( + padding: const EdgeInsets.only(left: 8, right: 8, top: 4, bottom: 8), + child: Row( + mainAxisSize: MainAxisSize.min, children: [ - Padding( - padding: isOnlyEmoji ? EdgeInsets.zero : widget.textPadding, - child: widget.textBuilder != null - ? widget.textBuilder(context, widget.message) - : MessageText( - onLinkTap: widget.onLinkTap, - message: widget.message, - onMentionTap: widget.onMentionTap, - messageTheme: isOnlyEmoji - ? widget.messageTheme.copyWith( - messageText: - widget.messageTheme.messageText.copyWith( - fontSize: 42, - )) - : widget.messageTheme, - ), + StreamSvgIcon.pin( + size: 16, ), - if (hasUrlAttachments && !hasQuotedMessage) _buildUrlAttachment(), + const SizedBox( + width: 4, + ), + Text( + 'Pinned by ${pinnedByMe ? 'You' : pinnedBy.name}', + style: TextStyle( + color: _streamChatTheme.colorTheme.textLowEmphasis, + fontSize: 13, + fontWeight: FontWeight.w400, + ), + ) ], ), ); } - bool get isOnlyEmoji => widget.message.text.isOnlyEmoji; + bool get isPinned => widget.message.pinned; - Color _getBackgroundColor() { + Color? _getBackgroundColor() { if (hasQuotedMessage) { return widget.messageTheme.messageBackgroundColor; } if (hasUrlAttachments) { - return StreamChatTheme.of(context).colorTheme.blueAlice; + return _streamChatTheme.colorTheme.linkBg; } if (isOnlyEmoji) { @@ -1125,27 +1342,72 @@ class _MessageWidgetState extends State } } -class _ThreadReplyPainter extends CustomPainter { - final Color color; - final BuildContext context; +class _ThreadParticipants extends StatelessWidget { + const _ThreadParticipants({ + Key? key, + required StreamChatThemeData streamChatTheme, + required this.threadParticipants, + }) : _streamChatTheme = streamChatTheme, + super(key: key); - const _ThreadReplyPainter({this.context, @required this.color}); + final StreamChatThemeData _streamChatTheme; + final Iterable threadParticipants; + + @override + Widget build(BuildContext context) { + var padding = 0.0; + return Stack( + children: threadParticipants.map((user) { + padding += 8.0; + return Positioned( + right: padding - 8, + bottom: 0, + top: 0, + child: Container( + decoration: BoxDecoration( + shape: BoxShape.circle, + color: _streamChatTheme.colorTheme.barsBg, + ), + padding: const EdgeInsets.all(1), + child: UserAvatar( + user: user, + constraints: BoxConstraints.loose(const Size.fromRadius(7)), + showOnlineStatus: false, + ), + ), + ); + }).toList(), + ); + } +} + +class _ThreadReplyPainter extends CustomPainter { + const _ThreadReplyPainter({ + this.context, + required this.color, + this.reverse = false, + }); + + final Color? color; + final BuildContext? context; + final bool reverse; @override void paint(Canvas canvas, Size size) { final paint = Paint() - ..color = color ?? StreamChatTheme.of(context).colorTheme.greyGainsboro + ..color = color ?? StreamChatTheme.of(context!).colorTheme.disabled ..style = PaintingStyle.stroke ..strokeWidth = 1 ..strokeCap = StrokeCap.round; final path = Path() - ..moveTo(0, 0) - ..quadraticBezierTo(0, size.height * 0.38, 0, size.height * 0.50) + ..moveTo(reverse ? size.width : 0, 0) + ..quadraticBezierTo(reverse ? size.width : 0, size.height * 0.38, + reverse ? size.width : 0, size.height * 0.50) ..quadraticBezierTo( - 0, + reverse ? size.width : 0, size.height, - size.width, + reverse ? 0 : size.width, size.height, ); canvas.drawPath(path, paint); diff --git a/packages/stream_chat_flutter/lib/src/option_list_tile.dart b/packages/stream_chat_flutter/lib/src/option_list_tile.dart index 61ce939e..16bf9e97 100644 --- a/packages/stream_chat_flutter/lib/src/option_list_tile.dart +++ b/packages/stream_chat_flutter/lib/src/option_list_tile.dart @@ -1,17 +1,11 @@ import 'package:flutter/material.dart'; import 'package:stream_chat_flutter/src/stream_chat_theme.dart'; +/// List tile for [ChannelBottomSheet] class OptionListTile extends StatelessWidget { - final String title; - final Widget leading; - final Widget trailing; - final VoidCallback onTap; - final Color titleColor; - final Color tileColor; - final Color separatorColor; - final TextStyle titleTextStyle; - - OptionListTile({ + /// Constructor for creating [OptionListTile] + const OptionListTile({ + Key? key, this.title, this.leading, this.trailing, @@ -20,49 +14,70 @@ class OptionListTile extends StatelessWidget { this.tileColor, this.separatorColor, this.titleTextStyle, - }); + }) : super(key: key); + + /// Title for tile + final String? title; + + /// Leading widget (start) + final Widget? leading; + + /// Trailing widget (end) + final Widget? trailing; + + /// Callback when tile is tapped + final VoidCallback? onTap; + + /// Title color + final Color? titleColor; + + /// Background tile color + final Color? tileColor; + + /// Separator color + final Color? separatorColor; + + /// [TextStyle] to apply to [title] + final TextStyle? titleTextStyle; @override Widget build(BuildContext context) { + final chatThemeData = StreamChatTheme.of(context); return Column( children: [ Container( - color: separatorColor ?? - StreamChatTheme.of(context).colorTheme.greyGainsboro, - height: 1.0, + color: separatorColor ?? chatThemeData.colorTheme.disabled, + height: 1, ), Material( - color: tileColor ?? StreamChatTheme.of(context).colorTheme.white, - child: Container( - height: 63.0, + color: tileColor ?? chatThemeData.colorTheme.barsBg, + child: SizedBox( + height: 63, child: InkWell( onTap: onTap, child: Row( children: [ if (leading != null) Center(child: leading), if (leading == null) - SizedBox( - width: 16.0, + const SizedBox( + width: 16, ), Expanded( flex: 4, child: Text( - title, + title!, style: titleTextStyle ?? (titleColor == null - ? StreamChatTheme.of(context).textTheme.bodyBold - : StreamChatTheme.of(context) - .textTheme - .bodyBold - .copyWith( - color: titleColor, - )), + ? chatThemeData.textTheme.bodyBold + : chatThemeData.textTheme.bodyBold.copyWith( + color: titleColor, + )), ), ), Expanded( flex: 2, child: Padding( - padding: const EdgeInsets.only(right: 16.0), + padding: const EdgeInsets.only(right: 16), child: Align( alignment: Alignment.centerRight, child: trailing ?? Container(), diff --git a/packages/stream_chat_flutter/lib/src/quoted_message_widget.dart b/packages/stream_chat_flutter/lib/src/quoted_message_widget.dart index 4d2b2e38..22ca4d4c 100644 --- a/packages/stream_chat_flutter/lib/src/quoted_message_widget.dart +++ b/packages/stream_chat_flutter/lib/src/quoted_message_widget.dart @@ -1,44 +1,38 @@ -import 'dart:math'; - import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; +import 'package:stream_chat_flutter/src/extension.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart'; import 'package:video_player/video_player.dart'; -import 'attachment/attachment.dart'; -import 'extension.dart'; -import 'message_text.dart'; -import 'stream_chat_theme.dart'; -import 'user_avatar.dart'; -import 'utils.dart'; - +/// Widget builder for quoted message attachment thumnail typedef QuotedMessageAttachmentThumbnailBuilder = Widget Function( BuildContext, Attachment, ); class _VideoAttachmentThumbnail extends StatefulWidget { - final Size size; - final Attachment attachment; - const _VideoAttachmentThumbnail({ - Key key, - @required this.attachment, + Key? key, + required this.attachment, this.size = const Size(32, 32), }) : super(key: key); + final Size size; + final Attachment attachment; + @override _VideoAttachmentThumbnailState createState() => _VideoAttachmentThumbnailState(); } class _VideoAttachmentThumbnailState extends State<_VideoAttachmentThumbnail> { - VideoPlayerController _controller; + late VideoPlayerController _controller; @override void initState() { super.initState(); - _controller = VideoPlayerController.network(widget.attachment.assetUrl) + _controller = VideoPlayerController.network(widget.attachment.assetUrl!) ..initialize().then((_) { setState(() {}); //when your thumbnail will show. }); @@ -51,18 +45,30 @@ class _VideoAttachmentThumbnailState extends State<_VideoAttachmentThumbnail> { } @override - Widget build(BuildContext context) { - return Container( + Widget build(BuildContext context) => SizedBox( height: widget.size.height, width: widget.size.width, child: _controller.value.isInitialized ? VideoPlayer(_controller) - : CircularProgressIndicator()); - } + : const CircularProgressIndicator(), + ); } /// class QuotedMessageWidget extends StatelessWidget { + /// + const QuotedMessageWidget({ + Key? key, + required this.message, + required this.messageTheme, + this.reverse = false, + this.showBorder = false, + this.textLimit = 170, + this.attachmentThumbnailBuilders, + this.padding = const EdgeInsets.all(8), + this.onTap, + }) : super(key: key); + /// The message final Message message; @@ -79,36 +85,29 @@ class QuotedMessageWidget extends StatelessWidget { final int textLimit; /// Map that defines a thumbnail builder for an attachment type - final Map + final Map? attachmentThumbnailBuilders; + /// Padding around the widget final EdgeInsetsGeometry padding; - final GestureTapCallback onTap; + /// Callback for tap on widget + final GestureTapCallback? onTap; - /// - QuotedMessageWidget({ - Key key, - @required this.message, - @required this.messageTheme, - this.reverse = false, - this.showBorder = false, - this.textLimit = 170, - this.attachmentThumbnailBuilders, - this.padding = const EdgeInsets.all(8), - this.onTap, - }) : super(key: key); - - bool get _hasAttachments => message.attachments?.isNotEmpty == true; + bool get _hasAttachments => message.attachments.isNotEmpty == true; bool get _containsScrapeUrl => - message.attachments?.any((element) => element.ogScrapeUrl != null) == - true; + message.attachments.any((element) => element.ogScrapeUrl != null) == true; - bool get _containsText => message?.text?.isNotEmpty == true; + bool get _containsText => message.text?.isNotEmpty == true; @override Widget build(BuildContext context) { + final children = [ + Flexible(child: _buildMessage(context)), + const SizedBox(width: 8), + if (message.user != null) _buildUserAvatar(), + ]; return Padding( padding: padding, child: InkWell( @@ -116,44 +115,36 @@ class QuotedMessageWidget extends StatelessWidget { child: Row( crossAxisAlignment: CrossAxisAlignment.end, mainAxisSize: MainAxisSize.min, - children: [ - Flexible(child: _buildMessage(context)), - SizedBox(width: 8), - _buildUserAvatar(), - ], + children: reverse ? children.reversed.toList() : children, ), ), ); } Widget _buildMessage(BuildContext context) { - final isOnlyEmoji = message.text.isOnlyEmoji; + final isOnlyEmoji = message.text!.isOnlyEmoji; var msg = _hasAttachments && !_containsText - ? message.copyWith(text: message.attachments.last?.title ?? '') + ? message.copyWith(text: message.attachments.last.title ?? '') : message; - if (msg.text.length > textLimit) { - msg = msg.copyWith(text: '${msg.text.substring(0, textLimit - 3)}...'); + if (msg.text!.length > textLimit) { + msg = msg.copyWith(text: '${msg.text!.substring(0, textLimit - 3)}...'); } final children = [ if (_hasAttachments) _parseAttachments(context), - if (msg.text.isNotEmpty) + if (msg.text!.isNotEmpty) Flexible( - child: Transform( - transform: Matrix4.rotationY(reverse ? pi : 0), - alignment: Alignment.center, - child: MessageText( - message: msg, - messageTheme: isOnlyEmoji && _containsText - ? messageTheme.copyWith( - messageText: messageTheme.messageText.copyWith( - fontSize: 32, - )) - : messageTheme.copyWith( - messageText: messageTheme.messageText.copyWith( - fontSize: 12, - )), - ), + child: MessageText( + message: msg, + messageTheme: isOnlyEmoji && _containsText + ? messageTheme.copyWith( + messageText: messageTheme.messageText?.copyWith( + fontSize: 32, + )) + : messageTheme.copyWith( + messageText: messageTheme.messageText?.copyWith( + fontSize: 12, + )), ), ), ].insertBetween(const SizedBox(width: 8)); @@ -163,13 +154,14 @@ class QuotedMessageWidget extends StatelessWidget { color: _getBackgroundColor(context), border: showBorder ? Border.all( - color: StreamChatTheme.of(context).colorTheme.greyGainsboro, + color: StreamChatTheme.of(context).colorTheme.disabled, ) : null, borderRadius: BorderRadius.only( - topRight: Radius.circular(12), - topLeft: Radius.circular(12), - bottomLeft: Radius.circular(12), + topRight: const Radius.circular(12), + topLeft: const Radius.circular(12), + bottomRight: reverse ? const Radius.circular(12) : Radius.zero, + bottomLeft: reverse ? Radius.zero : const Radius.circular(12), ), ), padding: const EdgeInsets.all(8), @@ -184,7 +176,7 @@ class QuotedMessageWidget extends StatelessWidget { } Widget _buildUrlAttachment(Attachment attachment) { - final size = Size(32, 32); + const size = Size(32, 32); if (attachment.thumbUrl != null) { return Container( height: size.height, @@ -193,13 +185,13 @@ class QuotedMessageWidget extends StatelessWidget { image: DecorationImage( fit: BoxFit.cover, image: CachedNetworkImageProvider( - attachment.imageUrl, + attachment.imageUrl!, ), ), ), ); } - return AttachmentError(size: size); + return const AttachmentError(size: size); } Widget _parseAttachments(BuildContext context) { @@ -211,104 +203,84 @@ class QuotedMessageWidget extends StatelessWidget { ); child = _buildUrlAttachment(attachment); } else { - QuotedMessageAttachmentThumbnailBuilder attachmentBuilder; + QuotedMessageAttachmentThumbnailBuilder? attachmentBuilder; attachment = message.attachments.last; - if (attachmentThumbnailBuilders?.containsKey(attachment?.type) == true) { - attachmentBuilder = attachmentThumbnailBuilders[attachment?.type]; + if (attachmentThumbnailBuilders?.containsKey(attachment.type) == true) { + attachmentBuilder = attachmentThumbnailBuilders![attachment.type]; } - attachmentBuilder = _defaultAttachmentBuilder[attachment?.type]; + attachmentBuilder = _defaultAttachmentBuilder[attachment.type]; if (attachmentBuilder == null) { - child = Offstage(); + child = const Offstage(); + } else { + child = attachmentBuilder(context, attachment); } - child = attachmentBuilder(context, attachment); } child = AbsorbPointer(child: child); - return Transform( - transform: Matrix4.rotationY(reverse ? pi : 0), - alignment: Alignment.center, - child: Material( - clipBehavior: Clip.antiAlias, - type: MaterialType.transparency, - shape: attachment.type == 'file' ? null : _getDefaultShape(context), - child: child, - ), + return Material( + clipBehavior: Clip.hardEdge, + type: MaterialType.transparency, + shape: attachment.type == 'file' ? null : _getDefaultShape(context), + child: child, ); } - ShapeBorder _getDefaultShape(BuildContext context) { - return RoundedRectangleBorder( - side: BorderSide(width: 0.0, color: Colors.transparent), - borderRadius: BorderRadius.circular(8), - ); - } + ShapeBorder _getDefaultShape(BuildContext context) => RoundedRectangleBorder( + side: const BorderSide(width: 0, color: Colors.transparent), + borderRadius: BorderRadius.circular(8), + ); - Widget _buildUserAvatar() { - return Transform( - transform: Matrix4.rotationY(reverse ? pi : 0), - alignment: Alignment.center, - child: UserAvatar( - user: message.user, - constraints: BoxConstraints.tightFor( + Widget _buildUserAvatar() => UserAvatar( + user: message.user!, + constraints: const BoxConstraints.tightFor( height: 24, width: 24, ), showOnlineStatus: false, - ), - ); - } + ); Map - get _defaultAttachmentBuilder { - return { - 'image': (_, attachment) { - return ImageAttachment( - attachment: attachment, - message: message, - messageTheme: messageTheme, - size: Size(32, 32), - ); - }, - 'video': (_, attachment) { - return _VideoAttachmentThumbnail( - key: ValueKey(attachment.assetUrl), - attachment: attachment, - ); - }, - 'giphy': (_, attachment) { - final size = Size(32, 32); - return CachedNetworkImage( - height: size?.height, - width: size?.width, - placeholder: (_, __) { - return Container( - width: size?.width, - height: size?.height, - child: Center( - child: CircularProgressIndicator(), - ), - ); - }, - imageUrl: - attachment.thumbUrl ?? attachment.imageUrl ?? attachment.assetUrl, - errorWidget: (context, url, error) { - return AttachmentError(size: size); - }, - fit: BoxFit.cover, - ); - }, - 'file': (_, attachment) { - return Container( - height: 32, - width: 32, - child: getFileTypeImage(attachment.extraData['mime_type']), - ); - }, - }; - } + get _defaultAttachmentBuilder => { + 'image': (_, attachment) => ImageAttachment( + attachment: attachment, + message: message, + messageTheme: messageTheme, + size: const Size(32, 32), + ), + 'video': (_, attachment) => _VideoAttachmentThumbnail( + key: ValueKey(attachment.assetUrl), + attachment: attachment, + ), + 'giphy': (_, attachment) { + const size = Size(32, 32); + return CachedNetworkImage( + height: size.height, + width: size.width, + placeholder: (_, __) => SizedBox( + width: size.width, + height: size.height, + child: const Center( + child: CircularProgressIndicator(), + ), + ), + imageUrl: attachment.thumbUrl ?? + attachment.imageUrl ?? + attachment.assetUrl!, + errorWidget: (context, url, error) => + const AttachmentError(size: size), + fit: BoxFit.cover, + ); + }, + 'file': (_, attachment) => SizedBox( + height: 32, + width: 32, + child: getFileTypeImage( + attachment.extraData['mime_type'] as String?), + ), + }; - Color _getBackgroundColor(BuildContext context) { + Color? _getBackgroundColor(BuildContext context) { if (_containsScrapeUrl) { - return StreamChatTheme.of(context).colorTheme.blueAlice; + return StreamChatTheme.of(context).colorTheme.linkBg; } return messageTheme.messageBackgroundColor; } diff --git a/packages/stream_chat_flutter/lib/src/reaction_bubble.dart b/packages/stream_chat_flutter/lib/src/reaction_bubble.dart index 4ddcca93..9e279344 100644 --- a/packages/stream_chat_flutter/lib/src/reaction_bubble.dart +++ b/packages/stream_chat_flutter/lib/src/reaction_bubble.dart @@ -1,103 +1,115 @@ import 'dart:math'; +import 'package:collection/collection.dart' show IterableExtension; import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; +import 'package:flutter/widgets.dart'; import 'package:stream_chat_flutter/src/reaction_icon.dart'; -import 'package:stream_chat_flutter/src/stream_svg_icon.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; +/// Creates reaction bubble widget for displaying over messages class ReactionBubble extends StatelessWidget { + /// Constructor for creating a [ReactionBubble] const ReactionBubble({ - Key key, - @required this.reactions, - @required this.borderColor, - @required this.backgroundColor, - @required this.maskColor, + Key? key, + required this.reactions, + required this.borderColor, + required this.backgroundColor, + required this.maskColor, this.reverse = false, this.flipTail = false, this.highlightOwnReactions = true, this.tailCirclesSpacing = 0, }) : super(key: key); + /// Reactions to show final List reactions; + + /// Border color of bubble final Color borderColor; + + /// Background color of bubble final Color backgroundColor; + + /// Mask color final Color maskColor; + + /// Reverse for other side final bool reverse; + + /// Reverse tail for other side final bool flipTail; + + /// Flag for highlighting own reactions final bool highlightOwnReactions; + + /// Spacing for tail circles final double tailCirclesSpacing; @override Widget build(BuildContext context) { final reactionIcons = StreamChatTheme.of(context).reactionIcons; final totalReactions = reactions.length; - final offset = totalReactions > 1 ? 16.0 : 2.0; - return Transform( - transform: Matrix4.rotationY(reverse ? pi : 0), + final offset = + totalReactions > 1 ? 16.0.mirrorConditionally(flipTail) : 2.0; + return Stack( alignment: Alignment.center, - child: Stack( - alignment: Alignment.center, - children: [ - Transform.translate( - offset: Offset(reverse ? offset : -offset, 0), + children: [ + Transform.translate( + offset: Offset(-offset, 0), + child: Container( + padding: const EdgeInsets.all(2), + decoration: BoxDecoration( + color: maskColor, + borderRadius: const BorderRadius.all(Radius.circular(16)), + ), child: Container( - padding: const EdgeInsets.all(2), - decoration: BoxDecoration( - color: maskColor, - borderRadius: BorderRadius.all(Radius.circular(16)), + padding: EdgeInsets.symmetric( + vertical: 4, + horizontal: totalReactions > 1 ? 4.0 : 0, ), - child: Container( - padding: EdgeInsets.symmetric( - vertical: 4, - horizontal: totalReactions > 1 ? 4 : 0, + decoration: BoxDecoration( + border: Border.all( + color: borderColor, ), - decoration: BoxDecoration( - border: Border.all( - color: borderColor, - ), - color: backgroundColor, - borderRadius: BorderRadius.all(Radius.circular(14)), - ), - child: LayoutBuilder( - builder: (context, constraints) { - return Flex( - direction: Axis.horizontal, - mainAxisSize: MainAxisSize.min, - children: [ - if (constraints.maxWidth < double.infinity) - ...reactions - .take((constraints.maxWidth) ~/ 24) - .map((reaction) { - return _buildReaction( - reactionIcons, - reaction, - context, - ); - }).toList(), - if (constraints.maxWidth == double.infinity) - ...reactions.map((reaction) { - return _buildReaction( - reactionIcons, - reaction, - context, - ); - }).toList(), - ], - ); - }, + color: backgroundColor, + borderRadius: const BorderRadius.all(Radius.circular(14)), + ), + child: LayoutBuilder( + builder: (context, constraints) => Flex( + direction: Axis.horizontal, + mainAxisSize: MainAxisSize.min, + children: [ + if (constraints.maxWidth < double.infinity) + ...reactions + .take((constraints.maxWidth) ~/ 24) + .map((reaction) => _buildReaction( + reactionIcons, + reaction, + context, + )) + .toList(), + if (constraints.maxWidth == double.infinity) + ...reactions + .map((reaction) => _buildReaction( + reactionIcons, + reaction, + context, + )) + .toList(), + ], ), ), ), ), - Positioned( - bottom: 2, - left: reverse ? null : 13, - right: !reverse ? null : 13, - child: _buildReactionsTail(context), - ), - ], - ), + ), + Positioned( + bottom: 2, + left: reverse ? null : 13, + right: reverse ? 13 : null, + child: _buildReactionsTail(context), + ), + ], ); } @@ -106,38 +118,31 @@ class ReactionBubble extends StatelessWidget { Reaction reaction, BuildContext context, ) { - final reactionIcon = reactionIcons.firstWhere( + final reactionIcon = reactionIcons.firstWhereOrNull( (r) => r.type == reaction.type, - orElse: () => null, ); + final chatThemeData = StreamChatTheme.of(context); + final userId = StreamChat.of(context).user?.id; return Padding( padding: const EdgeInsets.symmetric( - horizontal: 4.0, + horizontal: 4, ), child: reactionIcon != null - ? StreamSvgIcon( - assetName: reactionIcon.assetName, - width: 16, - height: 16, - color: (!highlightOwnReactions || - reaction.user.id == StreamChat.of(context).user.id) - ? StreamChatTheme.of(context).colorTheme.accentBlue - : StreamChatTheme.of(context) - .colorTheme - .black - .withOpacity(.5), + ? ConstrainedBox( + constraints: BoxConstraints.tight(const Size.square(16)), + child: reactionIcon.builder( + context, + !highlightOwnReactions || reaction.user?.id == userId, + 16, + ), ) : Icon( Icons.help_outline_rounded, size: 16, - color: (!highlightOwnReactions || - reaction.user.id == StreamChat.of(context).user.id) - ? StreamChatTheme.of(context).colorTheme.accentBlue - : StreamChatTheme.of(context) - .colorTheme - .black - .withOpacity(.5), + color: (!highlightOwnReactions || reaction.user?.id == userId) + ? chatThemeData.colorTheme.accentPrimary + : chatThemeData.colorTheme.textHighEmphasis.withOpacity(.5), ), ); } @@ -149,29 +154,44 @@ class ReactionBubble extends StatelessWidget { borderColor, maskColor, tailCirclesSpace: tailCirclesSpacing, + flipTail: !flipTail, + numberOfReactions: reactions.length, ), ); - return Transform( - transform: Matrix4.rotationY(flipTail ? 0 : pi), - alignment: Alignment.center, - child: tail, - ); + return tail; } } +/// Painter widget for a reaction bubble class ReactionBubblePainter extends CustomPainter { - final Color color; - final Color borderColor; - final Color maskColor; - final double tailCirclesSpace; - + /// Constructor for creating a [ReactionBubblePainter] ReactionBubblePainter( this.color, this.borderColor, this.maskColor, { this.tailCirclesSpace = 0, + this.flipTail = false, + this.numberOfReactions = 0, }); + /// Color of bubble + final Color color; + + /// Border color of bubble + final Color borderColor; + + /// Mask color + final Color maskColor; + + /// Tail circle space + final double tailCirclesSpace; + + /// Flip tail + final bool flipTail; + + /// Number of reactions on the page + final int numberOfReactions; + @override void paint(Canvas canvas, Size size) { _drawOvalMask(size, canvas); @@ -192,13 +212,15 @@ class ReactionBubblePainter extends CustomPainter { ..color = maskColor ..style = PaintingStyle.fill; - final path = Path(); - path.addOval( - Rect.fromCircle( - center: Offset(4, 3) + Offset(tailCirclesSpace, tailCirclesSpace), - radius: 4, - ), - ); + final path = Path() + ..addOval( + Rect.fromCircle( + center: const Offset(4, 3).mirrorConditionally(flipTail) + + Offset(tailCirclesSpace, tailCirclesSpace) + .mirrorConditionally(flipTail), + radius: 4, + ), + ); canvas.drawPath(path, paint); } @@ -208,13 +230,15 @@ class ReactionBubblePainter extends CustomPainter { ..strokeWidth = 1 ..style = PaintingStyle.stroke; - final path = Path(); - path.addOval( - Rect.fromCircle( - center: Offset(4, 3) + Offset(tailCirclesSpace, tailCirclesSpace), - radius: 2, - ), - ); + final path = Path() + ..addOval( + Rect.fromCircle( + center: const Offset(4, 3).mirrorConditionally(flipTail) + + Offset(tailCirclesSpace, tailCirclesSpace) + .mirrorConditionally(flipTail), + radius: 2, + ), + ); canvas.drawPath(path, paint); } @@ -223,11 +247,13 @@ class ReactionBubblePainter extends CustomPainter { ..color = color ..strokeWidth = 1; - final path = Path(); - path.addOval(Rect.fromCircle( - center: Offset(4, 3) + Offset(tailCirclesSpace, tailCirclesSpace), - radius: 2, - )); + final path = Path() + ..addOval(Rect.fromCircle( + center: const Offset(4, 3).mirrorConditionally(flipTail) + + Offset(tailCirclesSpace, tailCirclesSpace) + .mirrorConditionally(flipTail), + radius: 2, + )); canvas.drawPath(path, paint); } @@ -237,18 +263,18 @@ class ReactionBubblePainter extends CustomPainter { ..strokeWidth = 1 ..style = PaintingStyle.stroke; - final dy = -2.2; - final startAngle = 1.1; - final sweepAngle = 1.2; - final path = Path(); - path.addArc( - Rect.fromCircle( - center: Offset(1, dy), - radius: 4, - ), - -pi * startAngle, - -pi / sweepAngle, - ); + const dy = -2.2; + final startAngle = flipTail ? -0.1 : 1.1; + final sweepAngle = flipTail ? -1.2 : (numberOfReactions > 1 ? 1.2 : 0.9); + final path = Path() + ..addArc( + Rect.fromCircle( + center: const Offset(1, dy).mirrorConditionally(flipTail), + radius: 4, + ), + -pi * startAngle, + -pi / sweepAngle, + ); canvas.drawPath(path, paint); } @@ -257,18 +283,18 @@ class ReactionBubblePainter extends CustomPainter { ..color = color ..strokeWidth = 1; - final dy = -2.2; - final startAngle = 1; - final sweepAngle = 1.3; - final path = Path(); - path.addArc( - Rect.fromCircle( - center: Offset(1, dy), - radius: 4, - ), - -pi * startAngle, - -pi * sweepAngle, - ); + const dy = -2.2; + final startAngle = flipTail ? -0.0 : 1.0; + final sweepAngle = flipTail ? -1.3 : 1.3; + final path = Path() + ..addArc( + Rect.fromCircle( + center: const Offset(1, dy).mirrorConditionally(flipTail), + radius: 4, + ), + -pi * startAngle, + -pi * sweepAngle, + ); canvas.drawPath(path, paint); } @@ -278,23 +304,35 @@ class ReactionBubblePainter extends CustomPainter { ..strokeWidth = 1 ..style = PaintingStyle.fill; - final dy = -2.2; - final startAngle = 1.1; - final sweepAngle = 1.2; - final path = Path(); - path.addArc( - Rect.fromCircle( - center: Offset(1, dy), - radius: 6, - ), - -pi * startAngle, - -pi / sweepAngle, - ); + const dy = -2.2; + final startAngle = flipTail ? -0.1 : 1.1; + final sweepAngle = flipTail ? -1.2 : 1.2; + final path = Path() + ..addArc( + Rect.fromCircle( + center: const Offset(1, dy).mirrorConditionally(flipTail), + radius: 6, + ), + -pi * startAngle, + -pi / sweepAngle, + ); canvas.drawPath(path, paint); } @override - bool shouldRepaint(CustomPainter oldDelegate) { - return true; - } + bool shouldRepaint(CustomPainter oldDelegate) => true; +} + +/// Extension on [Offset] +extension YTransformer on Offset { + /// Flips x coordinate when flip is true + // ignore: avoid_positional_boolean_parameters + Offset mirrorConditionally(bool flip) => Offset(flip ? -dx : dx, dy); +} + +/// Extension on [Offset] +extension IntTransformer on double { + /// Flips x coordinate when flip is true + // ignore: avoid_positional_boolean_parameters + double mirrorConditionally(bool flip) => flip ? -this : this; } diff --git a/packages/stream_chat_flutter/lib/src/reaction_icon.dart b/packages/stream_chat_flutter/lib/src/reaction_icon.dart index 99b93328..e675128b 100644 --- a/packages/stream_chat_flutter/lib/src/reaction_icon.dart +++ b/packages/stream_chat_flutter/lib/src/reaction_icon.dart @@ -1,9 +1,20 @@ -class ReactionIcon { - final String type; - final String assetName; +import 'package:flutter/material.dart'; +/// Reaction icon data +class ReactionIcon { + /// Constructor for creating [ReactionIcon] ReactionIcon({ - this.type, - this.assetName, + required this.type, + required this.builder, }); + + /// Type of reaction + final String type; + + /// Asset to display for reaction + final Widget Function( + BuildContext, + bool highlighted, + double size, + ) builder; } diff --git a/packages/stream_chat_flutter/lib/src/reaction_picker.dart b/packages/stream_chat_flutter/lib/src/reaction_picker.dart index a33e7b52..3e1e5e50 100644 --- a/packages/stream_chat_flutter/lib/src/reaction_picker.dart +++ b/packages/stream_chat_flutter/lib/src/reaction_picker.dart @@ -1,28 +1,24 @@ -import 'dart:math'; - import 'package:ezanimation/ezanimation.dart'; import 'package:flutter/material.dart'; -import 'package:stream_chat_flutter/src/stream_svg_icon.dart'; - -import '../stream_chat_flutter.dart'; -import 'extension.dart'; +import 'package:stream_chat_flutter/src/extension.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; /// ![screenshot](https://raw.githubusercontent.com/GetStream/stream-chat-flutter/master/screenshots/reaction_picker.png) /// ![screenshot](https://raw.githubusercontent.com/GetStream/stream-chat-flutter/master/screenshots/reaction_picker_paint.png) /// /// It shows a reaction picker /// -/// Usually you don't use this widget as it's one of the default widgets used by [MessageWidget.onMessageActions]. - +/// Usually you don't use this widget as it's one of the default widgets used +/// by [MessageWidget.onMessageActions]. class ReactionPicker extends StatefulWidget { + /// Constructor for creating a [ReactionPicker] widget const ReactionPicker({ - Key key, - @required this.message, - @required this.messageTheme, + Key? key, + required this.message, }) : super(key: key); + /// Message to attach the reaction to final Message message; - final MessageTheme messageTheme; @override _ReactionPickerState createState() => _ReactionPickerState(); @@ -34,14 +30,15 @@ class _ReactionPickerState extends State @override Widget build(BuildContext context) { - final reactionIcons = StreamChatTheme.of(context).reactionIcons; + final chatThemeData = StreamChatTheme.of(context); + final reactionIcons = chatThemeData.reactionIcons; if (animations.isEmpty && reactionIcons.isNotEmpty) { reactionIcons.forEach((element) { animations.add( EzAnimation.tween( Tween(begin: 0.0, end: 1.0), - Duration(milliseconds: 500), + const Duration(milliseconds: 500), curve: Curves.easeInOutBack, ), ); @@ -50,113 +47,100 @@ class _ReactionPickerState extends State triggerAnimations(); } - return TweenAnimationBuilder( - tween: Tween(begin: 0.0, end: 1.0), - curve: Curves.easeInOutBack, - duration: Duration(milliseconds: 500), - builder: (context, val, wid) { - return Transform.scale( - scale: val, - child: Material( - borderRadius: BorderRadius.circular(24), - color: StreamChatTheme.of(context).colorTheme.white, - clipBehavior: Clip.hardEdge, - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: 16.0, - vertical: 8.0, - ), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisAlignment: MainAxisAlignment.end, - mainAxisSize: MainAxisSize.min, - children: reactionIcons - .map((reactionIcon) { - final ownReactionIndex = widget.message.ownReactions - ?.indexWhere((reaction) => - reaction.type == reactionIcon.type) ?? - -1; - var index = reactionIcons.indexOf(reactionIcon); + final child = Material( + borderRadius: BorderRadius.circular(24), + color: chatThemeData.colorTheme.barsBg, + clipBehavior: Clip.hardEdge, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 8, + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.end, + mainAxisSize: MainAxisSize.min, + children: reactionIcons + .map((reactionIcon) { + final ownReactionIndex = widget.message.ownReactions + ?.indexWhere( + (reaction) => reaction.type == reactionIcon.type) ?? + -1; + final index = reactionIcons.indexOf(reactionIcon); - return ConstrainedBox( - constraints: BoxConstraints.tightFor( - height: 24, - width: 24, - ), - child: RawMaterialButton( - elevation: 0, - padding: const EdgeInsets.all(0), - clipBehavior: Clip.none, - shape: ContinuousRectangleBorder( - borderRadius: BorderRadius.circular(16), - ), - constraints: BoxConstraints.tightFor( - height: 24, - width: 24, - ), - onPressed: () { - if (ownReactionIndex != -1) { - removeReaction( - context, - widget.message.ownReactions[ownReactionIndex], - ); - } else { - sendReaction( - context, - reactionIcon.type, - ); - } - }, - child: AnimatedBuilder( - animation: animations[index], - builder: (context, val) { - return Transform.scale( - alignment: Alignment.center, - scale: animations[index].value, - child: StreamSvgIcon( - assetName: reactionIcon.assetName, - height: max( - 0, - animations[index].value * 24.0, - ), - width: max( - 0, - animations[index].value * 24.0, - ), - color: ownReactionIndex != -1 - ? StreamChatTheme.of(context) - .colorTheme - .accentBlue - : Theme.of(context) - .iconTheme - .color - .withOpacity(.5), - ), - ); - }), - ), + final child = reactionIcon.builder( + context, + ownReactionIndex != -1, + 24, + ); + + return ConstrainedBox( + constraints: const BoxConstraints.tightFor( + height: 24, + width: 24, + ), + child: RawMaterialButton( + elevation: 0, + shape: ContinuousRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + constraints: const BoxConstraints.tightFor( + height: 24, + width: 24, + ), + onPressed: () { + if (ownReactionIndex != -1) { + removeReaction( + context, + widget.message.ownReactions![ownReactionIndex], ); - }) - .insertBetween(SizedBox( - width: 16, - )) - .toList(), - ), - ), - ), - ); - }); + } else { + sendReaction( + context, + reactionIcon.type, + ); + } + }, + child: AnimatedBuilder( + animation: animations[index], + builder: (context, child) => Transform.scale( + scale: animations[index].value, + child: child, + ), + child: child, + ), + ), + ); + }) + .insertBetween(const SizedBox( + width: 16, + )) + .toList(), + ), + ), + ); + + return TweenAnimationBuilder( + tween: Tween(begin: 0, end: 1), + curve: Curves.easeInOutBack, + duration: const Duration(milliseconds: 500), + builder: (context, val, widget) => Transform.scale( + scale: val, + child: widget, + ), + child: child, + ); } void triggerAnimations() async { - for (var a in animations) { + for (final a in animations) { a.start(); - await Future.delayed(Duration(milliseconds: 100)); + await Future.delayed(const Duration(milliseconds: 100)); } } void pop() async { - for (var a in animations) { + for (final a in animations) { a.stop(); } Navigator.of(context).pop(); @@ -180,8 +164,8 @@ class _ReactionPickerState extends State @override void dispose() { - for (var a in animations) { - a?.dispose(); + for (final a in animations) { + a.dispose(); } super.dispose(); } diff --git a/packages/stream_chat_flutter/lib/src/sending_indicator.dart b/packages/stream_chat_flutter/lib/src/sending_indicator.dart index 3fca2fa9..7f44e5b8 100644 --- a/packages/stream_chat_flutter/lib/src/sending_indicator.dart +++ b/packages/stream_chat_flutter/lib/src/sending_indicator.dart @@ -3,29 +3,35 @@ import 'package:stream_chat_flutter/stream_chat_flutter.dart'; /// Used to show the sending status of the message class SendingIndicator extends StatelessWidget { - final Message message; - final bool isMessageRead; - final double size; - + /// Constructor for creating a [SendingIndicator] widget const SendingIndicator({ - Key key, - this.message, + Key? key, + required this.message, this.isMessageRead = false, this.size = 12, }) : super(key: key); + /// Message for sending indicator + final Message message; + + /// Flag if message is read + final bool isMessageRead; + + /// Size for message + final double? size; + @override Widget build(BuildContext context) { if (isMessageRead) { return StreamSvgIcon.checkAll( size: size, - color: StreamChatTheme.of(context).colorTheme.accentBlue, + color: StreamChatTheme.of(context).colorTheme.accentPrimary, ); } - if (message.status == MessageSendingStatus.sent || message.status == null) { + if (message.status == MessageSendingStatus.sent) { return StreamSvgIcon.check( size: size, - color: IconTheme.of(context).color.withOpacity(0.5), + color: IconTheme.of(context).color!.withOpacity(0.5), ); } if (message.status == MessageSendingStatus.sending || @@ -35,6 +41,6 @@ class SendingIndicator extends StatelessWidget { size: size, ); } - return SizedBox(); + return const SizedBox(); } } diff --git a/packages/stream_chat_flutter/lib/src/stream_chat.dart b/packages/stream_chat_flutter/lib/src/stream_chat.dart index 470591e7..c050d8cb 100644 --- a/packages/stream_chat_flutter/lib/src/stream_chat.dart +++ b/packages/stream_chat_flutter/lib/src/stream_chat.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:ui' as ui; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; @@ -7,7 +8,6 @@ import 'package:jiffy/jiffy.dart'; import 'package:stream_chat_flutter/src/stream_chat_theme.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart'; -import 'dart:ui' as ui; /// Widget used to provide information about the chat to the widget tree /// @@ -31,35 +31,46 @@ import 'dart:ui' as ui; /// /// Use [StreamChat.of] to get the current [StreamChatState] instance. class StreamChat extends StatefulWidget { - final StreamChatClient client; - final Widget child; - final StreamChatThemeData streamChatThemeData; + /// Constructor for creating a [StreamChat] widget + const StreamChat({ + Key? key, + required this.client, + required this.child, + this.streamChatThemeData, + this.onBackgroundEventReceived, + this.backgroundKeepAlive = const Duration(minutes: 1), + this.connectivityStream, + }) : super(key: key); - /// The amount of time that will pass before disconnecting the client in the background + /// Client to do chat ops with + final StreamChatClient client; + + /// Child which inherits details + final Widget? child; + + /// Theme to pass on + final StreamChatThemeData? streamChatThemeData; + + /// The amount of time that will pass before disconnecting the client + /// in the background final Duration backgroundKeepAlive; /// Handler called whenever the [client] receives a new [Event] while the app /// is in background. Can be used to display various notifications depending /// upon the [Event.type] - final EventHandler onBackgroundEventReceived; + final EventHandler? onBackgroundEventReceived; - StreamChat({ - Key key, - @required this.client, - @required this.child, - this.streamChatThemeData, - this.onBackgroundEventReceived, - this.backgroundKeepAlive = const Duration(minutes: 1), - }) : super( - key: key, - ); + /// Stream of connectivity result + /// Visible for testing + @visibleForTesting + final Stream? connectivityStream; @override StreamChatState createState() => StreamChatState(); /// Use this method to get the current [StreamChatState] instance static StreamChatState of(BuildContext context) { - StreamChatState streamChatState; + StreamChatState? streamChatState; streamChatState = context.findAncestorStateOfType(); @@ -74,6 +85,7 @@ class StreamChat extends StatefulWidget { /// The current state of the StreamChat widget class StreamChatState extends State { + /// Gets client from widget StreamChatClient get client => widget.client; @override @@ -89,14 +101,15 @@ class StreamChatState extends State { return Theme( data: materialTheme.copyWith( primaryIconTheme: streamTheme.primaryIconTheme, - accentColor: streamTheme.colorTheme.accentBlue, - scaffoldBackgroundColor: streamTheme.colorTheme.white, + accentColor: streamTheme.colorTheme.accentPrimary, + scaffoldBackgroundColor: streamTheme.colorTheme.barsBg, ), child: StreamChatCore( client: client, onBackgroundEventReceived: widget.onBackgroundEventReceived, backgroundKeepAlive: widget.backgroundKeepAlive, - child: widget.child, + connectivityStream: widget.connectivityStream, + child: widget.child ?? const Offstage(), ), ); }, @@ -107,17 +120,18 @@ class StreamChatState extends State { StreamChatThemeData _getTheme( BuildContext context, - StreamChatThemeData themeData, + StreamChatThemeData? themeData, ) { - final defaultTheme = StreamChatThemeData.getDefaultTheme(Theme.of(context)); - return defaultTheme.merge(themeData) ?? themeData; + final appBrightness = Theme.of(context).brightness; + final defaultTheme = StreamChatThemeData(brightness: appBrightness); + return defaultTheme.merge(themeData); } /// The current user - User get user => widget.client.state.user; + User? get user => widget.client.state.user; /// The current user as a stream - Stream get userStream => widget.client.state.userStream; + Stream get userStream => widget.client.state.userStream; @override void initState() { diff --git a/packages/stream_chat_flutter/lib/src/stream_chat_theme.dart b/packages/stream_chat_flutter/lib/src/stream_chat_theme.dart index e54c0fbe..c62e92ec 100644 --- a/packages/stream_chat_flutter/lib/src/stream_chat_theme.dart +++ b/packages/stream_chat_flutter/lib/src/stream_chat_theme.dart @@ -1,48 +1,130 @@ import 'package:cached_network_image/cached_network_image.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:stream_chat_flutter/src/channel_header.dart'; import 'package:stream_chat_flutter/src/channel_preview.dart'; +import 'package:stream_chat_flutter/src/extension.dart'; import 'package:stream_chat_flutter/src/message_input.dart'; import 'package:stream_chat_flutter/src/reaction_icon.dart'; import 'package:stream_chat_flutter/src/utils.dart'; -import 'package:stream_chat_flutter/src/extension.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart'; /// Inherited widget providing the [StreamChatThemeData] to the widget tree class StreamChatTheme extends InheritedWidget { - final StreamChatThemeData data; - - StreamChatTheme({ - Key key, - @required this.data, - Widget child, + /// Constructor for creating a [StreamChatTheme] + const StreamChatTheme({ + Key? key, + required this.data, + required Widget child, }) : super( key: key, child: child, ); + /// Theme data + final StreamChatThemeData data; + @override - bool updateShouldNotify(StreamChatTheme old) { - return data != old.data; - } + bool updateShouldNotify(StreamChatTheme old) => data != old.data; /// Use this method to get the current [StreamChatThemeData] instance static StreamChatThemeData of(BuildContext context) { final streamChatTheme = context.dependOnInheritedWidgetOfExactType(); - if (streamChatTheme == null) { - throw Exception( - 'You must have a StreamChatTheme widget at the top of your widget tree', - ); - } + assert( + streamChatTheme != null, + 'You must have a StreamChatTheme widget at the top of your widget tree', + ); - return streamChatTheme.data; + return streamChatTheme!.data; } } /// Theme data class StreamChatThemeData { + /// Create a theme from scratch + factory StreamChatThemeData({ + Brightness? brightness, + TextTheme? textTheme, + ColorTheme? colorTheme, + ChannelListHeaderTheme? channelListHeaderTheme, + ChannelPreviewTheme? channelPreviewTheme, + ChannelTheme? channelTheme, + MessageTheme? otherMessageTheme, + MessageTheme? ownMessageTheme, + MessageInputTheme? messageInputTheme, + Widget Function(BuildContext, User)? defaultUserImage, + IconThemeData? primaryIconTheme, + List? reactionIcons, + GalleryHeaderThemeData? imageHeaderTheme, + GalleryFooterThemeData? imageFooterTheme, + }) { + brightness ??= colorTheme?.brightness ?? Brightness.light; + final isDark = brightness == Brightness.dark; + textTheme ??= isDark ? TextTheme.dark() : TextTheme.light(); + colorTheme ??= isDark ? ColorTheme.dark() : ColorTheme.light(); + + final defaultData = fromColorAndTextTheme( + colorTheme, + textTheme, + ); + + final customizedData = defaultData.copyWith( + channelListHeaderTheme: channelListHeaderTheme, + channelPreviewTheme: channelPreviewTheme, + channelTheme: channelTheme, + otherMessageTheme: otherMessageTheme, + ownMessageTheme: ownMessageTheme, + messageInputTheme: messageInputTheme, + defaultUserImage: defaultUserImage, + primaryIconTheme: primaryIconTheme, + reactionIcons: reactionIcons, + galleryHeaderTheme: imageHeaderTheme, + galleryFooterTheme: imageFooterTheme, + ); + + return defaultData.merge(customizedData); + } + + /// Theme initialised with light + factory StreamChatThemeData.light() => + StreamChatThemeData(brightness: Brightness.light); + + /// Theme initialised with dark + factory StreamChatThemeData.dark() => + StreamChatThemeData(brightness: Brightness.dark); + + /// Raw theme init + const StreamChatThemeData.raw({ + required this.textTheme, + required this.colorTheme, + required this.channelListHeaderTheme, + required this.channelPreviewTheme, + required this.channelTheme, + required this.otherMessageTheme, + required this.ownMessageTheme, + required this.messageInputTheme, + required this.defaultUserImage, + required this.primaryIconTheme, + required this.reactionIcons, + required this.galleryHeaderTheme, + required this.galleryFooterTheme, + }); + + /// Create a theme from a Material [Theme] + factory StreamChatThemeData.fromTheme(ThemeData theme) { + final defaultTheme = StreamChatThemeData(brightness: theme.brightness); + final customizedTheme = StreamChatThemeData.fromColorAndTextTheme( + defaultTheme.colorTheme.copyWith( + accentPrimary: theme.accentColor, + ), + defaultTheme.textTheme, + ); + return defaultTheme.merge(customizedTheme); + } + /// The text themes used in the widgets final TextTheme textTheme; @@ -58,6 +140,14 @@ class StreamChatThemeData { /// Theme of the chat widgets dedicated to a channel final ChannelTheme channelTheme; + /// The default style for [GalleryHeader]s below the overall + /// [StreamChatTheme]. + final GalleryHeaderThemeData galleryHeaderTheme; + + /// The default style for [GalleryFooter]s below the overall + /// [StreamChatTheme]. + final GalleryFooterThemeData galleryFooterTheme; + /// Theme of the current user messages final MessageTheme ownMessageTheme; @@ -67,9 +157,6 @@ class StreamChatThemeData { /// Theme dedicated to the [MessageInput] widget final MessageInputTheme messageInputTheme; - /// The widget that will be built when the channel image is unavailable - final Widget Function(BuildContext, Channel) defaultChannelImage; - /// The widget that will be built when the user image is unavailable final Widget Function(BuildContext, User) defaultUserImage; @@ -79,101 +166,109 @@ class StreamChatThemeData { /// Assets used for rendering reactions final List reactionIcons; - /// Create a theme from scratch - const StreamChatThemeData({ - this.textTheme, - this.colorTheme, - this.channelListHeaderTheme, - this.channelPreviewTheme, - this.channelTheme, - this.otherMessageTheme, - this.ownMessageTheme, - this.messageInputTheme, - this.defaultChannelImage, - this.defaultUserImage, - this.primaryIconTheme, - this.reactionIcons, - }); - - /// Create a theme from a Material [Theme] - factory StreamChatThemeData.fromTheme(ThemeData theme) { - final defaultTheme = getDefaultTheme(theme); - final customizedTheme = StreamChatThemeData.fromColorAndTextTheme( - defaultTheme.colorTheme.copyWith( - accentBlue: theme.accentColor, - ), - defaultTheme.textTheme, - ); - return defaultTheme.merge(customizedTheme) ?? customizedTheme; - } - - /// Creates a copy of [StreamChatThemeData] with specified attributes overridden. + /// Creates a copy of [StreamChatThemeData] with specified attributes + /// overridden. StreamChatThemeData copyWith({ - TextTheme textTheme, - ColorTheme colorTheme, - ChannelPreviewTheme channelPreviewTheme, - ChannelTheme channelTheme, - MessageTheme ownMessageTheme, - MessageTheme otherMessageTheme, - MessageInputTheme messageInputTheme, - Widget Function(BuildContext, Channel) defaultChannelImage, - Widget Function(BuildContext, User) defaultUserImage, - IconThemeData primaryIconTheme, - ChannelListHeaderTheme channelListHeaderTheme, - List reactionIcons, + TextTheme? textTheme, + ColorTheme? colorTheme, + ChannelPreviewTheme? channelPreviewTheme, + ChannelTheme? channelTheme, + MessageTheme? ownMessageTheme, + MessageTheme? otherMessageTheme, + MessageInputTheme? messageInputTheme, + Widget Function(BuildContext, User)? defaultUserImage, + IconThemeData? primaryIconTheme, + ChannelListHeaderTheme? channelListHeaderTheme, + List? reactionIcons, + GalleryHeaderThemeData? galleryHeaderTheme, + GalleryFooterThemeData? galleryFooterTheme, }) => - StreamChatThemeData( + StreamChatThemeData.raw( channelListHeaderTheme: - channelListHeaderTheme ?? this.channelListHeaderTheme, - textTheme: textTheme ?? this.textTheme, - colorTheme: colorTheme ?? this.colorTheme, - primaryIconTheme: primaryIconTheme ?? this.primaryIconTheme, - defaultChannelImage: defaultChannelImage ?? this.defaultChannelImage, + this.channelListHeaderTheme.merge(channelListHeaderTheme), + textTheme: this.textTheme.merge(textTheme), + colorTheme: this.colorTheme.merge(colorTheme), + primaryIconTheme: this.primaryIconTheme.merge(primaryIconTheme), defaultUserImage: defaultUserImage ?? this.defaultUserImage, - channelPreviewTheme: channelPreviewTheme ?? this.channelPreviewTheme, - channelTheme: channelTheme ?? this.channelTheme, - ownMessageTheme: ownMessageTheme ?? this.ownMessageTheme, - otherMessageTheme: otherMessageTheme ?? this.otherMessageTheme, - messageInputTheme: messageInputTheme ?? this.messageInputTheme, + channelPreviewTheme: + this.channelPreviewTheme.merge(channelPreviewTheme), + channelTheme: this.channelTheme.merge(channelTheme), + ownMessageTheme: this.ownMessageTheme.merge(ownMessageTheme), + otherMessageTheme: this.otherMessageTheme.merge(otherMessageTheme), + messageInputTheme: this.messageInputTheme.merge(messageInputTheme), reactionIcons: reactionIcons ?? this.reactionIcons, + galleryHeaderTheme: galleryHeaderTheme ?? this.galleryHeaderTheme, + galleryFooterTheme: galleryFooterTheme ?? this.galleryFooterTheme, ); - StreamChatThemeData merge(StreamChatThemeData other) { + /// Merge themes + StreamChatThemeData merge(StreamChatThemeData? other) { if (other == null) return this; return copyWith( channelListHeaderTheme: - channelListHeaderTheme?.merge(other.channelListHeaderTheme) ?? - other.channelListHeaderTheme, - textTheme: textTheme?.merge(other.textTheme) ?? other.textTheme, - colorTheme: colorTheme?.merge(other.colorTheme) ?? other.colorTheme, + channelListHeaderTheme.merge(other.channelListHeaderTheme), + textTheme: textTheme.merge(other.textTheme), + colorTheme: colorTheme.merge(other.colorTheme), primaryIconTheme: other.primaryIconTheme, - defaultChannelImage: other.defaultChannelImage, defaultUserImage: other.defaultUserImage, - channelPreviewTheme: - channelPreviewTheme?.merge(other.channelPreviewTheme) ?? - other.channelPreviewTheme, - channelTheme: - channelTheme?.merge(other.channelTheme) ?? other.channelTheme, - ownMessageTheme: ownMessageTheme?.merge(other.ownMessageTheme) ?? - other.ownMessageTheme, - otherMessageTheme: otherMessageTheme?.merge(other.otherMessageTheme) ?? - other.otherMessageTheme, - messageInputTheme: messageInputTheme?.merge(other.messageInputTheme) ?? - other.messageInputTheme, + channelPreviewTheme: channelPreviewTheme.merge(other.channelPreviewTheme), + channelTheme: channelTheme.merge(other.channelTheme), + ownMessageTheme: ownMessageTheme.merge(other.ownMessageTheme), + otherMessageTheme: otherMessageTheme.merge(other.otherMessageTheme), + messageInputTheme: messageInputTheme.merge(other.messageInputTheme), reactionIcons: other.reactionIcons, + galleryHeaderTheme: galleryHeaderTheme.merge(other.galleryHeaderTheme), + galleryFooterTheme: galleryFooterTheme.merge(other.galleryFooterTheme), ); } + /// Create theme from color and text theme + // ignore: prefer_constructors_over_static_methods static StreamChatThemeData fromColorAndTextTheme( ColorTheme colorTheme, TextTheme textTheme, ) { - final accentColor = colorTheme.accentBlue; - return StreamChatThemeData( + final accentColor = colorTheme.accentPrimary; + final iconTheme = + IconThemeData(color: colorTheme.textHighEmphasis.withOpacity(.5)); + final channelTheme = ChannelTheme( + channelHeaderTheme: ChannelHeaderTheme( + avatarTheme: AvatarTheme( + borderRadius: BorderRadius.circular(20), + constraints: const BoxConstraints.tightFor( + height: 40, + width: 40, + ), + ), + color: colorTheme.barsBg, + title: textTheme.headlineBold, + subtitle: textTheme.footnote.copyWith( + color: const Color(0xff7A7A7A), + ), + ), + ); + final channelPreviewTheme = ChannelPreviewTheme( + unreadCounterColor: colorTheme.accentError, + avatarTheme: AvatarTheme( + borderRadius: BorderRadius.circular(20), + constraints: const BoxConstraints.tightFor( + height: 40, + width: 40, + ), + ), + title: textTheme.bodyBold, + subtitle: textTheme.footnote.copyWith( + color: const Color(0xff7A7A7A), + ), + lastMessageAt: textTheme.footnote.copyWith( + color: colorTheme.textHighEmphasis.withOpacity(.5), + ), + indicatorIconSize: 16, + ); + return StreamChatThemeData.raw( textTheme: textTheme, colorTheme: colorTheme, - primaryIconTheme: IconThemeData(color: colorTheme.black.withOpacity(.5)), - defaultChannelImage: (context, channel) => SizedBox(), + primaryIconTheme: iconTheme, defaultUserImage: (context, user) => Center( child: CachedNetworkImage( filterQuality: FilterQuality.high, @@ -181,63 +276,34 @@ class StreamChatThemeData { fit: BoxFit.cover, ), ), - channelPreviewTheme: ChannelPreviewTheme( - unreadCounterColor: colorTheme.accentRed, - avatarTheme: AvatarTheme( - borderRadius: BorderRadius.circular(20), - constraints: BoxConstraints.tightFor( - height: 40, - width: 40, - ), - ), - title: textTheme.bodyBold, - subtitle: textTheme.footnote.copyWith( - color: Color(0xff7A7A7A), - ), - lastMessageAt: textTheme.footnote.copyWith( - color: colorTheme.black.withOpacity(.5), - ), - indicatorIconSize: 16.0), + channelPreviewTheme: channelPreviewTheme, channelListHeaderTheme: ChannelListHeaderTheme( avatarTheme: AvatarTheme( borderRadius: BorderRadius.circular(20), - constraints: BoxConstraints.tightFor( + constraints: const BoxConstraints.tightFor( height: 40, width: 40, ), ), - color: colorTheme.white, + color: colorTheme.barsBg, title: textTheme.headlineBold, ), - channelTheme: ChannelTheme( - channelHeaderTheme: ChannelHeaderTheme( - avatarTheme: AvatarTheme( - borderRadius: BorderRadius.circular(20), - constraints: BoxConstraints.tightFor( - height: 40, - width: 40, - ), - ), - color: colorTheme.white, - title: textTheme.headlineBold, - subtitle: textTheme.footnote.copyWith( - color: Color(0xff7A7A7A), - ), - ), - ), + channelTheme: channelTheme, ownMessageTheme: MessageTheme( - messageAuthor: textTheme.footnote.copyWith(color: colorTheme.grey), + messageAuthor: + textTheme.footnote.copyWith(color: colorTheme.textLowEmphasis), messageText: textTheme.body, - createdAt: textTheme.footnote.copyWith(color: colorTheme.grey), + createdAt: + textTheme.footnote.copyWith(color: colorTheme.textLowEmphasis), replies: textTheme.footnoteBold.copyWith(color: accentColor), - messageBackgroundColor: colorTheme.greyGainsboro, - reactionsBackgroundColor: colorTheme.white, - reactionsBorderColor: colorTheme.greyWhisper, - reactionsMaskColor: colorTheme.whiteSnow, - messageBorderColor: colorTheme.greyGainsboro, + messageBackgroundColor: colorTheme.disabled, + reactionsBackgroundColor: colorTheme.barsBg, + reactionsBorderColor: colorTheme.borders, + reactionsMaskColor: colorTheme.appBg, + messageBorderColor: colorTheme.disabled, avatarTheme: AvatarTheme( borderRadius: BorderRadius.circular(20), - constraints: BoxConstraints.tightFor( + constraints: const BoxConstraints.tightFor( height: 32, width: 32, ), @@ -247,21 +313,23 @@ class StreamChatThemeData { ), ), otherMessageTheme: MessageTheme( - reactionsBackgroundColor: colorTheme.greyGainsboro, - reactionsBorderColor: colorTheme.white, - reactionsMaskColor: colorTheme.whiteSnow, + reactionsBackgroundColor: colorTheme.disabled, + reactionsBorderColor: colorTheme.barsBg, + reactionsMaskColor: colorTheme.appBg, messageText: textTheme.body, - createdAt: textTheme.footnote.copyWith(color: colorTheme.grey), - messageAuthor: textTheme.footnote.copyWith(color: colorTheme.grey), + createdAt: + textTheme.footnote.copyWith(color: colorTheme.textLowEmphasis), + messageAuthor: + textTheme.footnote.copyWith(color: colorTheme.textLowEmphasis), replies: textTheme.footnoteBold.copyWith(color: accentColor), messageLinks: TextStyle( color: accentColor, ), - messageBackgroundColor: colorTheme.white, - messageBorderColor: colorTheme.greyWhisper, + messageBackgroundColor: colorTheme.barsBg, + messageBorderColor: colorTheme.borders, avatarTheme: AvatarTheme( borderRadius: BorderRadius.circular(20), - constraints: BoxConstraints.tightFor( + constraints: const BoxConstraints.tightFor( height: 32, width: 32, ), @@ -269,79 +337,114 @@ class StreamChatThemeData { ), messageInputTheme: MessageInputTheme( borderRadius: BorderRadius.circular(20), - sendAnimationDuration: Duration(milliseconds: 300), - actionButtonColor: colorTheme.accentBlue, - actionButtonIdleColor: colorTheme.grey, - expandButtonColor: colorTheme.accentBlue, - sendButtonColor: colorTheme.accentBlue, - sendButtonIdleColor: colorTheme.greyGainsboro, - inputBackground: colorTheme.white, + sendAnimationDuration: const Duration(milliseconds: 300), + actionButtonColor: colorTheme.accentPrimary, + actionButtonIdleColor: colorTheme.textLowEmphasis, + expandButtonColor: colorTheme.accentPrimary, + sendButtonColor: colorTheme.accentPrimary, + sendButtonIdleColor: colorTheme.disabled, + inputBackground: colorTheme.barsBg, inputTextStyle: textTheme.body, idleBorderGradient: LinearGradient( colors: [ - colorTheme.greyGainsboro, - colorTheme.greyGainsboro, + colorTheme.disabled, + colorTheme.disabled, ], ), activeBorderGradient: LinearGradient( colors: [ - colorTheme.greyGainsboro, - colorTheme.greyGainsboro, + colorTheme.disabled, + colorTheme.disabled, ], ), ), reactionIcons: [ ReactionIcon( type: 'love', - assetName: 'Icon_love_reaction.svg', + builder: (context, highlighted, size) { + final theme = StreamChatTheme.of(context); + return StreamSvgIcon.loveReaction( + color: highlighted + ? theme.colorTheme.accentPrimary + : theme.primaryIconTheme.color!.withOpacity(.5), + size: size, + ); + }, ), ReactionIcon( type: 'like', - assetName: 'Icon_thumbs_up_reaction.svg', + builder: (context, highlighted, size) { + final theme = StreamChatTheme.of(context); + return StreamSvgIcon.thumbsUpReaction( + color: highlighted + ? theme.colorTheme.accentPrimary + : theme.primaryIconTheme.color!.withOpacity(.5), + size: size, + ); + }, ), ReactionIcon( type: 'sad', - assetName: 'Icon_thumbs_down_reaction.svg', + builder: (context, highlighted, size) { + final theme = StreamChatTheme.of(context); + return StreamSvgIcon.thumbsDownReaction( + color: highlighted + ? theme.colorTheme.accentPrimary + : theme.primaryIconTheme.color!.withOpacity(.5), + size: size, + ); + }, ), ReactionIcon( type: 'haha', - assetName: 'Icon_LOL_reaction.svg', + builder: (context, highlighted, size) { + final theme = StreamChatTheme.of(context); + return StreamSvgIcon.lolReaction( + color: highlighted + ? theme.colorTheme.accentPrimary + : theme.primaryIconTheme.color!.withOpacity(.5), + size: size, + ); + }, ), ReactionIcon( type: 'wow', - assetName: 'Icon_wut_reaction.svg', + builder: (context, highlighted, size) { + final theme = StreamChatTheme.of(context); + return StreamSvgIcon.wutReaction( + color: highlighted + ? theme.colorTheme.accentPrimary + : theme.primaryIconTheme.color!.withOpacity(.5), + size: size, + ); + }, ), ], - ); - } - - /// Get the default Stream Chat theme - static StreamChatThemeData getDefaultTheme(ThemeData theme) { - final isDark = theme.brightness == Brightness.dark; - final textTheme = isDark ? TextTheme.dark() : TextTheme.light(); - final colorTheme = isDark ? ColorTheme.dark() : ColorTheme.light(); - return fromColorAndTextTheme( - colorTheme, - textTheme, + galleryHeaderTheme: GalleryHeaderThemeData( + closeButtonColor: colorTheme.textHighEmphasis, + backgroundColor: channelTheme.channelHeaderTheme.color, + iconMenuPointColor: colorTheme.textHighEmphasis, + titleTextStyle: textTheme.headlineBold, + subtitleTextStyle: channelPreviewTheme.subtitle, + bottomSheetBarrierColor: colorTheme.overlay, + ), + galleryFooterTheme: GalleryFooterThemeData( + backgroundColor: colorTheme.barsBg, + shareIconColor: colorTheme.textHighEmphasis, + titleTextStyle: textTheme.headlineBold, + gridIconButtonColor: colorTheme.textHighEmphasis, + bottomSheetBarrierColor: colorTheme.overlay, + bottomSheetBackgroundColor: colorTheme.barsBg, + bottomSheetPhotosTextStyle: textTheme.headlineBold, + bottomSheetCloseIconColor: colorTheme.textHighEmphasis, + ), ); } } -enum TextThemeType { - light, - dark, -} - +/// Class for holding text theme class TextTheme { - final TextStyle title; - final TextStyle headlineBold; - final TextStyle headline; - final TextStyle bodyBold; - final TextStyle body; - final TextStyle footnoteBold; - final TextStyle footnote; - final TextStyle captionBold; - + /// Initialise light text theme TextTheme.light({ this.title = const TextStyle( fontSize: 22, @@ -384,6 +487,7 @@ class TextTheme { ), }); + /// Initialise with dark theme TextTheme.dark({ this.title = const TextStyle( fontSize: 22, @@ -426,95 +530,95 @@ class TextTheme { ), }); - TextTheme copyWith({ - TextThemeType type = TextThemeType.light, - TextStyle body, - TextStyle title, - TextStyle headlineBold, - TextStyle headline, - TextStyle bodyBold, - TextStyle footnoteBold, - TextStyle footnote, - TextStyle captionBold, - }) { - return type == TextThemeType.light - ? TextTheme.light( - body: body ?? this.body, - title: title ?? this.title, - headlineBold: headlineBold ?? this.headlineBold, - headline: headline ?? this.headline, - bodyBold: bodyBold ?? this.bodyBold, - footnoteBold: footnoteBold ?? this.footnoteBold, - footnote: footnote ?? this.footnote, - captionBold: captionBold ?? this.captionBold, - ) - : TextTheme.dark( - body: body ?? this.body, - title: title ?? this.title, - headlineBold: headlineBold ?? this.headlineBold, - headline: headline ?? this.headline, - bodyBold: bodyBold ?? this.bodyBold, - footnoteBold: footnoteBold ?? this.footnoteBold, - footnote: footnote ?? this.footnote, - captionBold: captionBold ?? this.captionBold, - ); - } + /// Text theme for title + final TextStyle title; - TextTheme merge(TextTheme other) { + /// Body Text theme for headline + final TextStyle headlineBold; + + /// Text theme for headline + final TextStyle headline; + + /// Bold Text theme for body + final TextStyle bodyBold; + + /// Text theme body + final TextStyle body; + + /// Bold Text theme for footnote + final TextStyle footnoteBold; + + /// Text theme for footnote + final TextStyle footnote; + + /// Bold Text theme for caption + final TextStyle captionBold; + + /// Copy with theme + TextTheme copyWith({ + Brightness brightness = Brightness.light, + TextStyle? body, + TextStyle? title, + TextStyle? headlineBold, + TextStyle? headline, + TextStyle? bodyBold, + TextStyle? footnoteBold, + TextStyle? footnote, + TextStyle? captionBold, + }) => + brightness == Brightness.light + ? TextTheme.light( + body: body ?? this.body, + title: title ?? this.title, + headlineBold: headlineBold ?? this.headlineBold, + headline: headline ?? this.headline, + bodyBold: bodyBold ?? this.bodyBold, + footnoteBold: footnoteBold ?? this.footnoteBold, + footnote: footnote ?? this.footnote, + captionBold: captionBold ?? this.captionBold, + ) + : TextTheme.dark( + body: body ?? this.body, + title: title ?? this.title, + headlineBold: headlineBold ?? this.headlineBold, + headline: headline ?? this.headline, + bodyBold: bodyBold ?? this.bodyBold, + footnoteBold: footnoteBold ?? this.footnoteBold, + footnote: footnote ?? this.footnote, + captionBold: captionBold ?? this.captionBold, + ); + + /// Merge text theme + TextTheme merge(TextTheme? other) { if (other == null) return this; return copyWith( - body: body?.merge(other.body) ?? other.body, - title: title?.merge(other.title) ?? other.title, - headlineBold: - headlineBold?.merge(other.headlineBold) ?? other.headlineBold, - headline: headline?.merge(other.headline) ?? other.headline, - bodyBold: bodyBold?.merge(other.bodyBold) ?? other.bodyBold, - footnoteBold: - footnoteBold?.merge(other.footnoteBold) ?? other.footnoteBold, - footnote: footnote?.merge(other.footnote) ?? other.footnote, - captionBold: captionBold?.merge(other.captionBold) ?? other.captionBold, + body: body.merge(other.body), + title: title.merge(other.title), + headlineBold: headlineBold.merge(other.headlineBold), + headline: headline.merge(other.headline), + bodyBold: bodyBold.merge(other.bodyBold), + footnoteBold: footnoteBold.merge(other.footnoteBold), + footnote: footnote.merge(other.footnote), + captionBold: captionBold.merge(other.captionBold), ); } } -enum ColorThemeType { - light, - dark, -} - +/// Theme that holds colors class ColorTheme { - final Color black; - final Color grey; - final Color greyGainsboro; - final Color greyWhisper; - final Color whiteSmoke; - final Color whiteSnow; - final Color white; - final Color blueAlice; - final Color accentBlue; - final Color accentRed; - final Color accentGreen; - final Effect borderTop; - final Effect borderBottom; - final Effect shadowIconButton; - final Effect modalShadow; - final Color highlight; - final Color overlay; - final Color overlayDark; - final Gradient bgGradient; - + /// Initialise with light theme ColorTheme.light({ - this.black = const Color(0xff000000), - this.grey = const Color(0xff7a7a7a), - this.greyGainsboro = const Color(0xffdbdbdb), - this.greyWhisper = const Color(0xffecebeb), - this.whiteSmoke = const Color(0xfff2f2f2), - this.whiteSnow = const Color(0xfffcfcfc), - this.white = const Color(0xffffffff), - this.blueAlice = const Color(0xffe9f2ff), - this.accentBlue = const Color(0xff005FFF), - this.accentRed = const Color(0xffFF3842), - this.accentGreen = const Color(0xff20E070), + this.textHighEmphasis = const Color(0xff000000), + this.textLowEmphasis = const Color(0xff7a7a7a), + this.disabled = const Color(0xffdbdbdb), + this.borders = const Color(0xffecebeb), + this.inputBg = const Color(0xfff2f2f2), + this.appBg = const Color(0xfffcfcfc), + this.barsBg = const Color(0xffffffff), + this.linkBg = const Color(0xffe9f2ff), + this.accentPrimary = const Color(0xff005FFF), + this.accentError = const Color(0xffFF3842), + this.accentInfo = const Color(0xff20E070), this.highlight = const Color(0xfffbf4dd), this.overlay = const Color.fromRGBO(0, 0, 0, 0.2), this.overlayDark = const Color.fromRGBO(0, 0, 0, 0.6), @@ -525,39 +629,55 @@ class ColorTheme { stops: [0, 1], ), this.borderTop = const Effect( - sigmaX: 0, - sigmaY: -1, - color: Color(0xff000000), - blur: 0.0, - alpha: 0.08), + sigmaX: 0, sigmaY: -1, color: Color(0xff000000), blur: 0, alpha: 0.08), this.borderBottom = const Effect( - sigmaX: 0, sigmaY: 1, color: Color(0xff000000), blur: 0.0, alpha: 0.08), + sigmaX: 0, sigmaY: 1, color: Color(0xff000000), blur: 0, alpha: 0.08), this.shadowIconButton = const Effect( - sigmaX: 0, sigmaY: 2, color: Color(0xff000000), alpha: 0.5, blur: 4.0), + sigmaX: 0, sigmaY: 2, color: Color(0xff000000), alpha: 0.5, blur: 4), this.modalShadow = const Effect( - sigmaX: 0, sigmaY: 0, color: Color(0xff000000), alpha: 1, blur: 8.0), - }); + sigmaX: 0, sigmaY: 0, color: Color(0xff000000), alpha: 1, blur: 8), + }) : brightness = Brightness.light; + /// Initialise with dark theme ColorTheme.dark({ - this.black = const Color(0xffffffff), - this.grey = const Color(0xff7a7a7a), - this.greyGainsboro = const Color(0xff2d2f2f), - this.greyWhisper = const Color(0xff1c1e22), - this.whiteSmoke = const Color(0xff13151b), - this.whiteSnow = const Color(0xff070A0D), - this.white = const Color(0xff101418), - this.blueAlice = const Color(0xff00193D), - this.accentBlue = const Color(0xff005FFF), - this.accentRed = const Color(0xffFF3742), - this.accentGreen = const Color(0xff20E070), + this.textHighEmphasis = const Color(0xffffffff), + this.textLowEmphasis = const Color(0xff7a7a7a), + this.disabled = const Color(0xff2d2f2f), + this.borders = const Color(0xff1c1e22), + this.inputBg = const Color(0xff13151b), + this.appBg = const Color(0xff070A0D), + this.barsBg = const Color(0xff101418), + this.linkBg = const Color(0xff00193D), + this.accentPrimary = const Color(0xff005FFF), + this.accentError = const Color(0xffFF3742), + this.accentInfo = const Color(0xff20E070), this.borderTop = const Effect( - sigmaX: 0, sigmaY: -1, color: Color(0xff141924), blur: 0.0), + sigmaX: 0, + sigmaY: -1, + color: Color(0xff141924), + blur: 0, + ), this.borderBottom = const Effect( - sigmaX: 0, sigmaY: 1, color: Color(0xff141924), blur: 0.0, alpha: 1.0), + sigmaX: 0, + sigmaY: 1, + color: Color(0xff141924), + blur: 0, + alpha: 1, + ), this.shadowIconButton = const Effect( - sigmaX: 0, sigmaY: 2, color: Color(0xff000000), alpha: 0.5, blur: 4.0), + sigmaX: 0, + sigmaY: 2, + color: Color(0xff000000), + alpha: 0.5, + blur: 4, + ), this.modalShadow = const Effect( - sigmaX: 0, sigmaY: 0, color: Color(0xff000000), alpha: 1, blur: 8.0), + sigmaX: 0, + sigmaY: 0, + color: Color(0xff000000), + alpha: 1, + blur: 8, + ), this.highlight = const Color(0xff302d22), this.overlay = const Color.fromRGBO(0, 0, 0, 0.4), this.overlayDark = const Color.fromRGBO(255, 255, 255, 0.6), @@ -570,89 +690,150 @@ class ColorTheme { ], stops: [0, 1], ), - }); + }) : brightness = Brightness.dark; + /// + final Color textHighEmphasis; + + /// + final Color textLowEmphasis; + + /// + final Color disabled; + + /// + final Color borders; + + /// + final Color inputBg; + + /// + final Color appBg; + + /// + final Color barsBg; + + /// + final Color linkBg; + + /// + final Color accentPrimary; + + /// + final Color accentError; + + /// + final Color accentInfo; + + /// + final Effect borderTop; + + /// + final Effect borderBottom; + + /// + final Effect shadowIconButton; + + /// + final Effect modalShadow; + + /// + final Color highlight; + + /// + final Color overlay; + + /// + final Color overlayDark; + + /// + final Gradient bgGradient; + + /// + final Brightness brightness; + + /// Copy with theme ColorTheme copyWith({ - ColorThemeType type = ColorThemeType.light, - Color black, - Color grey, - Color greyGainsboro, - Color greyWhisper, - Color whiteSmoke, - Color whiteSnow, - Color white, - Color blueAlice, - Color accentBlue, - Color accentRed, - Color accentGreen, - Effect borderTop, - Effect borderBottom, - Effect shadowIconButton, - Effect modalShadow, - Color highlight, - Color overlay, - Color overlayDark, - Gradient bgGradient, - }) { - return type == ColorThemeType.light - ? ColorTheme.light( - black: black ?? this.black, - grey: grey ?? this.grey, - greyGainsboro: greyGainsboro ?? this.greyGainsboro, - greyWhisper: greyWhisper ?? this.greyWhisper, - whiteSmoke: whiteSmoke ?? this.whiteSmoke, - whiteSnow: whiteSnow ?? this.whiteSnow, - white: white ?? this.white, - blueAlice: blueAlice ?? this.blueAlice, - accentBlue: accentBlue ?? this.accentBlue, - accentRed: accentRed ?? this.accentRed, - accentGreen: accentGreen ?? this.accentGreen, - borderTop: borderTop ?? this.borderTop, - borderBottom: borderBottom ?? this.borderBottom, - shadowIconButton: shadowIconButton ?? this.shadowIconButton, - modalShadow: modalShadow ?? this.modalShadow, - highlight: highlight ?? this.highlight, - overlay: overlay ?? this.overlay, - overlayDark: overlayDark ?? this.overlayDark, - bgGradient: bgGradient ?? this.bgGradient, - ) - : ColorTheme.dark( - black: black ?? this.black, - grey: grey ?? this.grey, - greyGainsboro: greyGainsboro ?? this.greyGainsboro, - greyWhisper: greyWhisper ?? this.greyWhisper, - whiteSmoke: whiteSmoke ?? this.whiteSmoke, - whiteSnow: whiteSnow ?? this.whiteSnow, - white: white ?? this.white, - blueAlice: blueAlice ?? this.blueAlice, - accentBlue: accentBlue ?? this.accentBlue, - accentRed: accentRed ?? this.accentRed, - accentGreen: accentGreen ?? this.accentGreen, - borderTop: borderTop ?? this.borderTop, - borderBottom: borderBottom ?? this.borderBottom, - shadowIconButton: shadowIconButton ?? this.shadowIconButton, - modalShadow: modalShadow ?? this.modalShadow, - highlight: highlight ?? this.highlight, - overlay: overlay ?? this.overlay, - overlayDark: overlayDark ?? this.overlayDark, - bgGradient: bgGradient ?? this.bgGradient, - ); - } + Brightness brightness = Brightness.light, + Color? textHighEmphasis, + Color? textLowEmphasis, + Color? disabled, + Color? borders, + Color? inputBg, + Color? appBg, + Color? barsBg, + Color? linkBg, + Color? accentPrimary, + Color? accentError, + Color? accentInfo, + Effect? borderTop, + Effect? borderBottom, + Effect? shadowIconButton, + Effect? modalShadow, + Color? highlight, + Color? overlay, + Color? overlayDark, + Gradient? bgGradient, + }) => + brightness == Brightness.light + ? ColorTheme.light( + textHighEmphasis: textHighEmphasis ?? this.textHighEmphasis, + textLowEmphasis: textLowEmphasis ?? this.textLowEmphasis, + disabled: disabled ?? this.disabled, + borders: borders ?? this.borders, + inputBg: inputBg ?? this.inputBg, + appBg: appBg ?? this.appBg, + barsBg: barsBg ?? this.barsBg, + linkBg: linkBg ?? this.linkBg, + accentPrimary: accentPrimary ?? this.accentPrimary, + accentError: accentError ?? this.accentError, + accentInfo: accentInfo ?? this.accentInfo, + borderTop: borderTop ?? this.borderTop, + borderBottom: borderBottom ?? this.borderBottom, + shadowIconButton: shadowIconButton ?? this.shadowIconButton, + modalShadow: modalShadow ?? this.modalShadow, + highlight: highlight ?? this.highlight, + overlay: overlay ?? this.overlay, + overlayDark: overlayDark ?? this.overlayDark, + bgGradient: bgGradient ?? this.bgGradient, + ) + : ColorTheme.dark( + textHighEmphasis: textHighEmphasis ?? this.textHighEmphasis, + textLowEmphasis: textLowEmphasis ?? this.textLowEmphasis, + disabled: disabled ?? this.disabled, + borders: borders ?? this.borders, + inputBg: inputBg ?? this.inputBg, + appBg: appBg ?? this.appBg, + barsBg: barsBg ?? this.barsBg, + linkBg: linkBg ?? this.linkBg, + accentPrimary: accentPrimary ?? this.accentPrimary, + accentError: accentError ?? this.accentError, + accentInfo: accentInfo ?? this.accentInfo, + borderTop: borderTop ?? this.borderTop, + borderBottom: borderBottom ?? this.borderBottom, + shadowIconButton: shadowIconButton ?? this.shadowIconButton, + modalShadow: modalShadow ?? this.modalShadow, + highlight: highlight ?? this.highlight, + overlay: overlay ?? this.overlay, + overlayDark: overlayDark ?? this.overlayDark, + bgGradient: bgGradient ?? this.bgGradient, + ); - ColorTheme merge(ColorTheme other) { + /// Merge color theme + ColorTheme merge(ColorTheme? other) { if (other == null) return this; return copyWith( - black: other.black, - grey: other.grey, - greyGainsboro: other.greyGainsboro, - greyWhisper: other.greyWhisper, - whiteSmoke: other.whiteSmoke, - whiteSnow: other.whiteSnow, - white: other.white, - blueAlice: other.blueAlice, - accentBlue: other.accentBlue, - accentRed: other.accentRed, - accentGreen: other.accentGreen, + textHighEmphasis: other.textHighEmphasis, + textLowEmphasis: other.textLowEmphasis, + disabled: other.disabled, + borders: other.borders, + inputBg: other.inputBg, + appBg: other.appBg, + barsBg: other.barsBg, + linkBg: other.linkBg, + accentPrimary: other.accentPrimary, + accentError: other.accentError, + accentInfo: other.accentInfo, highlight: other.highlight, overlay: other.overlay, overlayDark: other.overlayDark, @@ -667,70 +848,77 @@ class ColorTheme { /// Channel theme data class ChannelTheme { + /// Constructor for creating [ChannelTheme] + ChannelTheme({ + required this.channelHeaderTheme, + }); + /// Theme of the [ChannelHeader] widget final ChannelHeaderTheme channelHeaderTheme; - ChannelTheme({ - this.channelHeaderTheme, - }); - /// Creates a copy of [ChannelTheme] with specified attributes overridden. ChannelTheme copyWith({ - ChannelHeaderTheme channelHeaderTheme, + ChannelHeaderTheme? channelHeaderTheme, }) => ChannelTheme( channelHeaderTheme: channelHeaderTheme ?? this.channelHeaderTheme, ); - ChannelTheme merge(ChannelTheme other) { + /// Merge with theme + ChannelTheme merge(ChannelTheme? other) { if (other == null) return this; return copyWith( - channelHeaderTheme: channelHeaderTheme?.merge(other.channelHeaderTheme) ?? - other.channelHeaderTheme, + channelHeaderTheme: channelHeaderTheme.merge(other.channelHeaderTheme), ); } } +/// Theme for avatar class AvatarTheme { - final BoxConstraints constraints; - final BorderRadius borderRadius; - + /// Constructor for creating [AvatarTheme] AvatarTheme({ - this.constraints, - this.borderRadius, - }); + BoxConstraints? constraints, + BorderRadius? borderRadius, + }) : _constraints = constraints, + _borderRadius = borderRadius; - AvatarTheme copyWith({ - BoxConstraints constraints, - BorderRadius borderRadius, - }) => - AvatarTheme( - constraints: constraints ?? this.constraints, - borderRadius: borderRadius ?? this.borderRadius, + final BoxConstraints? _constraints; + final BorderRadius? _borderRadius; + + /// Get constraints for avatar + BoxConstraints get constraints => + _constraints ?? + const BoxConstraints.tightFor( + height: 32, + width: 32, ); - AvatarTheme merge(AvatarTheme other) { + /// Get border radius + BorderRadius get borderRadius => _borderRadius ?? BorderRadius.circular(20); + + /// Copy with another theme + AvatarTheme copyWith({ + BoxConstraints? constraints, + BorderRadius? borderRadius, + }) => + AvatarTheme( + constraints: constraints ?? _constraints, + borderRadius: borderRadius ?? _borderRadius, + ); + + /// Merge with another AvatarTheme + AvatarTheme merge(AvatarTheme? other) { if (other == null) return this; return copyWith( - constraints: other.constraints, - borderRadius: other.borderRadius, + constraints: other._constraints, + borderRadius: other._borderRadius, ); } } +/// Class for getting message theme class MessageTheme { - final TextStyle messageText; - final TextStyle messageAuthor; - final TextStyle messageLinks; - final TextStyle createdAt; - final TextStyle replies; - final Color messageBackgroundColor; - final Color messageBorderColor; - final Color reactionsBackgroundColor; - final Color reactionsBorderColor; - final Color reactionsMaskColor; - final AvatarTheme avatarTheme; - + /// Constructor into [MessageTheme] const MessageTheme({ this.replies, this.messageText, @@ -745,18 +933,52 @@ class MessageTheme { this.createdAt, }); + /// Text style for message text + final TextStyle? messageText; + + /// Text style for message author + final TextStyle? messageAuthor; + + /// Text style for message links + final TextStyle? messageLinks; + + /// Text style for created at text + final TextStyle? createdAt; + + /// Text style for replies + final TextStyle? replies; + + /// Color for messageBackgroundColor + final Color? messageBackgroundColor; + + /// Color for message border color + final Color? messageBorderColor; + + /// Color for reactions + final Color? reactionsBackgroundColor; + + /// Colors reaction border + final Color? reactionsBorderColor; + + /// Color for reaction mask + final Color? reactionsMaskColor; + + /// Theme of the avatar + final AvatarTheme? avatarTheme; + + /// Copy with a theme MessageTheme copyWith({ - TextStyle messageText, - TextStyle messageAuthor, - TextStyle messageLinks, - TextStyle createdAt, - TextStyle replies, - Color messageBackgroundColor, - Color messageBorderColor, - AvatarTheme avatarTheme, - Color reactionsBackgroundColor, - Color reactionsBorderColor, - Color reactionsMaskColor, + TextStyle? messageText, + TextStyle? messageAuthor, + TextStyle? messageLinks, + TextStyle? createdAt, + TextStyle? replies, + Color? messageBackgroundColor, + Color? messageBorderColor, + AvatarTheme? avatarTheme, + Color? reactionsBackgroundColor, + Color? reactionsBorderColor, + Color? reactionsMaskColor, }) => MessageTheme( messageText: messageText ?? this.messageText, @@ -774,7 +996,8 @@ class MessageTheme { reactionsMaskColor: reactionsMaskColor ?? this.reactionsMaskColor, ); - MessageTheme merge(MessageTheme other) { + /// Merge with a theme + MessageTheme merge(MessageTheme? other) { if (other == null) return this; return copyWith( messageText: messageText?.merge(other.messageText) ?? other.messageText, @@ -794,14 +1017,9 @@ class MessageTheme { } } +/// Theme for channel preview class ChannelPreviewTheme { - final TextStyle title; - final TextStyle subtitle; - final TextStyle lastMessageAt; - final AvatarTheme avatarTheme; - final Color unreadCounterColor; - final double indicatorIconSize; - + /// Constructor for creating [ChannelPreviewTheme] const ChannelPreviewTheme({ this.title, this.subtitle, @@ -811,13 +1029,32 @@ class ChannelPreviewTheme { this.indicatorIconSize, }); + /// Theme for title + final TextStyle? title; + + /// Theme for subtitle + final TextStyle? subtitle; + + /// Theme of last message at + final TextStyle? lastMessageAt; + + /// Avatar theme + final AvatarTheme? avatarTheme; + + /// Unread counter color + final Color? unreadCounterColor; + + /// Indicator icon size + final double? indicatorIconSize; + + /// Copy with theme ChannelPreviewTheme copyWith({ - TextStyle title, - TextStyle subtitle, - TextStyle lastMessageAt, - AvatarTheme avatarTheme, - Color unreadCounterColor, - double indicatorIconSize, + TextStyle? title, + TextStyle? subtitle, + TextStyle? lastMessageAt, + AvatarTheme? avatarTheme, + Color? unreadCounterColor, + double? indicatorIconSize, }) => ChannelPreviewTheme( title: title ?? this.title, @@ -828,7 +1065,8 @@ class ChannelPreviewTheme { indicatorIconSize: indicatorIconSize ?? this.indicatorIconSize, ); - ChannelPreviewTheme merge(ChannelPreviewTheme other) { + /// Merge with theme + ChannelPreviewTheme merge(ChannelPreviewTheme? other) { if (other == null) return this; return copyWith( title: title?.merge(other.title) ?? other.title, @@ -841,12 +1079,9 @@ class ChannelPreviewTheme { } } +/// Theme for [ChannelHeader] class ChannelHeaderTheme { - final TextStyle title; - final TextStyle subtitle; - final AvatarTheme avatarTheme; - final Color color; - + /// Constructor for creating a [ChannelHeaderTheme] const ChannelHeaderTheme({ this.title, this.subtitle, @@ -854,11 +1089,24 @@ class ChannelHeaderTheme { this.color, }); + /// Theme for title + final TextStyle? title; + + /// Theme for subtitle + final TextStyle? subtitle; + + /// Theme for avatar + final AvatarTheme? avatarTheme; + + /// Color for [ChannelHeaderTheme] + final Color? color; + + /// Copy with theme ChannelHeaderTheme copyWith({ - TextStyle title, - TextStyle subtitle, - AvatarTheme avatarTheme, - Color color, + TextStyle? title, + TextStyle? subtitle, + AvatarTheme? avatarTheme, + Color? color, }) => ChannelHeaderTheme( title: title ?? this.title, @@ -867,7 +1115,8 @@ class ChannelHeaderTheme { color: color ?? this.color, ); - ChannelHeaderTheme merge(ChannelHeaderTheme other) { + /// Merge with other [ChannelHeaderTheme] + ChannelHeaderTheme merge(ChannelHeaderTheme? other) { if (other == null) return this; return copyWith( title: title?.merge(other.title) ?? other.title, @@ -880,15 +1129,6 @@ class ChannelHeaderTheme { /// Theme dedicated to the [ChannelListHeader] class ChannelListHeaderTheme { - /// Style of the title text - final TextStyle title; - - /// Theme dedicated to the userAvatar - final AvatarTheme avatarTheme; - - /// Background color of the appbar - final Color color; - /// Returns a new [ChannelListHeaderTheme] const ChannelListHeaderTheme({ this.title, @@ -896,11 +1136,20 @@ class ChannelListHeaderTheme { this.color, }); + /// Style of the title text + final TextStyle? title; + + /// Theme dedicated to the userAvatar + final AvatarTheme? avatarTheme; + + /// Background color of the appbar + final Color? color; + /// Returns a new [ChannelListHeaderTheme] replacing some of its properties ChannelListHeaderTheme copyWith({ - TextStyle title, - AvatarTheme avatarTheme, - Color color, + TextStyle? title, + AvatarTheme? avatarTheme, + Color? color, }) => ChannelListHeaderTheme( title: title ?? this.title, @@ -909,7 +1158,7 @@ class ChannelListHeaderTheme { ); /// Merges [this] [ChannelListHeaderTheme] with the [other] - ChannelListHeaderTheme merge(ChannelListHeaderTheme other) { + ChannelListHeaderTheme merge(ChannelListHeaderTheme? other) { if (other == null) return this; return copyWith( title: title?.merge(other.title) ?? other.title, @@ -921,42 +1170,6 @@ class ChannelListHeaderTheme { /// Defines the theme dedicated to the [MessageInput] widget class MessageInputTheme { - /// Duration of the [MessageInput] send button animation - final Duration sendAnimationDuration; - - /// Background color of [MessageInput] send button - final Color sendButtonColor; - - /// Background color of [MessageInput] action buttons - final Color actionButtonColor; - - /// Background color of [MessageInput] send button - final Color sendButtonIdleColor; - - /// Background color of [MessageInput] action buttons - final Color actionButtonIdleColor; - - /// Background color of [MessageInput] expand button - final Color expandButtonColor; - - /// Background color of [MessageInput] - final Color inputBackground; - - /// TextStyle of [MessageInput] - final TextStyle inputTextStyle; - - /// InputDecoration of [MessageInput] - final InputDecoration inputDecoration; - - /// Border gradient when the [MessageInput] is not focused - final Gradient idleBorderGradient; - - /// Border gradient when the [MessageInput] is focused - final Gradient activeBorderGradient; - - /// Border radius of [MessageInput] - final BorderRadius borderRadius; - /// Returns a new [MessageInputTheme] const MessageInputTheme({ this.sendAnimationDuration, @@ -973,20 +1186,56 @@ class MessageInputTheme { this.expandButtonColor, }); + /// Duration of the [MessageInput] send button animation + final Duration? sendAnimationDuration; + + /// Background color of [MessageInput] send button + final Color? sendButtonColor; + + /// Background color of [MessageInput] action buttons + final Color? actionButtonColor; + + /// Background color of [MessageInput] send button + final Color? sendButtonIdleColor; + + /// Background color of [MessageInput] action buttons + final Color? actionButtonIdleColor; + + /// Background color of [MessageInput] expand button + final Color? expandButtonColor; + + /// Background color of [MessageInput] + final Color? inputBackground; + + /// TextStyle of [MessageInput] + final TextStyle? inputTextStyle; + + /// InputDecoration of [MessageInput] + final InputDecoration? inputDecoration; + + /// Border gradient when the [MessageInput] is not focused + final Gradient? idleBorderGradient; + + /// Border gradient when the [MessageInput] is focused + final Gradient? activeBorderGradient; + + /// Border radius of [MessageInput] + final BorderRadius? borderRadius; + /// Returns a new [MessageInputTheme] replacing some of its properties MessageInputTheme copyWith({ - Duration sendAnimationDuration, - Color inputBackground, - Color actionButtonColor, - Color sendButtonColor, - Color actionButtonIdleColor, - Color sendButtonIdleColor, - Color expandButtonColor, - TextStyle inputTextStyle, - InputDecoration inputDecoration, - Gradient activeBorderGradient, - Gradient idleBorderGradient, - BorderRadius borderRadius, + Duration? sendAnimationDuration, + Color? inputBackground, + Color? actionButtonColor, + Color? sendButtonColor, + Color? actionButtonIdleColor, + Color? sendButtonIdleColor, + Color? expandButtonColor, + TextStyle? inputTextStyle, + InputDecoration? inputDecoration, + Gradient? activeBorderGradient, + Gradient? idleBorderGradient, + BorderRadius? borderRadius, }) => MessageInputTheme( sendAnimationDuration: @@ -1006,7 +1255,7 @@ class MessageInputTheme { ); /// Merges [this] [MessageInputTheme] with the [other] - MessageInputTheme merge(MessageInputTheme other) { + MessageInputTheme merge(MessageInputTheme? other) { if (other == null) return this; return copyWith( sendAnimationDuration: other.sendAnimationDuration, @@ -1015,7 +1264,8 @@ class MessageInputTheme { actionButtonIdleColor: other.actionButtonIdleColor, sendButtonColor: other.sendButtonColor, sendButtonIdleColor: other.sendButtonIdleColor, - inputTextStyle: other.inputTextStyle, + inputTextStyle: + inputTextStyle?.merge(other.inputTextStyle) ?? other.inputTextStyle, inputDecoration: inputDecoration?.merge(other.inputDecoration) ?? other.inputDecoration, activeBorderGradient: other.activeBorderGradient, @@ -1026,13 +1276,9 @@ class MessageInputTheme { } } +/// Effect store class Effect { - final double sigmaX; - final double sigmaY; - final Color color; - final double alpha; - final double blur; - + /// Constructor for creating [Effect] const Effect({ this.sigmaX, this.sigmaY, @@ -1041,18 +1287,424 @@ class Effect { this.blur, }); + /// + final double? sigmaX; + + /// + final double? sigmaY; + + /// + final Color? color; + + /// + final double? alpha; + + /// + final double? blur; + + /// Copy with new effect Effect copyWith({ - double sigmaX, - double sigmaY, - Color color, - double alpha, - double blur, + double? sigmaX, + double? sigmaY, + Color? color, + double? alpha, + double? blur, }) => Effect( sigmaX: sigmaX ?? this.sigmaX, sigmaY: sigmaY ?? this.sigmaY, color: color ?? this.color, - alpha: color ?? this.alpha, + alpha: color as double? ?? this.alpha, blur: blur ?? this.blur, ); } + +/// Overrides the default style of [GalleryHeader] descendants. +/// +/// See also: +/// +/// * [GalleryHeaderThemeData], which is used to configure this theme. +class GalleryHeaderTheme extends InheritedTheme { + /// Creates an [GalleryHeaderTheme]. + /// + /// The [data] parameter must not be null. + const GalleryHeaderTheme({ + Key? key, + required this.data, + required Widget child, + }) : super(key: key, child: child); + + /// The configuration of this theme. + final GalleryHeaderThemeData data; + + /// The closest instance of this class that encloses the given context. + /// + /// If there is no enclosing [GalleryHeaderTheme] widget, then + /// [StreamChatThemeData.galleryHeaderTheme] is used. + /// + /// Typical usage is as follows: + /// + /// ```dart + /// ImageHeaderTheme theme = ImageHeaderTheme.of(context); + /// ``` + static GalleryHeaderThemeData of(BuildContext context) { + final galleryHeaderTheme = + context.dependOnInheritedWidgetOfExactType(); + return galleryHeaderTheme?.data ?? + StreamChatTheme.of(context).galleryHeaderTheme; + } + + @override + Widget wrap(BuildContext context, Widget child) => + GalleryHeaderTheme(data: data, child: child); + + @override + bool updateShouldNotify(GalleryHeaderTheme oldWidget) => + data != oldWidget.data; +} + +/// A style that overrides the default appearance of [GalleryHeader]s when used +/// with [GalleryHeaderTheme] or with the overall [StreamChatTheme]'s +/// [StreamChatThemeData.galleryHeaderTheme]. +/// +/// See also: +/// +/// * [GalleryHeaderTheme], the theme which is configured with this class. +/// * [StreamChatThemeData.galleryHeaderTheme], which can be used to override +/// the default style for [GalleryHeader]s below the overall [StreamChatTheme]. +class GalleryHeaderThemeData with Diagnosticable { + /// Creates an [GalleryHeaderThemeData]. + const GalleryHeaderThemeData({ + this.closeButtonColor, + this.backgroundColor, + this.iconMenuPointColor, + this.titleTextStyle, + this.subtitleTextStyle, + this.bottomSheetBarrierColor, + }); + + /// The color of the "close" button. + /// + /// Defaults to [ColorTheme.textHighEmphasis]. + final Color? closeButtonColor; + + /// The background color of the [GalleryHeader] widget. + /// + /// Defaults to [ChannelHeaderTheme.color]. + final Color? backgroundColor; + + /// Defaults to [ColorTheme.textHighEmphasis]. + final Color? iconMenuPointColor; + + /// The [TextStyle] to use for the [GalleryHeader] title text. + /// + /// Defaults to [TextTheme.headlineBold]. + final TextStyle? titleTextStyle; + + /// The [TextStyle] to use for the [GalleryHeader] subtitle text. + /// + /// Defaults to [ChannelPreviewTheme.subtitle]. + final TextStyle? subtitleTextStyle; + + /// + final Color? bottomSheetBarrierColor; + + /// Copies this [GalleryHeaderThemeData] to another. + GalleryHeaderThemeData copyWith({ + Color? closeButtonColor, + Color? backgroundColor, + Color? iconMenuPointColor, + TextStyle? titleTextStyle, + TextStyle? subtitleTextStyle, + Color? bottomSheetBarrierColor, + }) => + GalleryHeaderThemeData( + closeButtonColor: closeButtonColor ?? this.closeButtonColor, + backgroundColor: backgroundColor ?? this.backgroundColor, + iconMenuPointColor: iconMenuPointColor ?? this.iconMenuPointColor, + titleTextStyle: titleTextStyle ?? this.titleTextStyle, + subtitleTextStyle: subtitleTextStyle ?? this.subtitleTextStyle, + bottomSheetBarrierColor: + bottomSheetBarrierColor ?? this.bottomSheetBarrierColor, + ); + + /// Linearly interpolate between two [GalleryHeader] themes. + /// + /// All the properties must be non-null. + GalleryHeaderThemeData lerp( + GalleryHeaderThemeData a, + GalleryHeaderThemeData b, + double t, + ) => + GalleryHeaderThemeData( + closeButtonColor: Color.lerp(a.closeButtonColor, b.closeButtonColor, t), + backgroundColor: Color.lerp(a.backgroundColor, b.backgroundColor, t), + iconMenuPointColor: + Color.lerp(a.iconMenuPointColor, b.iconMenuPointColor, t), + titleTextStyle: TextStyle.lerp(a.titleTextStyle, b.titleTextStyle, t), + subtitleTextStyle: + TextStyle.lerp(a.subtitleTextStyle, b.subtitleTextStyle, t), + bottomSheetBarrierColor: + Color.lerp(a.bottomSheetBarrierColor, b.bottomSheetBarrierColor, t), + ); + + /// Merges one [GalleryHeaderThemeData] with the another + GalleryHeaderThemeData merge(GalleryHeaderThemeData? other) { + if (other == null) return this; + return copyWith( + closeButtonColor: other.closeButtonColor, + backgroundColor: other.backgroundColor, + iconMenuPointColor: other.iconMenuPointColor, + titleTextStyle: other.titleTextStyle, + subtitleTextStyle: other.subtitleTextStyle, + bottomSheetBarrierColor: other.bottomSheetBarrierColor, + ); + } + + @override + bool operator ==(Object other) => + identical(this, other) || + other is GalleryHeaderThemeData && + runtimeType == other.runtimeType && + closeButtonColor == other.closeButtonColor && + backgroundColor == other.backgroundColor && + iconMenuPointColor == other.iconMenuPointColor && + titleTextStyle == other.titleTextStyle && + subtitleTextStyle == other.subtitleTextStyle && + bottomSheetBarrierColor == other.bottomSheetBarrierColor; + + @override + int get hashCode => + closeButtonColor.hashCode ^ + backgroundColor.hashCode ^ + iconMenuPointColor.hashCode ^ + titleTextStyle.hashCode ^ + subtitleTextStyle.hashCode ^ + bottomSheetBarrierColor.hashCode; + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties + ..add(ColorProperty('closeButtonColor', closeButtonColor)) + ..add(ColorProperty('backgroundColor', backgroundColor)) + ..add(ColorProperty('iconMenuPointColor', iconMenuPointColor)) + ..add(DiagnosticsProperty('titleTextStyle', titleTextStyle)) + ..add(DiagnosticsProperty('subtitleTextStyle', subtitleTextStyle)) + ..add(ColorProperty('bottomSheetBarrierColor', bottomSheetBarrierColor)); + } +} + +/// Overrides the default style of [GalleryFooter] descendants. +/// +/// See also: +/// +/// * [GalleryFooterThemeData], which is used to configure this theme. +class GalleryFooterTheme extends InheritedTheme { + /// Creates an [GalleryFooterTheme]. + /// + /// The [data] parameter must not be null. + const GalleryFooterTheme({ + Key? key, + required this.data, + required Widget child, + }) : super(key: key, child: child); + + /// The configuration of this theme. + final GalleryFooterThemeData data; + + /// The closest instance of this class that encloses the given context. + /// + /// If there is no enclosing [GalleryFooterTheme] widget, then + /// [StreamChatThemeData.galleryFooterTheme] is used. + /// + /// Typical usage is as follows: + /// + /// ```dart + /// ImageFooterTheme theme = ImageFooterTheme.of(context); + /// ``` + static GalleryFooterThemeData of(BuildContext context) { + final imageFooterTheme = + context.dependOnInheritedWidgetOfExactType(); + return imageFooterTheme?.data ?? + StreamChatTheme.of(context).galleryFooterTheme; + } + + @override + Widget wrap(BuildContext context, Widget child) => + GalleryFooterTheme(data: data, child: child); + + @override + bool updateShouldNotify(GalleryFooterTheme oldWidget) => + data != oldWidget.data; +} + +/// A style that overrides the default appearance of [GalleryFooter]s when used +/// with [GalleryFooterTheme] or with the overall [StreamChatTheme]'s +/// [StreamChatThemeData.galleryFooterTheme]. +/// +/// See also: +/// +/// * [GalleryFooterTheme], the theme which is configured with this class. +/// * [StreamChatThemeData.galleryFooterTheme], which can be used to override +/// the default style for [GalleryFooter]s below the overall [StreamChatTheme]. +class GalleryFooterThemeData with Diagnosticable { + /// Creates an [GalleryFooterThemeData]. + const GalleryFooterThemeData({ + this.backgroundColor, + this.shareIconColor, + this.titleTextStyle, + this.gridIconButtonColor, + this.bottomSheetBarrierColor, + this.bottomSheetBackgroundColor, + this.bottomSheetPhotosTextStyle, + this.bottomSheetCloseIconColor, + }); + + /// The background color for the [GalleryFooter] widget. + /// + /// Defaults to [ColorTheme.barsBg]. + final Color? backgroundColor; + + /// The color for the "share" icon. + /// + /// Defaults to [ColorTheme.textHighEmphasis]. + final Color? shareIconColor; + + /// The [TextStyle] to use for the [GalleryFooter] title text. + /// + /// Defaults to [TextTheme.headlineBold]. + final TextStyle? titleTextStyle; + + /// The color to use for the "grid" icon. + /// + /// Defaults to [ColorTheme.textHighEmphasis]. + final Color? gridIconButtonColor; + + /// The color to use behind the bottom sheet. + /// + /// Defaults to [ColorTheme.overlay]. + final Color? bottomSheetBarrierColor; + + /// The background color to use for the bottom sheet. + /// + /// Defaults to [ColorTheme.barsBg]. + final Color? bottomSheetBackgroundColor; + + /// The [TextStyle] to use for the "photos" text in the bottom sheet. + /// + /// Defaults to [TextTheme.headlineBold]. + final TextStyle? bottomSheetPhotosTextStyle; + + /// The color to use for the "close" icon. + /// + /// Defaults to [ColorTheme.textHighEmphasis]. + final Color? bottomSheetCloseIconColor; + + /// Copies this [GalleryFooterThemeData] to another. + GalleryFooterThemeData copyWith({ + Color? backgroundColor, + Color? shareIconColor, + TextStyle? titleTextStyle, + Color? gridIconButtonColor, + Color? bottomSheetBarrierColor, + Color? bottomSheetBackgroundColor, + TextStyle? bottomSheetPhotosTextStyle, + Color? bottomSheetCloseIconColor, + }) => + GalleryFooterThemeData( + backgroundColor: backgroundColor ?? this.backgroundColor, + shareIconColor: shareIconColor ?? this.shareIconColor, + titleTextStyle: titleTextStyle ?? this.titleTextStyle, + gridIconButtonColor: gridIconButtonColor ?? this.gridIconButtonColor, + bottomSheetBarrierColor: + bottomSheetBarrierColor ?? this.bottomSheetBarrierColor, + bottomSheetBackgroundColor: + bottomSheetBackgroundColor ?? this.bottomSheetBackgroundColor, + bottomSheetPhotosTextStyle: + bottomSheetPhotosTextStyle ?? this.bottomSheetPhotosTextStyle, + bottomSheetCloseIconColor: + bottomSheetCloseIconColor ?? this.bottomSheetCloseIconColor, + ); + + /// Linearly interpolate between two [GalleryFooter] themes. + /// + /// All the properties must be non-null. + GalleryFooterThemeData lerp( + GalleryFooterThemeData a, + GalleryFooterThemeData b, + double t, + ) => + GalleryFooterThemeData( + backgroundColor: Color.lerp(a.backgroundColor, b.backgroundColor, t), + shareIconColor: Color.lerp(a.shareIconColor, b.shareIconColor, t), + titleTextStyle: TextStyle.lerp(a.titleTextStyle, b.titleTextStyle, t), + gridIconButtonColor: + Color.lerp(a.gridIconButtonColor, b.gridIconButtonColor, t), + bottomSheetBarrierColor: + Color.lerp(a.bottomSheetBarrierColor, b.bottomSheetBarrierColor, t), + bottomSheetBackgroundColor: Color.lerp( + a.bottomSheetBackgroundColor, b.bottomSheetBackgroundColor, t), + bottomSheetPhotosTextStyle: TextStyle.lerp( + a.bottomSheetPhotosTextStyle, b.bottomSheetPhotosTextStyle, t), + bottomSheetCloseIconColor: Color.lerp( + a.bottomSheetCloseIconColor, b.bottomSheetCloseIconColor, t), + ); + + /// Merges one [GalleryFooterThemeData] with the another + GalleryFooterThemeData merge(GalleryFooterThemeData? other) { + if (other == null) return this; + return copyWith( + backgroundColor: other.backgroundColor, + bottomSheetBarrierColor: other.bottomSheetBarrierColor, + bottomSheetBackgroundColor: other.bottomSheetBackgroundColor, + bottomSheetCloseIconColor: other.bottomSheetCloseIconColor, + bottomSheetPhotosTextStyle: other.bottomSheetPhotosTextStyle, + gridIconButtonColor: other.gridIconButtonColor, + titleTextStyle: other.titleTextStyle, + shareIconColor: other.shareIconColor, + ); + } + + @override + bool operator ==(Object other) => + identical(this, other) || + other is GalleryFooterThemeData && + runtimeType == other.runtimeType && + backgroundColor == other.backgroundColor && + shareIconColor == other.shareIconColor && + titleTextStyle == other.titleTextStyle && + gridIconButtonColor == other.gridIconButtonColor && + bottomSheetBarrierColor == other.bottomSheetBarrierColor && + bottomSheetBackgroundColor == other.bottomSheetBackgroundColor && + bottomSheetPhotosTextStyle == other.bottomSheetPhotosTextStyle && + bottomSheetCloseIconColor == other.bottomSheetCloseIconColor; + + @override + int get hashCode => + backgroundColor.hashCode ^ + shareIconColor.hashCode ^ + titleTextStyle.hashCode ^ + gridIconButtonColor.hashCode ^ + bottomSheetBarrierColor.hashCode ^ + bottomSheetBackgroundColor.hashCode ^ + bottomSheetPhotosTextStyle.hashCode ^ + bottomSheetCloseIconColor.hashCode; + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties + ..add(ColorProperty('backgroundColor', backgroundColor)) + ..add(ColorProperty('shareIconColor', shareIconColor)) + ..add(DiagnosticsProperty('titleTextStyle', titleTextStyle)) + ..add(ColorProperty('gridIconButtonColor', gridIconButtonColor)) + ..add(ColorProperty('bottomSheetBarrierColor', bottomSheetBarrierColor)) + ..add(ColorProperty( + 'bottomSheetBackgroundColor', bottomSheetBackgroundColor)) + ..add(DiagnosticsProperty( + 'bottomSheetPhotosTextStyle', bottomSheetPhotosTextStyle)) + ..add(ColorProperty( + 'bottomSheetCloseIconColor', bottomSheetCloseIconColor)); + } +} diff --git a/packages/stream_chat_flutter/lib/src/stream_neumorphic_button.dart b/packages/stream_chat_flutter/lib/src/stream_neumorphic_button.dart index 35e98960..5867a4a1 100644 --- a/packages/stream_chat_flutter/lib/src/stream_neumorphic_button.dart +++ b/packages/stream_chat_flutter/lib/src/stream_neumorphic_button.dart @@ -1,40 +1,40 @@ import 'package:flutter/material.dart'; +/// Neumorphic button class StreamNeumorphicButton extends StatelessWidget { - final Widget child; - final Color backgroundColor; - + /// Constructor for creating [StreamNeumorphicButton] const StreamNeumorphicButton({ - Key key, - @required this.child, + Key? key, + required this.child, this.backgroundColor = Colors.white, }) : super(key: key); + /// Child contained in the button + final Widget child; + + /// Background color of button + final Color backgroundColor; + @override - Widget build(BuildContext context) { - return Container( - margin: EdgeInsets.all(8.0), - height: 40, - width: 40, - decoration: BoxDecoration( - color: backgroundColor, - shape: BoxShape.circle, - boxShadow: [ - BoxShadow( - color: Colors.grey[700], - offset: Offset(0, 1.0), - blurRadius: 0.5, - spreadRadius: 0, - ), - BoxShadow( - color: Colors.white, - offset: Offset.zero, - blurRadius: 0.5, - spreadRadius: 0, - ), - ], - ), - child: child, - ); - } + Widget build(BuildContext context) => Container( + margin: const EdgeInsets.all(8), + height: 40, + width: 40, + decoration: BoxDecoration( + color: backgroundColor, + shape: BoxShape.circle, + boxShadow: [ + BoxShadow( + color: Colors.grey.shade700, + offset: const Offset(0, 1), + blurRadius: 0.5, + ), + const BoxShadow( + color: Colors.white, + blurRadius: 0.5, + ), + ], + ), + child: child, + ); } diff --git a/packages/stream_chat_flutter/lib/src/stream_svg_icon.dart b/packages/stream_chat_flutter/lib/src/stream_svg_icon.dart index 374b9ba1..fb5017e1 100644 --- a/packages/stream_chat_flutter/lib/src/stream_svg_icon.dart +++ b/packages/stream_chat_flutter/lib/src/stream_svg_icon.dart @@ -2,18 +2,976 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_svg/flutter_svg.dart'; +/// Icon set of stream chat class StreamSvgIcon extends StatelessWidget { - final String assetName; - final double width; - final double height; - final Color color; - + /// Constructor for creating a [StreamSvgIcon] const StreamSvgIcon({ + Key? key, this.assetName, this.color, this.width = 24, this.height = 24, - }); + }) : super(key: key); + + /// [StreamSvgIcon] type + factory StreamSvgIcon.settings({ + double? size, + Color? color, + }) => + StreamSvgIcon( + assetName: 'settings.svg', + color: color, + width: size, + height: size, + ); + + /// [StreamSvgIcon] type + factory StreamSvgIcon.down({ + double? size, + Color? color, + }) => + StreamSvgIcon( + assetName: 'Icon_down.svg', + color: color, + width: size, + height: size, + ); + + /// [StreamSvgIcon] type + factory StreamSvgIcon.attach({ + double? size, + Color? color, + }) => + StreamSvgIcon( + assetName: 'Icon_attach.svg', + color: color, + width: size, + height: size, + ); + + /// [StreamSvgIcon] type + factory StreamSvgIcon.loveReaction({ + double? size, + Color? color, + }) => + StreamSvgIcon( + assetName: 'Icon_love_reaction.svg', + color: color, + width: size, + height: size, + ); + + /// [StreamSvgIcon] type + factory StreamSvgIcon.thumbsUpReaction({ + double? size, + Color? color, + }) => + StreamSvgIcon( + assetName: 'Icon_thumbs_up_reaction.svg', + color: color, + width: size, + height: size, + ); + + /// [StreamSvgIcon] type + factory StreamSvgIcon.thumbsDownReaction({ + double? size, + Color? color, + }) => + StreamSvgIcon( + assetName: 'Icon_thumbs_down_reaction.svg', + color: color, + width: size, + height: size, + ); + + /// [StreamSvgIcon] type + factory StreamSvgIcon.lolReaction({ + double? size, + Color? color, + }) => + StreamSvgIcon( + assetName: 'Icon_LOL_reaction.svg', + color: color, + width: size, + height: size, + ); + + /// [StreamSvgIcon] type + factory StreamSvgIcon.wutReaction({ + double? size, + Color? color, + }) => + StreamSvgIcon( + assetName: 'Icon_wut_reaction.svg', + color: color, + width: size, + height: size, + ); + + /// [StreamSvgIcon] type + factory StreamSvgIcon.smile({ + double? size, + Color? color, + }) => + StreamSvgIcon( + assetName: 'Icon_smile.svg', + color: color, + width: size, + height: size, + ); + + /// [StreamSvgIcon] type + factory StreamSvgIcon.mentions({ + double? size, + Color? color, + }) => + StreamSvgIcon( + assetName: 'mentions.svg', + color: color, + width: size, + height: size, + ); + + /// [StreamSvgIcon] type + factory StreamSvgIcon.record({ + double? size, + Color? color, + }) => + StreamSvgIcon( + assetName: 'Icon_record.svg', + color: color, + width: size, + height: size, + ); + + /// [StreamSvgIcon] type + factory StreamSvgIcon.camera({ + double? size, + Color? color, + }) => + StreamSvgIcon( + assetName: 'Icon_camera.svg', + color: color, + width: size, + height: size, + ); + + /// [StreamSvgIcon] type + factory StreamSvgIcon.files({ + double? size, + Color? color, + }) => + StreamSvgIcon( + assetName: 'files.svg', + color: color, + width: size, + height: size, + ); + + /// [StreamSvgIcon] type + factory StreamSvgIcon.pictures({ + double? size, + Color? color, + }) => + StreamSvgIcon( + assetName: 'pictures.svg', + color: color, + width: size, + height: size, + ); + + /// [StreamSvgIcon] type + factory StreamSvgIcon.left({ + double? size, + Color? color, + }) => + StreamSvgIcon( + assetName: 'Icon_left.svg', + color: color, + width: size, + height: size, + ); + + /// [StreamSvgIcon] type + factory StreamSvgIcon.user({ + double? size, + Color? color, + }) => + StreamSvgIcon( + assetName: 'Icon_user.svg', + color: color, + width: size, + height: size, + ); + + /// [StreamSvgIcon] type + factory StreamSvgIcon.userAdd({ + double? size, + Color? color, + }) => + StreamSvgIcon( + assetName: 'Icon_User_add.svg', + color: color, + width: size, + height: size, + ); + + /// [StreamSvgIcon] type + factory StreamSvgIcon.check({ + double? size, + Color? color, + }) => + StreamSvgIcon( + assetName: 'Icon_check.svg', + color: color, + width: size, + height: size, + ); + + /// [StreamSvgIcon] type + factory StreamSvgIcon.checkAll({ + double? size, + Color? color, + }) => + StreamSvgIcon( + assetName: 'Icon_check_all.svg', + color: color, + width: size, + height: size, + ); + + /// [StreamSvgIcon] type + factory StreamSvgIcon.checkSend({ + double? size, + Color? color, + }) => + StreamSvgIcon( + assetName: 'Icon_check_send.svg', + color: color, + width: size, + height: size, + ); + + /// [StreamSvgIcon] type + factory StreamSvgIcon.penWrite({ + double? size, + Color? color, + }) => + StreamSvgIcon( + assetName: 'Icon_pen-write.svg', + color: color, + width: size, + height: size, + ); + + /// [StreamSvgIcon] type + factory StreamSvgIcon.contacts({ + double? size, + Color? color, + }) => + StreamSvgIcon( + assetName: 'Icon_contacts.svg', + color: color, + width: size, + height: size, + ); + + /// [StreamSvgIcon] type + factory StreamSvgIcon.close({ + double? size, + Color? color, + }) => + StreamSvgIcon( + assetName: 'Icon_close.svg', + color: color, + width: size, + height: size, + ); + + /// [StreamSvgIcon] type + factory StreamSvgIcon.search({ + double? size, + Color? color, + }) => + StreamSvgIcon( + assetName: 'Icon_search.svg', + color: color, + width: size, + height: size, + ); + + /// [StreamSvgIcon] type + factory StreamSvgIcon.right({ + double? size, + Color? color, + }) => + StreamSvgIcon( + assetName: 'Icon_right.svg', + color: color, + width: size, + height: size, + ); + + /// [StreamSvgIcon] type + factory StreamSvgIcon.mute({ + double? size, + Color? color, + }) => + StreamSvgIcon( + assetName: 'Icon_mute.svg', + color: color, + width: size, + height: size, + ); + + /// [StreamSvgIcon] type + factory StreamSvgIcon.userRemove({ + double? size, + Color? color, + }) => + StreamSvgIcon( + assetName: 'Icon_User_deselect.svg', + color: color, + width: size, + height: size, + ); + + /// [StreamSvgIcon] type + factory StreamSvgIcon.lightning({ + double? size, + Color? color, + }) => + StreamSvgIcon( + assetName: 'Icon_lightning-command runner.svg', + color: color, + width: size, + height: size, + ); + + /// [StreamSvgIcon] type + factory StreamSvgIcon.emptyCircleLeft({ + double? size, + Color? color, + }) => + StreamSvgIcon( + assetName: 'Icon_empty_circle_left.svg', + color: color, + width: size, + height: size, + ); + + /// [StreamSvgIcon] type + factory StreamSvgIcon.message({ + double? size, + Color? color, + }) => + StreamSvgIcon( + assetName: 'Icon_message.svg', + color: color, + width: size, + height: size, + ); + + /// [StreamSvgIcon] type + factory StreamSvgIcon.thread({ + double? size, + Color? color, + }) => + StreamSvgIcon( + assetName: 'Icon_Thread_Reply.svg', + color: color, + width: size, + height: size, + ); + + /// [StreamSvgIcon] type + factory StreamSvgIcon.reply({ + double? size, + Color? color, + }) => + StreamSvgIcon( + assetName: 'Icon_curve_line_left_up_big.svg', + color: color, + width: size, + height: size, + ); + + /// [StreamSvgIcon] type + factory StreamSvgIcon.edit({ + double? size, + Color? color, + }) => + StreamSvgIcon( + assetName: 'Icon_edit.svg', + color: color, + width: size, + height: size, + ); + + /// [StreamSvgIcon] type + factory StreamSvgIcon.download({ + double? size, + Color? color, + }) => + StreamSvgIcon( + assetName: 'Icon_download.svg', + color: color, + width: size, + height: size, + ); + + /// [StreamSvgIcon] type + factory StreamSvgIcon.cloudDownload({ + double? size, + Color? color, + }) => + StreamSvgIcon( + assetName: 'Icon_cloud_download.svg', + color: color, + width: size, + height: size, + ); + + /// [StreamSvgIcon] type + factory StreamSvgIcon.copy({ + double? size, + Color? color, + }) => + StreamSvgIcon( + assetName: 'Icon_copy.svg', + color: color, + width: size, + height: size, + ); + + /// [StreamSvgIcon] type + factory StreamSvgIcon.delete({ + double? size, + Color? color, + }) => + StreamSvgIcon( + assetName: 'Icon_delete.svg', + color: color, + width: size, + height: size, + ); + + /// [StreamSvgIcon] type + factory StreamSvgIcon.eye({ + double? size, + Color? color, + }) => + StreamSvgIcon( + assetName: 'Icon_eye-off.svg', + color: color, + width: size, + height: size, + ); + + /// [StreamSvgIcon] type + factory StreamSvgIcon.arrowRight({ + double? size, + Color? color, + }) => + StreamSvgIcon( + assetName: 'Icon_arrow_right.svg', + color: color, + width: size, + height: size, + ); + + /// [StreamSvgIcon] type + factory StreamSvgIcon.closeSmall({ + double? size, + Color? color, + }) => + StreamSvgIcon( + assetName: 'Icon_close_sml.svg', + color: color, + width: size, + height: size, + ); + + /// [StreamSvgIcon] type + factory StreamSvgIcon.iconCurveLineLeftUp({ + double? size, + Color? color, + }) => + StreamSvgIcon( + assetName: 'Icon_curve_line_left_up.svg', + color: color, + width: size, + height: size, + ); + + /// [StreamSvgIcon] type + factory StreamSvgIcon.iconMoon({ + double? size, + Color? color, + }) => + StreamSvgIcon( + assetName: 'icon_moon.svg', + color: color, + width: size, + height: size, + ); + + /// [StreamSvgIcon] type + factory StreamSvgIcon.iconShare({ + double? size, + Color? color, + }) => + StreamSvgIcon( + assetName: 'icon_SHARE.svg', + color: color, + width: size, + height: size, + ); + + /// [StreamSvgIcon] type + factory StreamSvgIcon.iconGrid({ + double? size, + Color? color, + }) => + StreamSvgIcon( + assetName: 'Icon_grid.svg', + color: color, + width: size, + height: size, + ); + + /// [StreamSvgIcon] type + factory StreamSvgIcon.iconSendMessage({ + double? size, + Color? color, + }) => + StreamSvgIcon( + assetName: 'Icon_send_message.svg', + color: color, + width: size, + height: size, + ); + + /// [StreamSvgIcon] type + factory StreamSvgIcon.iconMenuPoint({ + double? size, + Color? color, + }) => + StreamSvgIcon( + assetName: 'Icon_menu_point_v.svg', + color: color, + width: size, + height: size, + ); + + /// [StreamSvgIcon] type + factory StreamSvgIcon.iconSave({ + double? size, + Color? color, + }) => + StreamSvgIcon( + assetName: 'Icon_save.svg', + color: color, + width: size, + height: size, + ); + + /// [StreamSvgIcon] type + factory StreamSvgIcon.shareArrow({ + double? size, + Color? color, + }) => + StreamSvgIcon( + assetName: 'share_arrow.svg', + color: color, + width: size, + height: size, + ); + + /// [StreamSvgIcon] type + factory StreamSvgIcon.filetype7z({ + double? size, + Color? color, + }) => + StreamSvgIcon( + assetName: 'filetype_7z.svg', + color: color, + width: size, + height: size, + ); + + /// [StreamSvgIcon] type + factory StreamSvgIcon.filetypeCsv({ + double? size, + Color? color, + }) => + StreamSvgIcon( + assetName: 'filetype_CSV.svg', + color: color, + width: size, + height: size, + ); + + /// [StreamSvgIcon] type + factory StreamSvgIcon.filetypeDoc({ + double? size, + Color? color, + }) => + StreamSvgIcon( + assetName: 'filetype_DOC.svg', + color: color, + width: size, + height: size, + ); + + /// [StreamSvgIcon] type + factory StreamSvgIcon.filetypeDocx({ + double? size, + Color? color, + }) => + StreamSvgIcon( + assetName: 'filetype_DOCX.svg', + color: color, + width: size, + height: size, + ); + + /// [StreamSvgIcon] type + factory StreamSvgIcon.filetypeGeneric({ + double? size, + Color? color, + }) => + StreamSvgIcon( + assetName: 'filetype_Generic.svg', + color: color, + width: size, + height: size, + ); + + /// [StreamSvgIcon] type + factory StreamSvgIcon.filetypeHtml({ + double? size, + Color? color, + }) => + StreamSvgIcon( + assetName: 'filetype_html.svg', + color: color, + width: size, + height: size, + ); + + /// [StreamSvgIcon] type + factory StreamSvgIcon.filetypeMd({ + double? size, + Color? color, + }) => + StreamSvgIcon( + assetName: 'filetype_MD.svg', + color: color, + width: size, + height: size, + ); + + /// [StreamSvgIcon] type + factory StreamSvgIcon.filetypeOdt({ + double? size, + Color? color, + }) => + StreamSvgIcon( + assetName: 'filetype_ODT.svg', + color: color, + width: size, + height: size, + ); + + /// [StreamSvgIcon] type + factory StreamSvgIcon.filetypePdf({ + double? size, + Color? color, + }) => + StreamSvgIcon( + assetName: 'filetype_PDF.svg', + color: color, + width: size, + height: size, + ); + + /// [StreamSvgIcon] type + factory StreamSvgIcon.filetypePpt({ + double? size, + Color? color, + }) => + StreamSvgIcon( + assetName: 'filetype_PPT.svg', + color: color, + width: size, + height: size, + ); + + /// [StreamSvgIcon] type + factory StreamSvgIcon.filetypePptx({ + double? size, + Color? color, + }) => + StreamSvgIcon( + assetName: 'filetype_PPTX.svg', + color: color, + width: size, + height: size, + ); + + /// [StreamSvgIcon] type + factory StreamSvgIcon.filetypeRar({ + double? size, + Color? color, + }) => + StreamSvgIcon( + assetName: 'filetype_RAR.svg', + color: color, + width: size, + height: size, + ); + + /// [StreamSvgIcon] type + factory StreamSvgIcon.filetypeRtf({ + double? size, + Color? color, + }) => + StreamSvgIcon( + assetName: 'filetype_RTF.svg', + color: color, + width: size, + height: size, + ); + + /// [StreamSvgIcon] type + factory StreamSvgIcon.filetypeTar({ + double? size, + Color? color, + }) => + StreamSvgIcon( + assetName: 'filetype_TAR.svg', + color: color, + width: size, + height: size, + ); + + /// [StreamSvgIcon] type + factory StreamSvgIcon.filetypeTxt({ + double? size, + Color? color, + }) => + StreamSvgIcon( + assetName: 'filetype_TXT.svg', + color: color, + width: size, + height: size, + ); + + /// [StreamSvgIcon] type + factory StreamSvgIcon.filetypeXls({ + double? size, + Color? color, + }) => + StreamSvgIcon( + assetName: 'filetype_XLS.svg', + color: color, + width: size, + height: size, + ); + + /// [StreamSvgIcon] type + factory StreamSvgIcon.filetypeXlsx({ + double? size, + Color? color, + }) => + StreamSvgIcon( + assetName: 'filetype_XLSX.svg', + color: color, + width: size, + height: size, + ); + + /// [StreamSvgIcon] type + factory StreamSvgIcon.filetypeZip({ + double? size, + Color? color, + }) => + StreamSvgIcon( + assetName: 'filetype_ZIP.svg', + color: color, + width: size, + height: size, + ); + + /// [StreamSvgIcon] type + factory StreamSvgIcon.iconGroup({ + double? size, + Color? color, + }) => + StreamSvgIcon( + assetName: 'Icon_group.svg', + color: color, + width: size, + height: size, + ); + + /// [StreamSvgIcon] type + factory StreamSvgIcon.iconNotification({ + double? size, + Color? color, + }) => + StreamSvgIcon( + assetName: 'Icon_notification.svg', + color: color, + width: size, + height: size, + ); + + /// [StreamSvgIcon] type + factory StreamSvgIcon.iconUserDelete({ + double? size, + Color? color, + }) => + StreamSvgIcon( + assetName: 'Icon_user_delete.svg', + color: color, + width: size, + height: size, + ); + + /// [StreamSvgIcon] type + factory StreamSvgIcon.error({ + double? size, + Color? color, + }) => + StreamSvgIcon( + assetName: 'Icon_error.svg', + color: color, + width: size, + height: size, + ); + + /// [StreamSvgIcon] type + factory StreamSvgIcon.circleUp({ + double? size, + Color? color, + }) => + StreamSvgIcon( + assetName: 'Icon_circle_up.svg', + color: color, + width: size, + height: size, + ); + + /// [StreamSvgIcon] type + factory StreamSvgIcon.iconUserSettings({ + double? size, + Color? color, + }) => + StreamSvgIcon( + assetName: 'Icon_user_settings.svg', + color: color, + width: size, + height: size, + ); + + /// [StreamSvgIcon] type + factory StreamSvgIcon.giphyIcon({ + double? size, + Color? color, + }) => + StreamSvgIcon( + assetName: 'giphy_icon.svg', + color: color, + width: size, + height: size, + ); + + /// [StreamSvgIcon] type + factory StreamSvgIcon.imgur({ + double? size, + Color? color, + }) => + StreamSvgIcon( + assetName: 'imgur.svg', + color: color, + width: size, + height: size, + ); + + /// [StreamSvgIcon] type + factory StreamSvgIcon.volumeUp({ + double? size, + Color? color, + }) => + StreamSvgIcon( + assetName: 'volume-up.svg', + color: color, + width: size, + height: size, + ); + + /// [StreamSvgIcon] type + factory StreamSvgIcon.flag({ + double? size, + Color? color, + }) => + StreamSvgIcon( + assetName: 'flag.svg', + color: color, + width: size, + height: size, + ); + + /// [StreamSvgIcon] type + factory StreamSvgIcon.iconFlag({ + double? size, + Color? color, + }) => + StreamSvgIcon( + assetName: 'icon_flag.svg', + color: color, + width: size, + height: size, + ); + + /// [StreamSvgIcon] type + factory StreamSvgIcon.retry({ + double? size, + Color? color, + }) => + StreamSvgIcon( + assetName: 'icon_retry.svg', + color: color, + width: size, + height: size, + ); + + /// [StreamSvgIcon] type + factory StreamSvgIcon.pin({ + double? size, + Color? color, + }) => + StreamSvgIcon( + assetName: 'icon_pin.svg', + color: color, + width: size, + height: size, + ); + + /// Name of icon asset + final String? assetName; + + /// Width of icon + final double? width; + + /// Height of icon + final double? height; + + /// Color of icon + final Color? color; @override Widget build(BuildContext context) { @@ -25,883 +983,6 @@ class StreamSvgIcon extends StatelessWidget { width: width, height: height, color: color, - alignment: Alignment.center, - ); - } - - factory StreamSvgIcon.settings({ - double size, - Color color, - }) { - return StreamSvgIcon( - assetName: 'settings.svg', - color: color, - width: size, - height: size, - ); - } - - factory StreamSvgIcon.down({ - double size, - Color color, - }) { - return StreamSvgIcon( - assetName: 'Icon_down.svg', - color: color, - width: size, - height: size, - ); - } - - factory StreamSvgIcon.attach({ - double size, - Color color, - }) { - return StreamSvgIcon( - assetName: 'Icon_attach.svg', - color: color, - width: size, - height: size, - ); - } - - factory StreamSvgIcon.smile({ - double size, - Color color, - }) { - return StreamSvgIcon( - assetName: 'Icon_smile.svg', - color: color, - width: size, - height: size, - ); - } - - factory StreamSvgIcon.mentions({ - double size, - Color color, - }) { - return StreamSvgIcon( - assetName: 'mentions.svg', - color: color, - width: size, - height: size, - ); - } - - factory StreamSvgIcon.record({ - double size, - Color color, - }) { - return StreamSvgIcon( - assetName: 'Icon_record.svg', - color: color, - width: size, - height: size, - ); - } - - factory StreamSvgIcon.camera({ - double size, - Color color, - }) { - return StreamSvgIcon( - assetName: 'Icon_camera.svg', - color: color, - width: size, - height: size, - ); - } - - factory StreamSvgIcon.files({ - double size, - Color color, - }) { - return StreamSvgIcon( - assetName: 'files.svg', - color: color, - width: size, - height: size, - ); - } - - factory StreamSvgIcon.pictures({ - double size, - Color color, - }) { - return StreamSvgIcon( - assetName: 'pictures.svg', - color: color, - width: size, - height: size, - ); - } - - factory StreamSvgIcon.left({ - double size, - Color color, - }) { - return StreamSvgIcon( - assetName: 'Icon_left.svg', - color: color, - width: size, - height: size, - ); - } - - factory StreamSvgIcon.user({ - double size, - Color color, - }) { - return StreamSvgIcon( - assetName: 'Icon_user.svg', - color: color, - width: size, - height: size, - ); - } - - factory StreamSvgIcon.userAdd({ - double size, - Color color, - }) { - return StreamSvgIcon( - assetName: 'Icon_User_add.svg', - color: color, - width: size, - height: size, - ); - } - - factory StreamSvgIcon.check({ - double size, - Color color, - }) { - return StreamSvgIcon( - assetName: 'Icon_check.svg', - color: color, - width: size, - height: size, - ); - } - - factory StreamSvgIcon.checkAll({ - double size, - Color color, - }) { - return StreamSvgIcon( - assetName: 'Icon_check_all.svg', - color: color, - width: size, - height: size, - ); - } - - factory StreamSvgIcon.checkSend({ - double size, - Color color, - }) { - return StreamSvgIcon( - assetName: 'Icon_check_send.svg', - color: color, - width: size, - height: size, - ); - } - - factory StreamSvgIcon.penWrite({ - double size, - Color color, - }) { - return StreamSvgIcon( - assetName: 'Icon_pen-write.svg', - color: color, - width: size, - height: size, - ); - } - - factory StreamSvgIcon.contacts({ - double size, - Color color, - }) { - return StreamSvgIcon( - assetName: 'Icon_contacts.svg', - color: color, - width: size, - height: size, - ); - } - - factory StreamSvgIcon.close({ - double size, - Color color, - }) { - return StreamSvgIcon( - assetName: 'Icon_close.svg', - color: color, - width: size, - height: size, - ); - } - - factory StreamSvgIcon.search({ - double size, - Color color, - }) { - return StreamSvgIcon( - assetName: 'Icon_search.svg', - color: color, - width: size, - height: size, - ); - } - - factory StreamSvgIcon.right({ - double size, - Color color, - }) { - return StreamSvgIcon( - assetName: 'Icon_right.svg', - color: color, - width: size, - height: size, - ); - } - - factory StreamSvgIcon.mute({ - double size, - Color color, - }) { - return StreamSvgIcon( - assetName: 'Icon_mute.svg', - color: color, - width: size, - height: size, - ); - } - - factory StreamSvgIcon.userRemove({ - double size, - Color color, - }) { - return StreamSvgIcon( - assetName: 'Icon_User_deselect.svg', - color: color, - width: size, - height: size, - ); - } - - factory StreamSvgIcon.lightning({ - double size, - Color color, - }) { - return StreamSvgIcon( - assetName: 'Icon_lightning-command runner.svg', - color: color, - width: size, - height: size, - ); - } - - factory StreamSvgIcon.emptyCircleLeft({ - double size, - Color color, - }) { - return StreamSvgIcon( - assetName: 'Icon_empty_circle_left.svg', - color: color, - width: size, - height: size, - ); - } - - factory StreamSvgIcon.message({ - double size, - Color color, - }) { - return StreamSvgIcon( - assetName: 'Icon_message.svg', - color: color, - width: size, - height: size, - ); - } - - factory StreamSvgIcon.thread({ - double size, - Color color, - }) { - return StreamSvgIcon( - assetName: 'Icon_Thread_Reply.svg', - color: color, - width: size, - height: size, - ); - } - - factory StreamSvgIcon.reply({ - double size, - Color color, - }) { - return StreamSvgIcon( - assetName: 'Icon_curve_line_left_up_big.svg', - color: color, - width: size, - height: size, - ); - } - - factory StreamSvgIcon.edit({ - double size, - Color color, - }) { - return StreamSvgIcon( - assetName: 'Icon_edit.svg', - color: color, - width: size, - height: size, - ); - } - - factory StreamSvgIcon.download({ - double size, - Color color, - }) { - return StreamSvgIcon( - assetName: 'Icon_download.svg', - color: color, - width: size, - height: size, - ); - } - - factory StreamSvgIcon.cloudDownload({ - double size, - Color color, - }) { - return StreamSvgIcon( - assetName: 'Icon_cloud_download.svg', - color: color, - width: size, - height: size, - ); - } - - factory StreamSvgIcon.copy({ - double size, - Color color, - }) { - return StreamSvgIcon( - assetName: 'Icon_copy.svg', - color: color, - width: size, - height: size, - ); - } - - factory StreamSvgIcon.delete({ - double size, - Color color, - }) { - return StreamSvgIcon( - assetName: 'Icon_delete.svg', - color: color, - width: size, - height: size, - ); - } - - factory StreamSvgIcon.eye({ - double size, - Color color, - }) { - return StreamSvgIcon( - assetName: 'Icon_eye-off.svg', - color: color, - width: size, - height: size, - ); - } - - factory StreamSvgIcon.arrowRight({ - double size, - Color color, - }) { - return StreamSvgIcon( - assetName: 'Icon_arrow_right.svg', - color: color, - width: size, - height: size, - ); - } - - factory StreamSvgIcon.closeSmall({ - double size, - Color color, - }) { - return StreamSvgIcon( - assetName: 'Icon_close_sml.svg', - color: color, - width: size, - height: size, - ); - } - - factory StreamSvgIcon.iconCurveLineLeftUp({ - double size, - Color color, - }) { - return StreamSvgIcon( - assetName: 'Icon_curve_line_left_up.svg', - color: color, - width: size, - height: size, - ); - } - - factory StreamSvgIcon.iconMoon({ - double size, - Color color, - }) { - return StreamSvgIcon( - assetName: 'icon_moon.svg', - color: color, - width: size, - height: size, - ); - } - - factory StreamSvgIcon.iconShare({ - double size, - Color color, - }) { - return StreamSvgIcon( - assetName: 'icon_SHARE.svg', - color: color, - width: size, - height: size, - ); - } - - factory StreamSvgIcon.iconGrid({ - double size, - Color color, - }) { - return StreamSvgIcon( - assetName: 'Icon_grid.svg', - color: color, - width: size, - height: size, - ); - } - - factory StreamSvgIcon.iconSendMessage({ - double size, - Color color, - }) { - return StreamSvgIcon( - assetName: 'Icon_send_message.svg', - color: color, - width: size, - height: size, - ); - } - - factory StreamSvgIcon.iconMenuPoint({ - double size, - Color color, - }) { - return StreamSvgIcon( - assetName: 'Icon_menu_point_v.svg', - color: color, - width: size, - height: size, - ); - } - - factory StreamSvgIcon.iconSave({ - double size, - Color color, - }) { - return StreamSvgIcon( - assetName: 'Icon_save.svg', - color: color, - width: size, - height: size, - ); - } - - factory StreamSvgIcon.shareArrow({ - double size, - Color color, - }) { - return StreamSvgIcon( - assetName: 'share_arrow.svg', - color: color, - width: size, - height: size, - ); - } - - factory StreamSvgIcon.filetype7z({ - double size, - Color color, - }) { - return StreamSvgIcon( - assetName: 'filetype_7z.svg', - color: color, - width: size, - height: size, - ); - } - - factory StreamSvgIcon.filetypeCsv({ - double size, - Color color, - }) { - return StreamSvgIcon( - assetName: 'filetype_CSV.svg', - color: color, - width: size, - height: size, - ); - } - - factory StreamSvgIcon.filetypeDoc({ - double size, - Color color, - }) { - return StreamSvgIcon( - assetName: 'filetype_DOC.svg', - color: color, - width: size, - height: size, - ); - } - - factory StreamSvgIcon.filetypeDocx({ - double size, - Color color, - }) { - return StreamSvgIcon( - assetName: 'filetype_DOCX.svg', - color: color, - width: size, - height: size, - ); - } - - factory StreamSvgIcon.filetypeGeneric({ - double size, - Color color, - }) { - return StreamSvgIcon( - assetName: 'filetype_Generic.svg', - color: color, - width: size, - height: size, - ); - } - - factory StreamSvgIcon.filetypeHtml({ - double size, - Color color, - }) { - return StreamSvgIcon( - assetName: 'filetype_html.svg', - color: color, - width: size, - height: size, - ); - } - - factory StreamSvgIcon.filetypeMd({ - double size, - Color color, - }) { - return StreamSvgIcon( - assetName: 'filetype_MD.svg', - color: color, - width: size, - height: size, - ); - } - - factory StreamSvgIcon.filetypeOdt({ - double size, - Color color, - }) { - return StreamSvgIcon( - assetName: 'filetype_ODT.svg', - color: color, - width: size, - height: size, - ); - } - - factory StreamSvgIcon.filetypePdf({ - double size, - Color color, - }) { - return StreamSvgIcon( - assetName: 'filetype_PDF.svg', - color: color, - width: size, - height: size, - ); - } - - factory StreamSvgIcon.filetypePpt({ - double size, - Color color, - }) { - return StreamSvgIcon( - assetName: 'filetype_PPT.svg', - color: color, - width: size, - height: size, - ); - } - - factory StreamSvgIcon.filetypePptx({ - double size, - Color color, - }) { - return StreamSvgIcon( - assetName: 'filetype_PPTX.svg', - color: color, - width: size, - height: size, - ); - } - - factory StreamSvgIcon.filetypeRar({ - double size, - Color color, - }) { - return StreamSvgIcon( - assetName: 'filetype_RAR.svg', - color: color, - width: size, - height: size, - ); - } - - factory StreamSvgIcon.filetypeRtf({ - double size, - Color color, - }) { - return StreamSvgIcon( - assetName: 'filetype_RTF.svg', - color: color, - width: size, - height: size, - ); - } - - factory StreamSvgIcon.filetypeTar({ - double size, - Color color, - }) { - return StreamSvgIcon( - assetName: 'filetype_TAR.svg', - color: color, - width: size, - height: size, - ); - } - - factory StreamSvgIcon.filetypeTxt({ - double size, - Color color, - }) { - return StreamSvgIcon( - assetName: 'filetype_TXT.svg', - color: color, - width: size, - height: size, - ); - } - - factory StreamSvgIcon.filetypeXls({ - double size, - Color color, - }) { - return StreamSvgIcon( - assetName: 'filetype_XLS.svg', - color: color, - width: size, - height: size, - ); - } - - factory StreamSvgIcon.filetypeXlsx({ - double size, - Color color, - }) { - return StreamSvgIcon( - assetName: 'filetype_XLSX.svg', - color: color, - width: size, - height: size, - ); - } - - factory StreamSvgIcon.filetypeZip({ - double size, - Color color, - }) { - return StreamSvgIcon( - assetName: 'filetype_ZIP.svg', - color: color, - width: size, - height: size, - ); - } - - factory StreamSvgIcon.iconGroup({ - double size, - Color color, - }) { - return StreamSvgIcon( - assetName: 'Icon_group.svg', - color: color, - width: size, - height: size, - ); - } - - factory StreamSvgIcon.iconNotification({ - double size, - Color color, - }) { - return StreamSvgIcon( - assetName: 'Icon_notification.svg', - color: color, - width: size, - height: size, - ); - } - - factory StreamSvgIcon.iconUserDelete({ - double size, - Color color, - }) { - return StreamSvgIcon( - assetName: 'Icon_user_delete.svg', - color: color, - width: size, - height: size, - ); - } - - factory StreamSvgIcon.error({ - double size, - Color color, - }) { - return StreamSvgIcon( - assetName: 'Icon_error.svg', - color: color, - width: size, - height: size, - ); - } - - factory StreamSvgIcon.circleUp({ - double size, - Color color, - }) { - return StreamSvgIcon( - assetName: 'Icon_circle_up.svg', - color: color, - width: size, - height: size, - ); - } - - factory StreamSvgIcon.iconUserSettings({ - double size, - Color color, - }) { - return StreamSvgIcon( - assetName: 'Icon_user_settings.svg', - color: color, - width: size, - height: size, - ); - } - - factory StreamSvgIcon.giphyIcon({ - double size, - Color color, - }) { - return StreamSvgIcon( - assetName: 'giphy_icon.svg', - color: color, - width: size, - height: size, - ); - } - - factory StreamSvgIcon.imgur({ - double size, - Color color, - }) { - return StreamSvgIcon( - assetName: 'imgur.svg', - color: color, - width: size, - height: size, - ); - } - - factory StreamSvgIcon.volumeUp({ - double size, - Color color, - }) { - return StreamSvgIcon( - assetName: 'volume-up.svg', - color: color, - width: size, - height: size, - ); - } - - factory StreamSvgIcon.flag({ - double size, - Color color, - }) { - return StreamSvgIcon( - assetName: 'flag.svg', - color: color, - width: size, - height: size, - ); - } - - factory StreamSvgIcon.iconFlag({ - double size, - Color color, - }) { - return StreamSvgIcon( - assetName: 'icon_flag.svg', - color: color, - width: size, - height: size, - ); - } - - factory StreamSvgIcon.retry({ - double size, - Color color, - }) { - return StreamSvgIcon( - assetName: 'icon_retry.svg', - color: color, - width: size, - height: size, ); } } diff --git a/packages/stream_chat_flutter/lib/src/swipeable.dart b/packages/stream_chat_flutter/lib/src/swipeable.dart index 8280bf24..041111e4 100644 --- a/packages/stream_chat_flutter/lib/src/swipeable.dart +++ b/packages/stream_chat_flutter/lib/src/swipeable.dart @@ -1,39 +1,50 @@ import 'dart:math' as math; import 'package:flutter/material.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; -import 'stream_chat_theme.dart'; - -/// +/// Widget to make a swipeable tile class Swipeable extends StatefulWidget { - final Widget child; - final Widget backgroundIcon; - final VoidCallback onSwipeStart; - final VoidCallback onSwipeCancel; - final VoidCallback onSwipeEnd; - final double threshold; - - /// + /// Constructor for creating a [Swipeable] widget const Swipeable({ - @required this.child, - @required this.backgroundIcon, + Key? key, + required this.child, + required this.backgroundIcon, this.onSwipeStart, this.onSwipeCancel, this.onSwipeEnd, this.threshold = 82.0, - }); + }) : super(key: key); + + /// Child to make swipeable + final Widget child; + + /// Background icon after swipe + final Widget backgroundIcon; + + /// Callback when swipe starts + final VoidCallback? onSwipeStart; + + /// Callback when swipe is cancelled + final VoidCallback? onSwipeCancel; + + /// Callback when swipe ends + final VoidCallback? onSwipeEnd; + + /// Threshold for swipe + final double threshold; @override State createState() => _SwipeableState(); } class _SwipeableState extends State with TickerProviderStateMixin { - double _dragExtent = 0.0; - AnimationController _moveController; - AnimationController _iconMoveController; - Animation _moveAnimation; - Animation _iconTransitionAnimation; - Animation _iconFadeAnimation; + double _dragExtent = 0; + late AnimationController _moveController; + late AnimationController _iconMoveController; + late Animation _moveAnimation; + late Animation _iconTransitionAnimation; + late Animation _iconFadeAnimation; bool _pastThreshold = false; final _animationDuration = const Duration(milliseconds: 200); @@ -45,15 +56,15 @@ class _SwipeableState extends State with TickerProviderStateMixin { AnimationController(duration: _animationDuration, vsync: this); _iconMoveController = AnimationController(duration: _animationDuration, vsync: this); - _moveAnimation = Tween(begin: Offset.zero, end: Offset(1.0, 0.0)) + _moveAnimation = Tween(begin: Offset.zero, end: const Offset(1, 0)) .animate(_moveController); _iconTransitionAnimation = - Tween(begin: Offset(-0.1, 0.0), end: Offset(0.4, 0.0)) + Tween(begin: const Offset(-0.1, 0), end: const Offset(0.4, 0)) .animate(_moveController); _iconFadeAnimation = - Tween(begin: 0.7, end: 1.0).animate(_iconMoveController); + Tween(begin: 0.7, end: 1).animate(_iconMoveController); - final controllerValue = 0.0; + const controllerValue = 0.0; _moveController.animateTo(controllerValue); _iconMoveController.animateTo(controllerValue); } @@ -67,30 +78,30 @@ class _SwipeableState extends State with TickerProviderStateMixin { void _handleDragStart(DragStartDetails details) { if (widget.onSwipeStart != null) { - widget.onSwipeStart(); + widget.onSwipeStart!(); } } void _handleDragUpdate(DragUpdateDetails details) { - final delta = details.primaryDelta; + final delta = details.primaryDelta!; _dragExtent += delta; if (_dragExtent.isNegative) return; - var movePastThresholdPixels = widget.threshold; - var newPos = _dragExtent.abs() / context.size.width; + final movePastThresholdPixels = widget.threshold; + var newPos = _dragExtent.abs() / context.size!.width; if (_dragExtent.abs() > movePastThresholdPixels) { // how many "thresholds" past the threshold we are. 1 = the threshold 2 // = two thresholds. - var n = _dragExtent.abs() / movePastThresholdPixels; + final n = _dragExtent.abs() / movePastThresholdPixels; // Take the number of thresholds past the threshold, and reduce this // number - var reducedThreshold = math.pow(n, 0.3); + final reducedThreshold = math.pow(n, 0.3); - var adjustedPixelPos = movePastThresholdPixels * reducedThreshold; - newPos = adjustedPixelPos / context.size.width; + final adjustedPixelPos = movePastThresholdPixels * reducedThreshold; + newPos = adjustedPixelPos / context.size!.width; if (_dragExtent > 0 && !_pastThreshold) { _iconMoveController.value = 1; @@ -100,7 +111,7 @@ class _SwipeableState extends State with TickerProviderStateMixin { // Send a cancel event if the user has swiped back underneath the // threshold if (_pastThreshold && widget.onSwipeCancel != null) { - widget.onSwipeCancel(); + widget.onSwipeCancel!(); } _pastThreshold = false; } @@ -111,53 +122,50 @@ class _SwipeableState extends State with TickerProviderStateMixin { } void _handleDragEnd(DragEndDetails details) { - _moveController.animateTo(0.0, duration: _animationDuration); - _iconMoveController.animateTo(0.0, duration: _animationDuration); + _moveController.animateTo(0, duration: _animationDuration); + _iconMoveController.animateTo(0, duration: _animationDuration); _dragExtent = 0.0; if (_pastThreshold && widget.onSwipeEnd != null) { - widget.onSwipeEnd(); + widget.onSwipeEnd!(); } } @override - Widget build(BuildContext context) { - return GestureDetector( - onHorizontalDragStart: _handleDragStart, - onHorizontalDragUpdate: _handleDragUpdate, - onHorizontalDragEnd: _handleDragEnd, - behavior: HitTestBehavior.opaque, - child: Stack( - alignment: Alignment.center, - fit: StackFit.passthrough, - children: [ - SlideTransition( - position: _iconTransitionAnimation, - child: Row( - children: [ - FadeTransition( - opacity: _iconFadeAnimation, - child: Container( - padding: const EdgeInsets.all(4), - decoration: BoxDecoration( - shape: BoxShape.circle, - border: Border.all( - color: StreamChatTheme.of(context) - .colorTheme - .greyGainsboro, + Widget build(BuildContext context) => GestureDetector( + onHorizontalDragStart: _handleDragStart, + onHorizontalDragUpdate: _handleDragUpdate, + onHorizontalDragEnd: _handleDragEnd, + behavior: HitTestBehavior.opaque, + child: Stack( + alignment: Alignment.center, + fit: StackFit.passthrough, + children: [ + SlideTransition( + position: _iconTransitionAnimation, + child: Row( + children: [ + FadeTransition( + opacity: _iconFadeAnimation, + child: Container( + padding: const EdgeInsets.all(4), + decoration: BoxDecoration( + shape: BoxShape.circle, + border: Border.all( + color: + StreamChatTheme.of(context).colorTheme.disabled, + ), ), + child: widget.backgroundIcon, ), - child: widget.backgroundIcon, ), - ), - ], + ], + ), ), - ), - SlideTransition( - position: _moveAnimation, - child: widget.child, - ), - ], - ), - ); - } + SlideTransition( + position: _moveAnimation, + child: widget.child, + ), + ], + ), + ); } diff --git a/packages/stream_chat_flutter/lib/src/system_message.dart b/packages/stream_chat_flutter/lib/src/system_message.dart index ac3176ce..11c80300 100644 --- a/packages/stream_chat_flutter/lib/src/system_message.dart +++ b/packages/stream_chat_flutter/lib/src/system_message.dart @@ -3,17 +3,19 @@ import 'package:stream_chat_flutter/stream_chat_flutter.dart'; /// It shows a date divider depending on the date difference class SystemMessage extends StatelessWidget { + /// Constructor for creating a [SystemMessage] + const SystemMessage({ + Key? key, + required this.message, + this.onMessageTap, + }) : super(key: key); + /// This message final Message message; + // ignore: lines_longer_than_80_chars /// The function called when tapping on the message when the message is not failed - final void Function(Message) onMessageTap; - - const SystemMessage({ - Key key, - @required this.message, - this.onMessageTap, - }) : super(key: key); + final void Function(Message)? onMessageTap; @override Widget build(BuildContext context) { @@ -22,15 +24,15 @@ class SystemMessage extends StatelessWidget { behavior: HitTestBehavior.opaque, onTap: () { if (onMessageTap != null) { - onMessageTap(message); + onMessageTap!(message); } }, child: Text( - message.text, + message.text!, textAlign: TextAlign.center, softWrap: true, style: theme.textTheme.captionBold.copyWith( - color: theme.colorTheme.grey, + color: theme.colorTheme.textLowEmphasis, ), ), ); diff --git a/packages/stream_chat_flutter/lib/src/thread_header.dart b/packages/stream_chat_flutter/lib/src/thread_header.dart index 449d4584..bfde9a4c 100644 --- a/packages/stream_chat_flutter/lib/src/thread_header.dart +++ b/packages/stream_chat_flutter/lib/src/thread_header.dart @@ -1,10 +1,7 @@ import 'package:flutter/material.dart'; -import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart'; import 'package:stream_chat_flutter/src/stream_chat_theme.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; - -import 'back_button.dart'; -import 'channel_name.dart'; +import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart'; /// ![screenshot](https://raw.githubusercontent.com/GetStream/stream-chat-flutter/master/screenshots/thread_header.png) /// ![screenshot](https://raw.githubusercontent.com/GetStream/stream-chat-flutter/master/screenshots/thread_header_paint.png) @@ -46,45 +43,24 @@ import 'channel_name.dart'; /// Usually you would use this widget as an [AppBar] inside a [Scaffold]. /// However you can also use it as a normal widget. /// -/// Make sure to have a [StreamChannel] ancestor in order to provide the information about the channel. -/// Every part of the widget uses a [StreamBuilder] to render the channel information as soon as it updates. +/// Make sure to have a [StreamChannel] ancestor in order to provide the +/// information about the channel. +/// Every part of the widget uses a [StreamBuilder] to render the channel +/// information as soon as it updates. /// /// By default the widget shows a backButton that calls [Navigator.pop]. -/// You can disable this button using the [showBackButton] property of just override the behaviour +/// You can disable this button using the [showBackButton] property of just +/// override the behaviour /// with [onBackPressed]. /// -/// The widget components render the ui based on the first ancestor of type [StreamChatTheme] and on its [ChannelTheme.channelHeaderTheme] property. +/// The widget components render the ui based on the first ancestor of type +/// [StreamChatTheme] and on its [ChannelTheme.channelHeaderTheme] property. /// Modify it to change the widget appearance. class ThreadHeader extends StatelessWidget implements PreferredSizeWidget { - /// True if this header shows the leading back button - final bool showBackButton; - - /// Callback to call when pressing the back button. - /// By default it calls [Navigator.pop] - final VoidCallback onBackPressed; - - /// Callback to call when the title is tapped. - final VoidCallback onTitleTap; - - /// The message parent of this thread - final Message parent; - - /// Title widget - final Widget title; - - /// Subtitle widget - final Widget subtitle; - - /// Leading widget - final Widget leading; - - /// AppBar actions - final List actions; - /// Instantiate a new ThreadHeader - ThreadHeader({ - Key key, - @required this.parent, + const ThreadHeader({ + Key? key, + required this.parent, this.showBackButton = true, this.onBackPressed, this.title, @@ -92,13 +68,64 @@ class ThreadHeader extends StatelessWidget implements PreferredSizeWidget { this.leading, this.actions, this.onTitleTap, - }) : preferredSize = Size.fromHeight(kToolbarHeight), + this.showTypingIndicator = true, + }) : preferredSize = const Size.fromHeight(kToolbarHeight), super(key: key); + /// True if this header shows the leading back button + final bool showBackButton; + + /// Callback to call when pressing the back button. + /// By default it calls [Navigator.pop] + final VoidCallback? onBackPressed; + + /// Callback to call when the title is tapped. + final VoidCallback? onTitleTap; + + /// The message parent of this thread + final Message parent; + + /// Title widget + final Widget? title; + + /// Subtitle widget + final Widget? subtitle; + + /// Leading widget + final Widget? leading; + + /// AppBar actions + final List? actions; + + /// If true the typing indicator will be rendered + /// if a user is typing in this thread + final bool showTypingIndicator; + @override Widget build(BuildContext context) { + final chatThemeData = StreamChatTheme.of(context); + + final defaultSubtitle = subtitle ?? + Row( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + 'with ', + style: chatThemeData.channelTheme.channelHeaderTheme.subtitle, + ), + Flexible( + child: ChannelName( + textStyle: + chatThemeData.channelTheme.channelHeaderTheme.subtitle, + ), + ), + ], + ); + return AppBar( automaticallyImplyLeading: false, + textTheme: Theme.of(context).textTheme, brightness: Theme.of(context).brightness, elevation: 1, leading: leading ?? @@ -108,51 +135,34 @@ class ThreadHeader extends StatelessWidget implements PreferredSizeWidget { onPressed: onBackPressed, showUnreads: true, ) - : SizedBox()), - backgroundColor: - StreamChatTheme.of(context).channelTheme.channelHeaderTheme.color, + : const SizedBox()), + backgroundColor: chatThemeData.channelTheme.channelHeaderTheme.color, centerTitle: true, actions: actions, title: InkWell( onTap: onTitleTap, - child: Container( + child: SizedBox( height: preferredSize.height, + width: 250, child: Column( - crossAxisAlignment: CrossAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center, children: [ title ?? Text( 'Thread Reply', - style: StreamChatTheme.of(context) - .channelTheme - .channelHeaderTheme - .title, - ), - SizedBox(height: 2), - subtitle ?? - Row( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.center, - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text( - 'with ', - style: StreamChatTheme.of(context) - .channelTheme - .channelHeaderTheme - .subtitle, - ), - Flexible( - child: ChannelName( - textStyle: StreamChatTheme.of(context) - .channelTheme - .channelHeaderTheme - .subtitle, - ), - ), - ], + style: chatThemeData.channelTheme.channelHeaderTheme.title, ), + const SizedBox(height: 2), + if (showTypingIndicator) + TypingIndicator( + alignment: Alignment.center, + channel: StreamChannel.of(context).channel, + style: chatThemeData.channelTheme.channelHeaderTheme.subtitle, + parentId: parent.id, + alternativeWidget: defaultSubtitle, + ) + else + defaultSubtitle, ], ), ), diff --git a/packages/stream_chat_flutter/lib/src/typing_indicator.dart b/packages/stream_chat_flutter/lib/src/typing_indicator.dart index 8a53c3e8..4c0ad19f 100644 --- a/packages/stream_chat_flutter/lib/src/typing_indicator.dart +++ b/packages/stream_chat_flutter/lib/src/typing_indicator.dart @@ -6,70 +6,74 @@ import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart'; class TypingIndicator extends StatelessWidget { /// Instantiate a new TypingIndicator const TypingIndicator({ - Key key, + Key? key, this.channel, this.alternativeWidget, this.style, this.alignment = Alignment.centerLeft, this.padding = const EdgeInsets.all(0), + this.parentId, }) : super(key: key); /// Style of the text widget - final TextStyle style; + final TextStyle? style; /// List of typing users - final Channel channel; + final Channel? channel; /// Widget built when no typings is happening - final Widget alternativeWidget; + final Widget? alternativeWidget; /// The padding of this widget final EdgeInsets padding; + /// Alignment of the typing indicator final Alignment alignment; + /// Id of the parent message in case of a thread + final String? parentId; + @override Widget build(BuildContext context) { final channelState = - channel?.state ?? StreamChannel.of(context).channel.state; - return StreamBuilder>( - initialData: channelState.typingEvents, - stream: channelState.typingEventsStream, - builder: (context, snapshot) { - return AnimatedSwitcher( - duration: Duration(milliseconds: 300), - child: snapshot.data?.isNotEmpty == true - ? Padding( - padding: padding, - child: Align( - key: Key('typings'), - alignment: alignment, - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Lottie.asset( - 'animations/typing_dots.json', - package: 'stream_chat_flutter', - height: 4, - ), - Text( - ' ${snapshot.data[0].name}${snapshot.data.length == 1 ? '' : ' and ${snapshot.data.length - 1} more'} ${snapshot.data.length == 1 ? 'is' : 'are'} typing', - maxLines: 1, - style: style, - ), - ], - ), - ), - ) - : Align( - key: Key('alternative'), + channel?.state ?? StreamChannel.of(context).channel.state!; + + final altWidget = alternativeWidget ?? const Offstage(); + + return BetterStreamBuilder>( + initialData: channelState.typingEvents.keys, + stream: channelState.typingEventsStream.map((typings) => typings.entries + .where((element) => element.value.parentId == parentId) + .map((e) => e.key)), + builder: (context, data) => AnimatedSwitcher( + duration: const Duration(milliseconds: 300), + child: data.isNotEmpty == true + ? Padding( + key: const Key('main'), + padding: padding, + child: Align( + key: const Key('typings'), alignment: alignment, - child: Container( - child: alternativeWidget ?? Offstage(), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Lottie.asset( + 'animations/typing_dots.json', + package: 'stream_chat_flutter', + height: 4, + ), + Text( + // ignore: lines_longer_than_80_chars + ' ${data.elementAt(0).name}${data.length == 1 ? '' : ' and ${data.length - 1} more'} ${data.length == 1 ? 'is' : 'are'} typing', + maxLines: 1, + style: style, + ), + ], ), ), - ); - }, + ) + : altWidget, + ), ); } } diff --git a/packages/stream_chat_flutter/lib/src/unread_indicator.dart b/packages/stream_chat_flutter/lib/src/unread_indicator.dart index 61a92987..381bad39 100644 --- a/packages/stream_chat_flutter/lib/src/unread_indicator.dart +++ b/packages/stream_chat_flutter/lib/src/unread_indicator.dart @@ -2,29 +2,31 @@ import 'package:flutter/material.dart'; import 'package:stream_chat_flutter/src/stream_chat_theme.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; +/// Widget for showing an unread indicator class UnreadIndicator extends StatelessWidget { + /// Constructor for creating an [UnreadIndicator] const UnreadIndicator({ - Key key, + Key? key, this.cid, }) : super(key: key); /// Channel cid used to retrieve unread count - final String cid; + final String? cid; @override Widget build(BuildContext context) { final client = StreamChat.of(context).client; return IgnorePointer( - child: StreamBuilder( + child: BetterStreamBuilder( stream: cid != null - ? client.state.channels[cid].state.unreadCountStream + ? client.state.channels[cid]?.state?.unreadCountStream : client.state.totalUnreadCountStream, initialData: cid != null - ? client.state.channels[cid].state.unreadCount + ? client.state.channels[cid]?.state?.unreadCount : client.state.totalUnreadCount, - builder: (context, snapshot) { - if (!snapshot.hasData || snapshot.data == 0) { - return SizedBox(); + builder: (context, data) { + if (data == null || data == 0) { + return const Offstage(); } return Material( borderRadius: BorderRadius.circular(8), @@ -33,15 +35,15 @@ class UnreadIndicator extends StatelessWidget { .unreadCounterColor, child: Padding( padding: const EdgeInsets.only( - left: 5.0, - right: 5.0, + left: 5, + right: 5, top: 2, bottom: 1, ), child: Center( child: Text( - '${snapshot.data}', - style: TextStyle( + '${data > 99 ? '99+' : data}', + style: const TextStyle( fontSize: 11, color: Colors.white, ), diff --git a/packages/stream_chat_flutter/lib/src/upload_progress_indicator.dart b/packages/stream_chat_flutter/lib/src/upload_progress_indicator.dart index 53c121e2..b3fe510d 100644 --- a/packages/stream_chat_flutter/lib/src/upload_progress_indicator.dart +++ b/packages/stream_chat_flutter/lib/src/upload_progress_indicator.dart @@ -1,25 +1,42 @@ import 'package:flutter/material.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; -import 'stream_chat_theme.dart'; - +/// Widget for showing upload progress class UploadProgressIndicator extends StatelessWidget { - final int uploaded; - final int total; - final Color progressIndicatorColor; - final EdgeInsetsGeometry padding; - final bool showBackground; - final TextStyle textStyle; - + /// Constructor for creating an [UploadProgressIndicator] const UploadProgressIndicator({ - Key key, - @required this.uploaded, - @required this.total, + Key? key, + required this.uploaded, + required this.total, this.progressIndicatorColor = const Color(0xffb2b2b2), - this.padding = const EdgeInsets.only(top: 5, bottom: 5, right: 11, left: 5), + this.padding = const EdgeInsets.only( + top: 5, + bottom: 5, + right: 11, + left: 5, + ), this.showBackground = true, this.textStyle, }) : super(key: key); + /// Bytes uploaded + final int uploaded; + + /// Total bytes + final int total; + + /// Color of progress indicator + final Color progressIndicatorColor; + + /// Padding for widget + final EdgeInsetsGeometry padding; + + /// Flag for showing background + final bool showBackground; + + /// [TextStyle] to be applied to text + final TextStyle? textStyle; + @override Widget build(BuildContext context) { final theme = StreamChatTheme.of(context); @@ -37,12 +54,12 @@ class UploadProgressIndicator extends StatelessWidget { valueColor: AlwaysStoppedAnimation(progressIndicatorColor), ), ), - SizedBox(width: 8), + const SizedBox(width: 8), Text( '${_percentage.toInt()}%', style: textStyle ?? theme.textTheme.footnote.copyWith( - color: theme.colorTheme.white, + color: theme.colorTheme.barsBg, ), ), ], diff --git a/packages/stream_chat_flutter/lib/src/url_attachment.dart b/packages/stream_chat_flutter/lib/src/url_attachment.dart index 9dc5cdc8..5d5ca489 100644 --- a/packages/stream_chat_flutter/lib/src/url_attachment.dart +++ b/packages/stream_chat_flutter/lib/src/url_attachment.dart @@ -3,67 +3,76 @@ import 'package:flutter/material.dart'; import 'package:stream_chat_flutter/src/utils.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; +/// Widget to display URL attachment class UrlAttachment extends StatelessWidget { - final Attachment urlAttachment; - final String hostDisplayName; - final EdgeInsets textPadding; + /// Constructor for creating a [UrlAttachment] + const UrlAttachment({ + Key? key, + required this.urlAttachment, + required this.hostDisplayName, + this.textPadding = const EdgeInsets.symmetric( + horizontal: 16, + vertical: 8, + ), + }) : super(key: key); - UrlAttachment({ - @required this.urlAttachment, - @required this.hostDisplayName, - @required this.textPadding, - }); + /// Attachment to be displayed + final Attachment urlAttachment; + + /// Host name + final String hostDisplayName; + + /// Padding for text + final EdgeInsets textPadding; @override Widget build(BuildContext context) { + final chatThemeData = StreamChatTheme.of(context); return GestureDetector( - onTap: () => launchURL( - context, - urlAttachment.ogScrapeUrl, - ), + onTap: () { + launchURL( + context, + urlAttachment.ogScrapeUrl, + ); + }, child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ if (urlAttachment.imageUrl != null) Container( - clipBehavior: Clip.antiAliasWithSaveLayer, - margin: EdgeInsets.symmetric(horizontal: 8.0), + clipBehavior: Clip.hardEdge, + margin: const EdgeInsets.symmetric(horizontal: 8), decoration: BoxDecoration( - borderRadius: BorderRadius.circular(8.0), + borderRadius: BorderRadius.circular(8), ), child: Stack( children: [ CachedNetworkImage( width: double.infinity, - imageUrl: urlAttachment.imageUrl, + imageUrl: urlAttachment.imageUrl!, fit: BoxFit.cover, ), Positioned( - left: 0.0, + left: 0, bottom: -1, child: Container( decoration: BoxDecoration( - borderRadius: BorderRadius.only( - topRight: Radius.circular(16.0), + borderRadius: const BorderRadius.only( + topRight: Radius.circular(16), ), - color: StreamChatTheme.of(context).colorTheme.blueAlice, + color: chatThemeData.colorTheme.linkBg, ), child: Padding( padding: const EdgeInsets.only( - top: 8.0, - left: 8.0, - right: 8.0, + top: 8, + left: 8, + right: 8, ), child: Text( hostDisplayName, - style: StreamChatTheme.of(context) - .textTheme - .bodyBold - .copyWith( - color: StreamChatTheme.of(context) - .colorTheme - .accentBlue, - ), + style: chatThemeData.textTheme.bodyBold.copyWith( + color: chatThemeData.colorTheme.accentPrimary, + ), ), ), ), @@ -78,20 +87,16 @@ class UrlAttachment extends StatelessWidget { children: [ if (urlAttachment.title != null) Text( - urlAttachment.title.trim(), + urlAttachment.title!.trim(), maxLines: 1, overflow: TextOverflow.ellipsis, - style: StreamChatTheme.of(context) - .textTheme - .body + style: chatThemeData.textTheme.body .copyWith(fontWeight: FontWeight.w700), ), if (urlAttachment.text != null) Text( - urlAttachment.text, - style: StreamChatTheme.of(context) - .textTheme - .body + urlAttachment.text!, + style: chatThemeData.textTheme.body .copyWith(fontWeight: FontWeight.w400), ), ], diff --git a/packages/stream_chat_flutter/lib/src/user_avatar.dart b/packages/stream_chat_flutter/lib/src/user_avatar.dart index 41860a7f..31e07f0a 100644 --- a/packages/stream_chat_flutter/lib/src/user_avatar.dart +++ b/packages/stream_chat_flutter/lib/src/user_avatar.dart @@ -1,13 +1,14 @@ import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart'; -import '../stream_chat_flutter.dart'; - +/// Widget that displays a user avatar class UserAvatar extends StatelessWidget { + /// Constructor to create a [UserAvatar] const UserAvatar({ - Key key, - @required this.user, + Key? key, + required this.user, this.constraints, this.onlineIndicatorConstraints, this.onTap, @@ -20,21 +21,42 @@ class UserAvatar extends StatelessWidget { this.selectionThickness = 4, }) : super(key: key); + /// User whose avatar is to displayed final User user; + + /// Alignment of the online indicator final Alignment onlineIndicatorAlignment; - final BoxConstraints constraints; - final BorderRadius borderRadius; - final BoxConstraints onlineIndicatorConstraints; - final void Function(User) onTap; - final void Function(User) onLongPress; + + /// Size of the avatar + final BoxConstraints? constraints; + + /// [BorderRadius] of the image + final BorderRadius? borderRadius; + + /// Size of the online indicator + final BoxConstraints? onlineIndicatorConstraints; + + /// Callback when avatar is tapped + final void Function(User)? onTap; + + /// Callback when avatar is long pressed + final void Function(User)? onLongPress; + + /// Flag for showing online status final bool showOnlineStatus; + + /// Flag for if avatar is selected final bool selected; - final Color selectionColor; + + /// Color of selection + final Color? selectionColor; + + /// Selection thickness around the avatar final double selectionThickness; @override Widget build(BuildContext context) { - final hasImage = user.extraData?.containsKey('image') == true && + final hasImage = user.extraData.containsKey('image') && user.extraData['image'] != null && user.extraData['image'] != ''; final streamChatTheme = StreamChatTheme.of(context); @@ -42,22 +64,21 @@ class UserAvatar extends StatelessWidget { Widget avatar = FittedBox( fit: BoxFit.cover, child: ClipRRect( - clipBehavior: Clip.antiAlias, borderRadius: borderRadius ?? - streamChatTheme.ownMessageTheme.avatarTheme.borderRadius, + streamChatTheme.ownMessageTheme.avatarTheme?.borderRadius, child: Container( constraints: constraints ?? - streamChatTheme.ownMessageTheme.avatarTheme.constraints, + streamChatTheme.ownMessageTheme.avatarTheme?.constraints, decoration: BoxDecoration( - color: streamChatTheme.colorTheme.accentBlue, + color: streamChatTheme.colorTheme.accentPrimary, ), child: hasImage ? CachedNetworkImage( filterQuality: FilterQuality.high, - imageUrl: user.extraData['image'], - errorWidget: (_, __, ___) { - return streamChatTheme.defaultUserImage(context, user); - }, + // ignore: cast_nullable_to_non_nullable + imageUrl: user.extraData['image'] as String, + errorWidget: (_, __, ___) => + streamChatTheme.defaultUserImage(context, user), fit: BoxFit.cover, ) : streamChatTheme.defaultUserImage(context, user), @@ -68,13 +89,13 @@ class UserAvatar extends StatelessWidget { if (selected) { avatar = ClipRRect( borderRadius: (borderRadius ?? - streamChatTheme.ownMessageTheme.avatarTheme.borderRadius) + + streamChatTheme.ownMessageTheme.avatarTheme?.borderRadius ?? + BorderRadius.zero) + BorderRadius.circular(selectionThickness), child: Container( constraints: constraints ?? - streamChatTheme.ownMessageTheme.avatarTheme.constraints, - color: selectionColor ?? - StreamChatTheme.of(context).colorTheme.accentBlue, + streamChatTheme.ownMessageTheme.avatarTheme?.constraints, + color: selectionColor ?? streamChatTheme.colorTheme.accentPrimary, child: Padding( padding: EdgeInsets.all(selectionThickness), child: avatar, @@ -83,28 +104,28 @@ class UserAvatar extends StatelessWidget { ); } return GestureDetector( - onTap: onTap != null ? () => onTap(user) : null, - onLongPress: onLongPress != null ? () => onLongPress(user) : null, + onTap: onTap != null ? () => onTap!(user) : null, + onLongPress: onLongPress != null ? () => onLongPress!(user) : null, child: Stack( children: [ avatar, - if (showOnlineStatus && user.online == true) + if (showOnlineStatus && user.online) Positioned.fill( child: Align( alignment: onlineIndicatorAlignment, child: Material( type: MaterialType.circle, - color: streamChatTheme.colorTheme.white, + color: streamChatTheme.colorTheme.barsBg, child: Container( - margin: const EdgeInsets.all(2.0), + margin: const EdgeInsets.all(2), constraints: onlineIndicatorConstraints ?? - BoxConstraints.tightFor( + const BoxConstraints.tightFor( width: 8, height: 8, ), child: Material( - shape: CircleBorder(), - color: streamChatTheme.colorTheme.accentGreen, + shape: const CircleBorder(), + color: streamChatTheme.colorTheme.accentInfo, ), ), ), diff --git a/packages/stream_chat_flutter/lib/src/user_item.dart b/packages/stream_chat_flutter/lib/src/user_item.dart index cb82e311..a13b544e 100644 --- a/packages/stream_chat_flutter/lib/src/user_item.dart +++ b/packages/stream_chat_flutter/lib/src/user_item.dart @@ -1,26 +1,27 @@ import 'package:flutter/material.dart'; import 'package:jiffy/jiffy.dart'; -import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart'; import 'package:stream_chat_flutter/src/stream_svg_icon.dart'; import 'package:stream_chat_flutter/src/user_list_view.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; - -import 'stream_chat_theme.dart'; +import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart'; /// /// It shows the current [User] preview. /// -/// The widget uses a [StreamBuilder] to render the user information image as soon as it updates. +/// The widget uses a [StreamBuilder] to render the user information +/// image as soon as it updates. /// -/// Usually you don't use this widget as it's the default user preview used by [UserListView]. +/// Usually you don't use this widget as it's the default user preview used +/// by [UserListView]. /// -/// The widget renders the ui based on the first ancestor of type [StreamChatTheme]. +/// The widget renders the ui based on the first ancestor of type +/// [StreamChatTheme]. /// Modify it to change the widget appearance. class UserItem extends StatelessWidget { /// Instantiate a new UserItem const UserItem({ - Key key, - @required this.user, + Key? key, + required this.user, this.onTap, this.onLongPress, this.onImageTap, @@ -29,16 +30,16 @@ class UserItem extends StatelessWidget { }) : super(key: key); /// Function called when tapping this widget - final void Function(User) onTap; + final void Function(User)? onTap; /// Function called when long pressing this widget - final void Function(User) onLongPress; + final void Function(User)? onLongPress; /// User displayed final User user; /// The function called when the image is tapped - final void Function(User) onImageTap; + final void Function(User)? onImageTap; /// If true the [UserItem] will show a trailing checkmark final bool selected; @@ -48,50 +49,51 @@ class UserItem extends StatelessWidget { @override Widget build(BuildContext context) { + final chatThemeData = StreamChatTheme.of(context); return ListTile( onTap: () { if (onTap != null) { - onTap(user); + onTap!(user); } }, onLongPress: () { if (onLongPress != null) { - onLongPress(user); + onLongPress!(user); } }, leading: UserAvatar( user: user, - showOnlineStatus: true, onTap: (user) { if (onImageTap != null) { - onImageTap(user); + onImageTap!(user); } }, - constraints: BoxConstraints.tightFor( + constraints: const BoxConstraints.tightFor( height: 40, width: 40, ), ), trailing: selected ? StreamSvgIcon.checkSend( - color: StreamChatTheme.of(context).colorTheme.accentBlue, + color: chatThemeData.colorTheme.accentPrimary, ) : null, title: Text( user.name, - style: StreamChatTheme.of(context).textTheme.bodyBold, + style: chatThemeData.textTheme.bodyBold, ), subtitle: showLastOnline ? _buildLastActive(context) : null, ); } Widget _buildLastActive(context) { + final chatTheme = StreamChatTheme.of(context); return Text( user.online == true ? 'Online' : 'Last online ${Jiffy(user.lastActive).fromNow()}', - style: StreamChatTheme.of(context).textTheme.footnote.copyWith( - color: StreamChatTheme.of(context).colorTheme.black.withOpacity(.5)), + style: chatTheme.textTheme.footnote.copyWith( + color: chatTheme.colorTheme.textHighEmphasis.withOpacity(.5)), ); } } diff --git a/packages/stream_chat_flutter/lib/src/user_list_view.dart b/packages/stream_chat_flutter/lib/src/user_list_view.dart index d4bc2f72..923e0334 100644 --- a/packages/stream_chat_flutter/lib/src/user_list_view.dart +++ b/packages/stream_chat_flutter/lib/src/user_list_view.dart @@ -2,10 +2,8 @@ import 'package:flutter/material.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart'; -import 'user_item.dart'; - /// Callback called when tapping on a user -typedef UserTapCallback = void Function(User, Widget); +typedef UserTapCallback = void Function(User, Widget?); /// Builder used to create a custom [ListUserItem] from a [User] typedef UserItemBuilder = Widget Function(BuildContext, User, bool); @@ -36,18 +34,21 @@ typedef UserItemBuilder = Widget Function(BuildContext, User, bool); /// ``` /// /// -/// Make sure to have a [UsersBloc] ancestor in order to provide the information about the users. -/// The widget uses a [ListView.separated], [GridView.builder] to render the list, grid of channels. +/// Make sure to have a [UsersBloc] ancestor in order to provide the +/// information about the users. +/// The widget uses a [ListView.separated], [GridView.builder] to render the +/// list, grid of channels. /// -/// The widget components render the ui based on the first ancestor of type [StreamChatTheme]. +/// The widget components render the ui based on the first ancestor of +/// type [StreamChatTheme]. /// Modify it to change the widget appearance. class UserListView extends StatefulWidget { /// Instantiate a new UserListView const UserListView({ - Key key, + Key? key, this.filter, - this.options, this.sort, + this.presence, this.pagination, this.onUserTap, this.onUserLongPress, @@ -63,6 +64,7 @@ class UserListView extends StatefulWidget { this.emptyBuilder, this.loadingBuilder, this.listBuilder, + this.userListController, }) : assert( crossAxisCount == 1 || groupAlphabetically == false, 'Cannot group alphabetically when crossAxisCount > 1', @@ -72,51 +74,51 @@ class UserListView extends StatefulWidget { /// The query filters to use. /// You can query on any of the custom fields you've defined on the [Channel]. /// You can also filter other built-in channel fields. - final Map filter; - - /// Query channels options. - /// - /// state: if true returns the Channel state - /// watch: if true listen to changes to this Channel in real time. - final Map options; + final Filter? filter; /// The sorting used for the channels matching the filters. - /// Sorting is based on field and direction, multiple sorting options can be provided. - /// You can sort based on last_updated, last_message_at, updated_at, created_at or member_count. + /// Sorting is based on field and direction, multiple sorting options can + /// be provided. + /// You can sort based on last_updated, last_message_at, updated_at, created_ + /// at or member_count. /// Direction can be ascending or descending. - final List sort; + final List? sort; + + /// If true you’ll receive user presence updates via the websocket events + final bool? presence; /// Pagination parameters /// limit: the number of users to return (max is 30) /// offset: the offset (max is 1000) /// message_limit: how many messages should be included to each channel - final PaginationParams pagination; + final PaginationParams? pagination; /// Function called when tapping on a channel /// By default it calls [Navigator.push] building a [MaterialPageRoute] /// with the widget [userWidget] as child. - final UserTapCallback onUserTap; + final UserTapCallback? onUserTap; /// Function called when long pressing on a channel - final Function(User) onUserLongPress; + final Function(User)? onUserLongPress; /// Widget used when opening a channel - final Widget userWidget; + final Widget? userWidget; /// Builder used to create a custom user preview - final UserItemBuilder userItemBuilder; + final UserItemBuilder? userItemBuilder; /// Builder used to create a custom item separator - final Function(BuildContext, int) separatorBuilder; + final Function(BuildContext, int)? separatorBuilder; /// The function called when the image is tapped - final Function(User) onImageTap; + final Function(User)? onImageTap; /// Set it to false to disable the pull-to-refresh widget final bool pullToRefresh; - /// Sets a blue trailing checkMark in [ListUserItem] for all the [selectedUsers] - final Set selectedUsers; + /// Sets a blue trailing checkMark in [ListUserItem] for all the + /// [selectedUsers] + final Set? selectedUsers; /// Set it to true to group users by their first character /// @@ -127,16 +129,22 @@ class UserListView extends StatefulWidget { final int crossAxisCount; /// The builder that will be used in case of error - final Widget Function(Error error) errorBuilder; + final ErrorBuilder? errorBuilder; /// The builder that will be used to build the list - final Widget Function(BuildContext context, List users) listBuilder; + final Widget Function(BuildContext context, List users)? + listBuilder; /// The builder that will be used for loading - final WidgetBuilder loadingBuilder; + final WidgetBuilder? loadingBuilder; /// The builder used when the channel list is empty. - final WidgetBuilder emptyBuilder; + final WidgetBuilder? emptyBuilder; + + /// A [UserListController] allows reloading and pagination. + /// Use [UserListController.loadData] and [UserListController.paginateData] + /// respectively for reloading and pagination. + final UserListController? userListController; @override _UserListViewState createState() => _UserListViewState(); @@ -146,45 +154,37 @@ class _UserListViewState extends State with WidgetsBindingObserver { bool get _isListView => widget.crossAxisCount == 1; - final UserListController _userListController = UserListController(); + late final _defaultController = UserListController(); + UserListController get _userListController => + widget.userListController ?? _defaultController; @override Widget build(BuildContext context) { - var child = UserListCore( + final child = UserListCore( errorBuilder: widget.errorBuilder ?? - (err) { - return _buildError(err); - }, - emptyBuilder: widget.emptyBuilder ?? - (context) { - return _buildEmpty(); - }, + (BuildContext context, Object err) => _buildError(err), + emptyBuilder: widget.emptyBuilder ?? (context) => _buildEmpty(), loadingBuilder: widget.loadingBuilder ?? - (context) { - return LayoutBuilder( - builder: (context, viewportConstraints) { - return SingleChildScrollView( - physics: AlwaysScrollableScrollPhysics(), + (context) => LayoutBuilder( + builder: (context, viewportConstraints) => + SingleChildScrollView( + physics: const AlwaysScrollableScrollPhysics(), child: ConstrainedBox( constraints: BoxConstraints( minHeight: viewportConstraints.maxHeight, ), - child: Center( + child: const Center( child: CircularProgressIndicator(), ), ), - ); - }, - ); - }, - listBuilder: widget.listBuilder ?? - (context, list) { - return _buildListView(list); - }, + ), + ), + listBuilder: + widget.listBuilder ?? (context, list) => _buildListView(list), pagination: widget.pagination, - options: widget.options, sort: widget.sort, filter: widget.filter, + presence: widget.presence, groupAlphabetically: widget.groupAlphabetically, userListController: _userListController, ); @@ -193,7 +193,7 @@ class _UserListViewState extends State return child; } else { return RefreshIndicator( - onRefresh: () => _userListController.loadData(), + onRefresh: () => _userListController.loadData!(), child: child, ); } @@ -202,101 +202,76 @@ class _UserListViewState extends State bool get isListAlreadySorted => widget.sort?.any((e) => e.field == 'name' && e.direction == 1) ?? false; - Widget _buildError(Error error) { - print((error).stackTrace); - - var message = error.toString(); - if (error is DioError) { - final dioError = error as DioError; - if (dioError.type == DioErrorType.RESPONSE) { - message = dioError.message; - } else { - message = 'Check your connection and retry'; - } - } - return Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text.rich( - TextSpan( - children: [ - WidgetSpan( - child: Padding( - padding: const EdgeInsets.only( - right: 2.0, + Widget _buildError(Object error) => Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text.rich( + const TextSpan( + children: [ + WidgetSpan( + child: Padding( + padding: EdgeInsets.only( + right: 2, + ), + child: Icon(Icons.error_outline), ), - child: Icon(Icons.error_outline), ), - ), - TextSpan(text: 'Error loading channels'), - ], + TextSpan(text: 'Error loading users'), + ], + ), + style: Theme.of(context).textTheme.headline6, ), - style: Theme.of(context).textTheme.headline6, - ), - Padding( - padding: const EdgeInsets.only( - top: 16.0, + TextButton( + onPressed: () => _userListController.loadData!(), + child: const Text('Retry'), ), - child: Text(message), - ), - TextButton( - onPressed: () => _userListController.loadData(), - child: Text('Retry'), - ), - ], - ), - ); - } + ], + ), + ); - Widget _buildEmpty() { - return LayoutBuilder( - builder: (context, viewportConstraints) { - return SingleChildScrollView( - physics: AlwaysScrollableScrollPhysics(), + Widget _buildEmpty() => LayoutBuilder( + builder: (context, viewportConstraints) => SingleChildScrollView( + physics: const AlwaysScrollableScrollPhysics(), child: ConstrainedBox( constraints: BoxConstraints( minHeight: viewportConstraints.maxHeight, ), - child: Center( + child: const Center( child: Text('There are no users currently'), ), ), - ); - }, - ); - } + ), + ); Widget _buildListView( List items, ) { final child = _isListView ? ListView.separated( - physics: AlwaysScrollableScrollPhysics(), + physics: const AlwaysScrollableScrollPhysics(), itemCount: items.isNotEmpty ? items.length + 1 : items.length, separatorBuilder: (_, index) { if (widget.separatorBuilder != null) { - return widget.separatorBuilder(context, index); + return widget.separatorBuilder!(context, index); } return _separatorBuilder(context, index); }, - itemBuilder: (context, index) { - return _listItemBuilder(context, index, items); - }, + itemBuilder: (context, index) => + _listItemBuilder(context, index, items), ) : GridView.builder( gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( crossAxisCount: widget.crossAxisCount, ), itemCount: items.isNotEmpty ? items.length + 1 : items.length, - physics: AlwaysScrollableScrollPhysics(), - itemBuilder: (context, index) { - return _gridItemBuilder(context, index, items); - }, + physics: const AlwaysScrollableScrollPhysics(), + itemBuilder: (context, index) => + _gridItemBuilder(context, index, items), ); return LazyLoadScrollView( - onEndOfPage: () => _userListController.paginateData(), + onEndOfPage: () => _userListController.paginateData!(), child: child, ); } @@ -307,18 +282,18 @@ class _UserListViewState extends State final item = items[i]; return item.when( headerItem: (header) { + final chatThemeData = StreamChatTheme.of(context); return Container( key: ValueKey('HEADER-$header'), - color: - StreamChatTheme.of(context).colorTheme.black.withOpacity(0.05), + color: chatThemeData.colorTheme.textHighEmphasis.withOpacity(0.05), child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 6), + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6), child: Text( header, style: TextStyle( fontWeight: FontWeight.bold, fontSize: 14.5, - color: StreamChatTheme.of(context).colorTheme.grey, + color: chatThemeData.colorTheme.textLowEmphasis, ), ), ), @@ -329,10 +304,10 @@ class _UserListViewState extends State return Container( key: ValueKey('USER-${user.id}'), child: widget.userItemBuilder != null - ? widget.userItemBuilder(context, user, selected) + ? widget.userItemBuilder!(context, user, selected) : UserItem( user: user, - onTap: (user) => widget.onUserTap(user, widget.userWidget), + onTap: (user) => widget.onUserTap!(user, widget.userWidget), onLongPress: widget.onUserLongPress, onImageTap: widget.onImageTap, selected: selected, @@ -350,34 +325,34 @@ class _UserListViewState extends State if (i < items.length) { final item = items[i]; return item.when( - headerItem: (_) => Offstage(), + headerItem: (_) => const Offstage(), userItem: (user) { final selected = widget.selectedUsers?.contains(user) ?? false; return Container( key: ValueKey('USER-${user.id}'), child: widget.userItemBuilder != null - ? widget.userItemBuilder(context, user, selected) + ? widget.userItemBuilder!(context, user, selected) : Column( mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.center, children: [ UserAvatar( user: user, borderRadius: BorderRadius.circular(32), selected: selected, - constraints: BoxConstraints.tightFor( + constraints: const BoxConstraints.tightFor( height: 64, width: 64, ), - onlineIndicatorConstraints: BoxConstraints.tightFor( + onlineIndicatorConstraints: + const BoxConstraints.tightFor( height: 12, width: 12, ), onTap: (user) => - widget.onUserTap(user, widget.userWidget), + widget.onUserTap!(user, widget.userWidget), onLongPress: widget.onUserLongPress, ), - SizedBox(height: 4), + const SizedBox(height: 4), Padding( padding: const EdgeInsets.symmetric(horizontal: 8), child: Text( @@ -385,7 +360,7 @@ class _UserListViewState extends State textAlign: TextAlign.center, maxLines: 2, overflow: TextOverflow.ellipsis, - style: TextStyle( + style: const TextStyle( fontWeight: FontWeight.bold, fontSize: 12, ), @@ -401,39 +376,38 @@ class _UserListViewState extends State } } - Widget _buildQueryProgressIndicator(context, UsersBlocState usersProvider) { - return StreamBuilder( - stream: usersProvider.queryUsersLoading, - initialData: false, - builder: (context, snapshot) { - if (snapshot.hasError) { - return Container( - color: StreamChatTheme.of(context) - .colorTheme - .accentRed - .withOpacity(.2), - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 16.0), - child: Center( - child: Text('Error loading users'), + Widget _buildQueryProgressIndicator(context, UsersBlocState usersProvider) => + StreamBuilder( + stream: usersProvider.queryUsersLoading, + initialData: false, + builder: (context, snapshot) { + if (snapshot.hasError) { + return Container( + color: StreamChatTheme.of(context) + .colorTheme + .accentError + .withOpacity(.2), + child: const Padding( + padding: EdgeInsets.symmetric(vertical: 16), + child: Center( + child: Text('Error loading users'), + ), ), + ); + } + return Container( + height: 100, + padding: const EdgeInsets.all(32), + child: Center( + child: snapshot.data! + ? const CircularProgressIndicator() + : Container(), ), ); - } - return Container( - height: 100, - padding: EdgeInsets.all(32), - child: Center( - child: snapshot.data ? CircularProgressIndicator() : Container(), - ), - ); - }); - } + }); - Widget _separatorBuilder(context, i) { - return Container( - height: 1, - color: StreamChatTheme.of(context).colorTheme.greyWhisper, - ); - } + Widget _separatorBuilder(context, i) => Container( + height: 1, + color: StreamChatTheme.of(context).colorTheme.borders, + ); } diff --git a/packages/stream_chat_flutter/lib/src/user_reaction_display.dart b/packages/stream_chat_flutter/lib/src/user_reaction_display.dart index af2797ca..b5ef623e 100644 --- a/packages/stream_chat_flutter/lib/src/user_reaction_display.dart +++ b/packages/stream_chat_flutter/lib/src/user_reaction_display.dart @@ -1,55 +1,60 @@ import 'package:flutter/material.dart'; -import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart'; import 'package:stream_chat_flutter/src/user_avatar.dart'; +import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart'; +/// Displays a list of users who reacted class UserReactionDisplay extends StatelessWidget { + /// Constructor for creating a [UserReactionDisplay] const UserReactionDisplay({ - Key key, - @required this.reactionToEmoji, - @required this.message, + Key? key, + required this.reactionToEmoji, + required this.message, this.size = 30, }) : super(key: key); + /// Reaction map final Map reactionToEmoji; + + /// Message which is reacted to final Message message; + + /// Size of Icon final double size; @override - Widget build(BuildContext context) { - return Container( - color: Colors.black87, - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisAlignment: MainAxisAlignment.center, - mainAxisSize: MainAxisSize.min, - children: reactionToEmoji.keys.map((reactionType) { - var firstUserReaction = message.latestReactions.firstWhere( - (element) => element.type == reactionType, orElse: () { - return null; - }); + Widget build(BuildContext context) => Container( + color: Colors.black87, + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: reactionToEmoji.keys.map((reactionType) { + final firstUserReaction = message.latestReactions! + .firstWhere((element) => element.type == reactionType, + //ignore: unnecessary_parenthesis + orElse: (() => null) as Reaction Function()?); + + if (firstUserReaction.user == null) { + return IconButton( + iconSize: size, + icon: Container(), + onPressed: null, + ); + } - if (firstUserReaction == null) { return IconButton( iconSize: size, - icon: Container(), - onPressed: null, - ); - } - - return IconButton( - iconSize: size, - icon: UserAvatar( - user: firstUserReaction.user, - constraints: BoxConstraints( - maxHeight: size - 5, - maxWidth: size - 5, + icon: UserAvatar( + user: firstUserReaction.user!, + constraints: BoxConstraints( + maxHeight: size - 5, + maxWidth: size - 5, + ), + onTap: (user) {}, ), - onTap: (user) {}, - ), - onPressed: () {}, - ); - }).toList(), - ), - ); - } + onPressed: () {}, + ); + }).toList(), + ), + ); } diff --git a/packages/stream_chat_flutter/lib/src/utils.dart b/packages/stream_chat_flutter/lib/src/utils.dart index 8950a77c..aee46800 100644 --- a/packages/stream_chat_flutter/lib/src/utils.dart +++ b/packages/stream_chat_flutter/lib/src/utils.dart @@ -1,87 +1,86 @@ -import 'dart:math'; +import 'dart:async'; import 'package:flutter/material.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart'; import 'package:url_launcher/url_launcher.dart'; -import '../stream_chat_flutter.dart'; -import 'stream_svg_icon.dart'; - -Future launchURL(BuildContext context, String url) async { - if (await canLaunch(url)) { +/// Launch URL +Future launchURL(BuildContext context, String? url) async { + if (url != null && await canLaunch(url)) { await launch(url); } else { // ignore: deprecated_member_use Scaffold.of(context).showSnackBar( - SnackBar( + const SnackBar( content: Text('Cannot launch the url'), ), ); } } -Future showConfirmationDialog( +/// Shows confirmation dialog +Future showConfirmationDialog( BuildContext context, { - String title, - Widget icon, - String question, - String okText, - String cancelText, + required String title, + required String okText, + Widget? icon, + String? question, + String? cancelText, }) { + final chatThemeData = StreamChatTheme.of(context); return showModalBottomSheet( - backgroundColor: StreamChatTheme.of(context).colorTheme.white, + useRootNavigator: false, + backgroundColor: chatThemeData.colorTheme.barsBg, context: context, - shape: RoundedRectangleBorder( + shape: const RoundedRectangleBorder( borderRadius: BorderRadius.only( - topLeft: Radius.circular(16.0), - topRight: Radius.circular(16.0), + topLeft: Radius.circular(16), + topRight: Radius.circular(16), )), builder: (context) { - final effect = StreamChatTheme.of(context).colorTheme.borderTop; + final effect = chatThemeData.colorTheme.borderTop; return SafeArea( child: Column( mainAxisSize: MainAxisSize.min, children: [ - SizedBox(height: 26.0), + const SizedBox(height: 26), if (icon != null) icon, - SizedBox(height: 26.0), + const SizedBox(height: 26), Text( title, - style: StreamChatTheme.of(context).textTheme.headlineBold, + style: chatThemeData.textTheme.headlineBold, ), - SizedBox(height: 7.0), - Text( - question, - textAlign: TextAlign.center, - ), - SizedBox(height: 36.0), + const SizedBox(height: 7), + if (question != null) + Text( + question, + textAlign: TextAlign.center, + ), + const SizedBox(height: 36), Container( - color: effect.color.withOpacity(effect.alpha ?? 1), + color: effect.color!.withOpacity(effect.alpha ?? 1), height: 1, ), Row( children: [ - Flexible( - child: Container( - alignment: Alignment.center, - child: TextButton( - onPressed: () { - Navigator.of(context).pop(false); - }, - child: Text( - cancelText, - style: StreamChatTheme.of(context) - .textTheme - .bodyBold - .copyWith( - color: StreamChatTheme.of(context) - .colorTheme - .black - .withOpacity(0.5)), + if (cancelText != null) + Flexible( + child: Container( + alignment: Alignment.center, + child: TextButton( + onPressed: () { + Navigator.of(context).pop(false); + }, + child: Text( + cancelText, + style: chatThemeData.textTheme.bodyBold.copyWith( + color: chatThemeData.colorTheme.textHighEmphasis + .withOpacity(0.5)), + ), ), ), ), - ), Flexible( child: Container( alignment: Alignment.center, @@ -91,13 +90,8 @@ Future showConfirmationDialog( }, child: Text( okText, - style: StreamChatTheme.of(context) - .textTheme - .bodyBold - .copyWith( - color: StreamChatTheme.of(context) - .colorTheme - .accentRed), + style: chatThemeData.textTheme.bodyBold.copyWith( + color: chatThemeData.colorTheme.accentError), ), ), ), @@ -110,71 +104,72 @@ Future showConfirmationDialog( }); } -Future showInfoDialog( +/// Shows info dialog +Future showInfoDialog( BuildContext context, { - String title, - Widget icon, - String details, - String okText, - StreamChatThemeData theme, + required String title, + required String okText, + Widget? icon, + String? details, + StreamChatThemeData? theme, }) { + final chatThemeData = StreamChatTheme.of(context); return showModalBottomSheet( - backgroundColor: theme?.colorTheme?.white ?? - StreamChatTheme.of(context).colorTheme.white, + useRootNavigator: false, + backgroundColor: + theme?.colorTheme.barsBg ?? chatThemeData.colorTheme.barsBg, context: context, - shape: RoundedRectangleBorder( + shape: const RoundedRectangleBorder( borderRadius: BorderRadius.only( - topLeft: Radius.circular(16.0), - topRight: Radius.circular(16.0), + topLeft: Radius.circular(16), + topRight: Radius.circular(16), )), - builder: (context) { - return SafeArea( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - SizedBox( - height: 26.0, - ), - if (icon != null) icon, - SizedBox( - height: 26.0, - ), - Text( - title, - style: theme?.textTheme?.headlineBold ?? - StreamChatTheme.of(context).textTheme.headlineBold, - ), - SizedBox( - height: 7.0, - ), - Text(details), - SizedBox( - height: 36.0, - ), - Container( - color: theme?.colorTheme?.black?.withOpacity(.08) ?? - StreamChatTheme.of(context).colorTheme.black.withOpacity(.08), - height: 1.0, - ), - Center( - child: TextButton( - onPressed: () { - Navigator.of(context).pop(); - }, - child: Text( - okText, - style: TextStyle( - color: theme?.colorTheme?.black?.withOpacity(0.5) ?? - StreamChatTheme.of(context).colorTheme.accentBlue, - fontWeight: FontWeight.w400, - ), + builder: (context) => SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox( + height: 26, + ), + if (icon != null) icon, + const SizedBox( + height: 26, + ), + Text( + title, + style: theme?.textTheme.headlineBold ?? + chatThemeData.textTheme.headlineBold, + ), + const SizedBox( + height: 7, + ), + if (details != null) Text(details), + const SizedBox( + height: 36, + ), + Container( + color: theme?.colorTheme.textHighEmphasis.withOpacity(.08) ?? + chatThemeData.colorTheme.textHighEmphasis.withOpacity(.08), + height: 1, + ), + Center( + child: TextButton( + onPressed: () { + Navigator.of(context).pop(); + }, + child: Text( + okText, + style: TextStyle( + color: theme?.colorTheme.textHighEmphasis.withOpacity(0.5) ?? + chatThemeData.colorTheme.accentPrimary, + fontWeight: FontWeight.w400, ), ), ), - ], - ), - ); - }, + ), + ], + ), + ), ); } @@ -183,7 +178,7 @@ String getRandomPicUrl(User user) => 'https://getstream.io/random_png/?id=${user.id}&name=${user.name}'; /// Get websiteName from [hostName] -String getWebsiteName(String hostName) { +String? getWebsiteName(String hostName) { switch (hostName) { case 'reddit': return 'Reddit'; @@ -234,7 +229,7 @@ String fileSize(dynamic size, [int round = 2]) { * the optional parameter [round] specifies the number * of digits after comma/point (default is 2) */ - final divider = 1024; + const divider = 1024; int _size; try { _size = int.parse(size.toString()); @@ -272,103 +267,78 @@ String fileSize(dynamic size, [int round = 2]) { if (_size < divider * divider * divider * divider * divider && _size % divider == 0) { - num r = _size / divider / divider / divider / divider; + final num r = _size / divider / divider / divider / divider; return '${r.toStringAsFixed(0)} TB'; } if (_size < divider * divider * divider * divider * divider) { - num r = _size / divider / divider / divider / divider; + final num r = _size / divider / divider / divider / divider; return '${r.toStringAsFixed(round)} TB'; } if (_size < divider * divider * divider * divider * divider * divider && _size % divider == 0) { - num r = _size / divider / divider / divider / divider / divider; + final num r = _size / divider / divider / divider / divider / divider; return '${r.toStringAsFixed(0)} PB'; } else { - num r = _size / divider / divider / divider / divider / divider; + final num r = _size / divider / divider / divider / divider / divider; return '${r.toStringAsFixed(round)} PB'; } } /// -StreamSvgIcon getFileTypeImage(String type) { +StreamSvgIcon getFileTypeImage(String? type) { switch (type) { case '7z': return StreamSvgIcon.filetype7z(); - break; case 'csv': return StreamSvgIcon.filetypeCsv(); - break; case 'doc': return StreamSvgIcon.filetypeDoc(); - break; case 'docx': return StreamSvgIcon.filetypeDocx(); - break; case 'html': return StreamSvgIcon.filetypeHtml(); - break; case 'md': return StreamSvgIcon.filetypeMd(); - break; case 'odt': return StreamSvgIcon.filetypeOdt(); - break; case 'pdf': return StreamSvgIcon.filetypePdf(); - break; case 'ppt': return StreamSvgIcon.filetypePpt(); - break; case 'pptx': return StreamSvgIcon.filetypePptx(); - break; case 'rar': return StreamSvgIcon.filetypeRar(); - break; case 'rtf': return StreamSvgIcon.filetypeRtf(); - break; case 'tar': return StreamSvgIcon.filetypeTar(); - break; case 'txt': return StreamSvgIcon.filetypeTxt(); - break; case 'xls': return StreamSvgIcon.filetypeXls(); - break; case 'xlsx': return StreamSvgIcon.filetypeXlsx(); - break; case 'zip': return StreamSvgIcon.filetypeZip(); - break; default: return StreamSvgIcon.filetypeGeneric(); - break; } } +/// Wraps attachment widget with custom shape Widget wrapAttachmentWidget( BuildContext context, Widget attachmentWidget, ShapeBorder attachmentShape, + // ignore: avoid_positional_boolean_parameters bool reverse, - BorderRadius borderRadius, -) { - return ClipRRect( - borderRadius: borderRadius, - child: Material( - clipBehavior: Clip.antiAlias, +) => + Material( + clipBehavior: Clip.hardEdge, shape: attachmentShape, type: MaterialType.transparency, - child: Transform( - transform: Matrix4.rotationY(reverse ? pi : 0), - alignment: Alignment.center, - child: attachmentWidget, - ), - ), - ); -} + child: attachmentWidget, + ); diff --git a/packages/stream_chat_flutter/lib/src/video_service.dart b/packages/stream_chat_flutter/lib/src/video_service.dart index 30d06c55..e914f466 100644 --- a/packages/stream_chat_flutter/lib/src/video_service.dart +++ b/packages/stream_chat_flutter/lib/src/video_service.dart @@ -4,59 +4,66 @@ import 'dart:typed_data'; import 'package:synchronized/synchronized.dart'; import 'package:video_compress/video_compress.dart'; import 'package:video_thumbnail/video_thumbnail.dart'; -import 'package:meta/meta.dart'; +/// class IVideoService { + IVideoService._(); + + /// Singleton instance of [IVideoService] static final IVideoService instance = IVideoService._(); final _lock = Lock(); - IVideoService._(); - /// compress video from [path] /// compress video from [path] return [Future] /// - /// you can choose its quality by [quality], - /// determine whether to delete his source file by [deleteOrigin] - /// optional parameters [startTime] [duration] [includeAudio] [frameRate] + /// you can choose its [quality] and [frameRate] /// /// ## example /// ```dart /// final info = await _flutterVideoCompress.compressVideo( /// file.path, - /// deleteOrigin: true, /// ); /// debugPrint(info.toJson()); /// ``` - Future compressVideo(String path) async { - return _lock.synchronized(() { - return VideoCompress.compressVideo( - path, + Future compressVideo( + String path, { + int frameRate = 30, + VideoQuality quality = VideoQuality.DefaultQuality, + }) async => + _lock.synchronized( + () => VideoCompress.compressVideo( + path, + frameRate: frameRate, + quality: quality, + ), ); - }); - } - /// Generates a thumbnail image data in memory as UInt8List, it can be easily used by Image.memory(...). - /// The video can be a local video file, or an URL repreents iOS or Android native supported video format. - /// Speicify the maximum height or width for the thumbnail or 0 for same resolution as the original video. - /// The lower quality value creates lower quality of the thumbnail image, but it gets ignored for PNG format. - Future generateVideoThumbnail({ - @required String video, + /// Generates a thumbnail image data in memory as UInt8List, + /// it can be easily used by Image.memory(...). + /// The video can be a local video file, or an URL repreents iOS or + /// Android native supported video format. + /// Speicify the maximum height or width for the thumbnail or 0 for + /// same resolution as the original video. + /// The lower quality value creates lower quality of the thumbnail image, + /// but it gets ignored for PNG format. + Future generateVideoThumbnail({ + required String video, ImageFormat imageFormat = ImageFormat.PNG, int maxHeight = 0, int maxWidth = 0, int timeMs = 0, int quality = 10, - }) { - return VideoThumbnail.thumbnailData( - video: video, - imageFormat: imageFormat, - maxHeight: maxHeight, - maxWidth: maxWidth, - timeMs: timeMs, - quality: quality, - ); - } + }) => + VideoThumbnail.thumbnailData( + video: video, + imageFormat: imageFormat, + maxHeight: maxHeight, + maxWidth: maxWidth, + timeMs: timeMs, + quality: quality, + ); } +/// Get instance of [IVideoService] // ignore: non_constant_identifier_names IVideoService get VideoService => IVideoService.instance; diff --git a/packages/stream_chat_flutter/lib/src/video_thumbnail_image.dart b/packages/stream_chat_flutter/lib/src/video_thumbnail_image.dart index 80607b8e..2286bc3d 100644 --- a/packages/stream_chat_flutter/lib/src/video_thumbnail_image.dart +++ b/packages/stream_chat_flutter/lib/src/video_thumbnail_image.dart @@ -2,24 +2,16 @@ import 'dart:typed_data'; import 'package:flutter/material.dart'; import 'package:shimmer/shimmer.dart'; +import 'package:stream_chat_flutter/src/video_service.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; import 'package:video_thumbnail/video_thumbnail.dart'; -import 'stream_svg_icon.dart'; -import 'video_service.dart'; - +/// Widget for creating video thumbnail image class VideoThumbnailImage extends StatefulWidget { - final String video; - final double width; - final double height; - final BoxFit fit; - final ImageFormat format; - final Widget Function(BuildContext, Object) errorBuilder; - final WidgetBuilder placeholderBuilder; - + /// Constructor for creating [VideoThumbnailImage] const VideoThumbnailImage({ - Key key, - @required this.video, + Key? key, + required this.video, this.width, this.height, this.fit, @@ -28,12 +20,34 @@ class VideoThumbnailImage extends StatefulWidget { this.placeholderBuilder, }) : super(key: key); + /// Video path + final String video; + + /// Width of widget + final double? width; + + /// Height of widget + final double? height; + + /// Fit of iamge + final BoxFit? fit; + + /// Image format + final ImageFormat format; + + /// Builds widget on error + final Widget Function(BuildContext, Object?)? errorBuilder; + + /// Builds placeholder + final WidgetBuilder? placeholderBuilder; + @override _VideoThumbnailImageState createState() => _VideoThumbnailImageState(); } class _VideoThumbnailImageState extends State { - Future thumbnailFuture; + late Future thumbnailFuture; + late StreamChatThemeData _streamChatTheme; @override void initState() { @@ -44,6 +58,12 @@ class _VideoThumbnailImageState extends State { super.initState(); } + @override + void didChangeDependencies() { + _streamChatTheme = StreamChatTheme.of(context); + super.didChangeDependencies(); + } + @override void didUpdateWidget(covariant VideoThumbnailImage oldWidget) { if (oldWidget.video != widget.video || oldWidget.format != widget.format) { @@ -56,14 +76,12 @@ class _VideoThumbnailImageState extends State { } @override - Widget build(BuildContext context) { - return FutureBuilder( - future: thumbnailFuture, - builder: (context, snapshot) { - return AnimatedSwitcher( + Widget build(BuildContext context) => FutureBuilder( + future: thumbnailFuture, + builder: (context, snapshot) => AnimatedSwitcher( duration: const Duration(milliseconds: 350), child: Builder( - key: ValueKey>(snapshot), + key: ValueKey>(snapshot), builder: (_) { if (snapshot.hasError) { return widget.errorBuilder?.call(context, snapshot.error) ?? @@ -73,14 +91,11 @@ class _VideoThumbnailImageState extends State { } if (!snapshot.hasData) { return Container( - constraints: BoxConstraints.expand(), + constraints: const BoxConstraints.expand(), child: widget.placeholderBuilder?.call(context) ?? Shimmer.fromColors( - baseColor: StreamChatTheme.of(context) - .colorTheme - .greyGainsboro, - highlightColor: - StreamChatTheme.of(context).colorTheme.whiteSmoke, + baseColor: _streamChatTheme.colorTheme.disabled, + highlightColor: _streamChatTheme.colorTheme.inputBg, child: Image.asset( 'images/placeholder.png', fit: BoxFit.cover, @@ -90,15 +105,13 @@ class _VideoThumbnailImageState extends State { ); } return Image.memory( - snapshot.data, + snapshot.data!, fit: widget.fit, height: widget.height, width: widget.width, ); }, ), - ); - }, - ); - } + ), + ); } diff --git a/packages/stream_chat_flutter/lib/stream_chat_flutter.dart b/packages/stream_chat_flutter/lib/stream_chat_flutter.dart index b8062209..39aabb85 100644 --- a/packages/stream_chat_flutter/lib/stream_chat_flutter.dart +++ b/packages/stream_chat_flutter/lib/stream_chat_flutter.dart @@ -1,43 +1,43 @@ +export 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart'; + +export 'src/attachment/attachment.dart'; export 'src/back_button.dart'; +export 'src/channel_avatar.dart'; export 'src/channel_header.dart'; -export 'src/channel_image.dart'; export 'src/channel_list_header.dart'; export 'src/channel_list_view.dart'; export 'src/channel_name.dart'; export 'src/channel_preview.dart'; +export 'src/connection_status_builder.dart'; export 'src/date_divider.dart'; export 'src/deleted_message.dart'; -export 'src/message_action.dart'; -export 'src/attachment/attachment.dart'; export 'src/full_screen_media.dart'; -export 'src/image_header.dart'; -export 'src/image_footer.dart'; +export 'src/gallery_footer.dart'; +export 'src/gallery_header.dart'; +export 'src/info_tile.dart'; +export 'src/mention_tile.dart'; +export 'src/message_action.dart'; export 'src/message_input.dart'; export 'src/message_list_view.dart'; +export 'src/message_search_item.dart'; +export 'src/message_search_list_view.dart'; export 'src/message_text.dart'; export 'src/message_widget.dart'; +export 'src/option_list_tile.dart'; +export 'src/reaction_icon.dart'; export 'src/reaction_picker.dart'; export 'src/sending_indicator.dart'; +export 'src/stream_chat.dart'; export 'src/stream_chat_theme.dart'; export 'src/stream_neumorphic_button.dart'; export 'src/stream_svg_icon.dart'; export 'src/system_message.dart'; export 'src/thread_header.dart'; export 'src/typing_indicator.dart'; +export 'src/unread_indicator.dart'; export 'src/user_avatar.dart'; export 'src/user_item.dart'; export 'src/user_item.dart'; export 'src/user_list_view.dart'; export 'src/user_list_view.dart'; export 'src/utils.dart'; -export 'src/message_search_item.dart'; -export 'src/message_search_list_view.dart'; -export 'src/unread_indicator.dart'; -export 'src/option_list_tile.dart'; -export 'src/channel_file_display_screen.dart'; -export 'src/channel_media_display_screen.dart'; -export 'src/info_tile.dart'; -export 'src/stream_chat.dart'; -export 'src/connection_status_builder.dart'; -export 'src/mention_tile.dart'; -export 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart'; diff --git a/packages/stream_chat_flutter/lib/svgs/icon_pin.svg b/packages/stream_chat_flutter/lib/svgs/icon_pin.svg new file mode 100644 index 00000000..0f494729 --- /dev/null +++ b/packages/stream_chat_flutter/lib/svgs/icon_pin.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/stream_chat_flutter/pubspec.yaml b/packages/stream_chat_flutter/pubspec.yaml index 7328865d..590ed055 100644 --- a/packages/stream_chat_flutter/pubspec.yaml +++ b/packages/stream_chat_flutter/pubspec.yaml @@ -1,48 +1,49 @@ name: stream_chat_flutter homepage: https://github.com/GetStream/stream-chat-flutter description: Stream Chat official Flutter SDK. Build your own chat experience using Dart and Flutter. -version: 1.5.4 +version: 2.0.0 repository: https://github.com/GetStream/stream-chat-flutter issue_tracker: https://github.com/GetStream/stream-chat-flutter/issues environment: - sdk: ">=2.7.0 <3.0.0" + sdk: '>=2.12.0 <3.0.0' dependencies: + cached_network_image: ^3.0.0 + characters: ^1.1.0 + chewie: ^1.2.0 + collection: ^1.15.0 + dio: ^4.0.0 + ezanimation: ^0.5.0 + file_picker: ^3.0.1 flutter: sdk: flutter - stream_chat_flutter_core: ^1.5.3 - photo_view: ^0.11.0 - rxdart: ^0.25.0 - scrollable_positioned_list: ^0.1.8 - jiffy: ^3.0.1 - flutter_svg: ^0.19.3 - flutter_portal: ^0.3.0 - cached_network_image: ^2.5.0 - shimmer: ^1.1.2 - flutter_markdown: ^0.5.2 - url_launcher: ^5.7.10 - emojis: ^0.9.3 - video_player: ^2.0.0 - chewie: ^1.0.0 - file_picker: ^2.1.5 - image_picker: ^0.6.7+17 - flutter_keyboard_visibility: ^4.0.2 - video_compress: ^2.1.1 - visibility_detector: ^0.1.5 - meta: ^1.2.4 - lottie: ^0.7.0+1 - substring_highlight: ^0.1.2 - flutter_slidable: ^0.5.7 - image_gallery_saver: ^1.6.7 - share_plus: ^1.2.0 - photo_manager: ^1.0.0 - ezanimation: ^0.4.1 - synchronized: ^2.1.0 - characters: ^1.0.0 - dio: ^3.0.10 - path_provider: ^1.6.27 - video_thumbnail: ^0.2.5+1 + flutter_keyboard_visibility: ^5.0.1 + flutter_markdown: ^0.6.1 + flutter_portal: ^0.4.0 + flutter_slidable: ^0.6.0 + flutter_svg: ^0.22.0 + http_parser: ^4.0.0 + image_gallery_saver: ^1.6.9 + image_picker: ^0.8.2 + jiffy: ^4.1.0 + lottie: ^1.0.1 + meta: ^1.3.0 + path_provider: ^2.0.1 + photo_manager: ^1.1.6 + photo_view: ^0.11.1 + rxdart: ^0.27.0 + scrollable_positioned_list: ^0.2.0-nullsafety.0 + share_plus: ^2.0.3 + shimmer: ^2.0.0 + stream_chat_flutter_core: ^2.0.0 + substring_highlight: ^1.0.26 + synchronized: ^3.0.0 + url_launcher: ^6.0.3 + video_compress: ^3.0.0 + video_player: ^2.1.0 + video_thumbnail: ^0.3.3 + visibility_detector: ^0.2.0 flutter: assets: @@ -50,9 +51,12 @@ flutter: - svgs/ - lib/svgs/ - animations/ + uses-material-design: true dev_dependencies: flutter_test: sdk: flutter - mockito: ^4.1.3 - pedantic: ^1.9.2 \ No newline at end of file + golden_toolkit: ^0.9.0 + mocktail: ^0.1.2 + pedantic: ^1.11.0 + diff --git a/packages/stream_chat_flutter/test/flutter_test_config.dart b/packages/stream_chat_flutter/test/flutter_test_config.dart new file mode 100644 index 00000000..c09db700 --- /dev/null +++ b/packages/stream_chat_flutter/test/flutter_test_config.dart @@ -0,0 +1,9 @@ +import 'dart:async'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:golden_toolkit/golden_toolkit.dart'; + +Future testExecutable(FutureOr Function() testMain) async { + await loadAppFonts(); + return testMain(); +} diff --git a/packages/stream_chat_flutter/test/src/attachment_actions_modal_test.dart b/packages/stream_chat_flutter/test/src/attachment_actions_modal_test.dart new file mode 100644 index 00000000..3f210742 --- /dev/null +++ b/packages/stream_chat_flutter/test/src/attachment_actions_modal_test.dart @@ -0,0 +1,504 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:stream_chat_flutter/src/attachment_actions_modal.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; + +import 'mocks.dart'; + +class MockAttachmentDownloader extends Mock { + ProgressCallback? progressCallback; + Completer completer = Completer(); + + Future call( + Attachment attachment, { + ProgressCallback? progressCallback, + }) { + this.progressCallback = progressCallback; + return completer.future; + } +} + +void main() { + setUpAll(() { + registerFallbackValue( + MaterialPageRoute(builder: (context) => const SizedBox())); + registerFallbackValue(Message()); + }); + + testWidgets( + 'it should show all the actions', + (WidgetTester tester) async { + final client = MockClient(); + final clientState = MockClientState(); + + when(() => client.state).thenReturn(clientState); + when(() => clientState.user).thenReturn(OwnUser(id: 'user-id')); + + final themeData = ThemeData(); + final streamTheme = StreamChatThemeData.fromTheme(themeData); + await tester.pumpWidget( + MaterialApp( + theme: themeData, + home: StreamChat( + streamChatThemeData: streamTheme, + client: client, + child: AttachmentActionsModal( + message: Message( + text: 'test', + user: User( + id: 'user-id', + ), + attachments: [ + Attachment( + type: 'image', + title: 'text.jpg', + ), + ], + ), + currentIndex: 0, + ), + ), + ), + ); + expect(find.text('Reply'), findsOneWidget); + expect(find.text('Show in Chat'), findsOneWidget); + expect(find.text('Save Image'), findsOneWidget); + expect(find.text('Delete'), findsOneWidget); + }, + ); + + testWidgets( + 'it should hide delete if it\'s not my message', + (WidgetTester tester) async { + final client = MockClient(); + final clientState = MockClientState(); + + when(() => client.state).thenReturn(clientState); + when(() => clientState.user).thenReturn(OwnUser(id: 'user-id2')); + + final themeData = ThemeData(); + final streamTheme = StreamChatThemeData.fromTheme(themeData); + await tester.pumpWidget( + MaterialApp( + theme: themeData, + home: StreamChat( + streamChatThemeData: streamTheme, + client: client, + child: AttachmentActionsModal( + message: Message( + text: 'test', + user: User( + id: 'user-id', + ), + attachments: [ + Attachment( + type: 'image', + title: 'text.jpg', + ), + ], + ), + currentIndex: 0, + ), + ), + ), + ); + expect(find.text('Reply'), findsOneWidget); + expect(find.text('Show in Chat'), findsOneWidget); + expect(find.text('Save Image'), findsOneWidget); + expect(find.text('Delete'), findsNothing); + }, + ); + + testWidgets( + 'it should show save video for videos', + (WidgetTester tester) async { + final client = MockClient(); + final clientState = MockClientState(); + + when(() => client.state).thenReturn(clientState); + when(() => clientState.user).thenReturn(OwnUser(id: 'user-id')); + + final themeData = ThemeData(); + final streamTheme = StreamChatThemeData.fromTheme(themeData); + await tester.pumpWidget( + MaterialApp( + theme: themeData, + home: StreamChat( + streamChatThemeData: streamTheme, + client: client, + child: SizedBox( + child: AttachmentActionsModal( + message: Message( + text: 'test', + user: User( + id: 'user-id', + ), + attachments: [ + Attachment( + type: 'video', + title: 'video.mp4', + ), + ], + ), + currentIndex: 0, + ), + ), + ), + ), + ); + expect(find.text('Save Video'), findsOneWidget); + }, + ); + + testWidgets( + 'tapping on reply should pop', + (WidgetTester tester) async { + final client = MockClient(); + final clientState = MockClientState(); + + when(() => client.state).thenReturn(clientState); + when(() => clientState.user).thenReturn(OwnUser(id: 'user-id')); + + final themeData = ThemeData(); + final streamTheme = StreamChatThemeData.fromTheme(themeData); + + final mockObserver = MockNavigatorObserver(); + + final message = Message( + text: 'test', + user: User( + id: 'user-id', + ), + attachments: [ + Attachment( + type: 'image', + title: 'image.jpg', + ), + ], + ); + await tester.pumpWidget( + MaterialApp( + theme: themeData, + navigatorObservers: [mockObserver], + home: StreamChat( + streamChatThemeData: streamTheme, + client: client, + child: SizedBox( + child: AttachmentActionsModal( + message: message, + currentIndex: 0, + ), + ), + ), + ), + ); + await tester.tap(find.text('Reply')); + verify(() => mockObserver.didPop(any(), any())); + }, + ); + + testWidgets( + 'tapping on show in chat should call onShowMessage', + (WidgetTester tester) async { + final client = MockClient(); + final clientState = MockClientState(); + + when(() => client.state).thenReturn(clientState); + when(() => clientState.user).thenReturn(OwnUser(id: 'user-id')); + + final themeData = ThemeData(); + final streamTheme = StreamChatThemeData.fromTheme(themeData); + final onShowMessage = MockVoidCallback(); + + await tester.pumpWidget( + MaterialApp( + theme: themeData, + home: StreamChat( + streamChatThemeData: streamTheme, + client: client, + child: SizedBox( + child: AttachmentActionsModal( + onShowMessage: onShowMessage, + message: Message( + text: 'test', + user: User( + id: 'user-id', + ), + attachments: [ + Attachment( + type: 'image', + title: 'image.jpg', + ), + ]), + currentIndex: 0, + ), + ), + ), + ), + ); + await tester.tap(find.text('Show in Chat')); + verify(onShowMessage.call).called(1); + }, + ); + + testWidgets( + 'tapping on delete in chat should remove the attachment', + (WidgetTester tester) async { + final client = MockClient(); + final clientState = MockClientState(); + final mockChannel = MockChannel(); + + when(() => mockChannel.updateMessage(any())) + .thenAnswer((_) async => UpdateMessageResponse()); + when(() => client.state).thenReturn(clientState); + when(() => clientState.user).thenReturn(OwnUser(id: 'user-id')); + + final message = Message( + text: 'test', + user: User( + id: 'user-id', + ), + attachments: [ + Attachment( + type: 'image', + title: 'image.jpg', + ), + Attachment( + type: 'image', + title: 'image.jpg', + ), + ], + ); + + await tester.pumpWidget( + MaterialApp( + builder: (context, child) => StreamChat( + client: client, + child: child, + ), + home: StreamChannel( + showLoading: false, + channel: mockChannel, + child: AttachmentActionsModal( + message: message, + currentIndex: 0, + ), + ), + ), + ); + await tester.tap(find.text('Delete')); + verify(() => mockChannel.updateMessage(message.copyWith( + attachments: [ + message.attachments[1], + ], + ))).called(1); + }, + ); + + testWidgets( + 'tapping on delete in chat should remove the attachment if there is text', + (WidgetTester tester) async { + final client = MockClient(); + final clientState = MockClientState(); + final mockChannel = MockChannel(); + + when(() => mockChannel.updateMessage(any())) + .thenAnswer((_) async => UpdateMessageResponse()); + when(() => client.state).thenReturn(clientState); + when(() => clientState.user).thenReturn(OwnUser(id: 'user-id')); + + final message = Message( + text: 'test', + user: User( + id: 'user-id', + ), + attachments: [ + Attachment( + type: 'image', + title: 'image.jpg', + ), + ], + ); + + await tester.pumpWidget( + MaterialApp( + builder: (context, child) => StreamChat( + client: client, + child: child, + ), + home: StreamChannel( + showLoading: false, + channel: mockChannel, + child: AttachmentActionsModal( + message: message, + currentIndex: 0, + ), + ), + ), + ); + await tester.tap(find.text('Delete')); + verify(() => mockChannel.updateMessage(message.copyWith( + attachments: [], + ))).called(1); + }, + ); + + testWidgets( + // ignore: lines_longer_than_80_chars + 'tapping on delete in chat should remove the message if that\'s the only attachment and there is no text', + (WidgetTester tester) async { + final client = MockClient(); + final clientState = MockClientState(); + final mockChannel = MockChannel(); + + when(() => mockChannel.deleteMessage(any())) + .thenAnswer((_) async => EmptyResponse()); + when(() => client.state).thenReturn(clientState); + when(() => clientState.user).thenReturn(OwnUser(id: 'user-id')); + + final message = Message( + user: User( + id: 'user-id', + ), + attachments: [ + Attachment( + type: 'image', + title: 'image.jpg', + ), + ], + ); + + await tester.pumpWidget( + MaterialApp( + builder: (context, child) => StreamChat( + client: client, + child: child, + ), + home: StreamChannel( + showLoading: false, + channel: mockChannel, + child: AttachmentActionsModal( + message: message, + currentIndex: 0, + ), + ), + ), + ); + await tester.tap(find.text('Delete')); + verify(() => mockChannel.deleteMessage(message)).called(1); + }, + ); + + testWidgets( + 'tapping on save in chat should call image downloader', + (WidgetTester tester) async { + final client = MockClient(); + final clientState = MockClientState(); + + when(() => client.state).thenReturn(clientState); + when(() => clientState.user).thenReturn(OwnUser(id: 'user-id')); + + final imageDownloader = MockAttachmentDownloader(); + + await tester.pumpWidget( + MaterialApp( + builder: (context, child) => StreamChat( + client: client, + child: child, + ), + home: SizedBox( + child: AttachmentActionsModal( + imageDownloader: imageDownloader, + message: Message( + text: 'test', + user: User( + id: 'user-id', + ), + attachments: [ + Attachment( + type: 'image', + title: 'image.jpg', + ), + ]), + currentIndex: 0, + ), + ), + ), + ); + + await tester.tap(find.text('Save Image')); + + imageDownloader.progressCallback!(0, 100); + await tester.pump(); + expect(find.text('0%'), findsOneWidget); + + imageDownloader.progressCallback!(50, 100); + await tester.pump(); + expect(find.text('50%'), findsOneWidget); + + imageDownloader.progressCallback!(100, 100); + imageDownloader.completer.complete('path'); + await tester.pump(); + expect(find.byKey(const Key('completedIcon')), findsOneWidget); + await tester.pumpAndSettle(const Duration(milliseconds: 500)); + }, + ); + + testWidgets( + 'tapping on save in chat should call file downloader', + (WidgetTester tester) async { + final client = MockClient(); + final clientState = MockClientState(); + + when(() => client.state).thenReturn(clientState); + when(() => clientState.user).thenReturn(OwnUser(id: 'user-id')); + + final fileDownloader = MockAttachmentDownloader(); + + await tester.pumpWidget( + MaterialApp( + builder: (context, child) => StreamChat( + client: client, + child: child, + ), + home: SizedBox( + child: AttachmentActionsModal( + fileDownloader: fileDownloader, + message: Message( + text: 'test', + user: User( + id: 'user-id', + ), + attachments: [ + Attachment( + type: 'video', + title: 'video.mp4', + ), + ]), + currentIndex: 0, + ), + ), + ), + ); + + await tester.tap(find.text('Save Video')); + + fileDownloader.progressCallback!(0, 100); + await tester.pump(); + expect(find.text('0%'), findsOneWidget); + + fileDownloader.progressCallback!(50, 100); + await tester.pump(); + expect(find.text('50%'), findsOneWidget); + + fileDownloader.progressCallback!(100, 100); + fileDownloader.completer.complete('path'); + await tester.pump(); + expect(find.byKey(const Key('completedIcon')), findsOneWidget); + await tester.pumpAndSettle(const Duration(milliseconds: 500)); + }, + ); +} diff --git a/packages/stream_chat_flutter/test/src/attachment_widgets_test.dart b/packages/stream_chat_flutter/test/src/attachment_widgets_test.dart new file mode 100644 index 00000000..ed4ce267 --- /dev/null +++ b/packages/stream_chat_flutter/test/src/attachment_widgets_test.dart @@ -0,0 +1,50 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; + +import 'mocks.dart'; + +void main() { + testWidgets( + 'it should show file details', + (WidgetTester tester) async { + final channel = MockChannel(); + final channelState = MockChannelState(); + + when(() => channel.state).thenReturn(channelState); + + final themeData = ThemeData(); + final streamTheme = StreamChatThemeData.fromTheme(themeData); + + await tester.pumpWidget( + MaterialApp( + home: StreamChatTheme( + data: streamTheme, + child: StreamChannel( + channel: channel, + child: SizedBox( + child: FileAttachment( + size: const Size( + 300, + 300, + ), + message: Message(), + attachment: Attachment( + type: 'file', + title: 'example.pdf', + extraData: const { + 'mime_type': 'pdf', + }, + ), + ), + ), + ), + ), + ), + ); + + expect(find.text('example.pdf'), findsOneWidget); + }, + ); +} diff --git a/packages/stream_chat_flutter/test/src/back_button_test.dart b/packages/stream_chat_flutter/test/src/back_button_test.dart new file mode 100644 index 00000000..efb889ac --- /dev/null +++ b/packages/stream_chat_flutter/test/src/back_button_test.dart @@ -0,0 +1,143 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:stream_chat_flutter/src/back_button.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; + +import 'mocks.dart'; + +void main() { + testWidgets( + 'BackButton control test', + (WidgetTester tester) async { + final theme = ThemeData(); + await tester.pumpWidget( + MaterialApp( + home: const Material(child: Text('Home')), + routes: { + '/next': (BuildContext context) => Material( + child: Center( + child: StreamChatTheme( + data: StreamChatThemeData.fromTheme(theme), + child: const StreamBackButton(), + ), + ), + ), + }, + ), + ); + + // ignore: unawaited_futures + tester.state(find.byType(Navigator)).pushNamed('/next'); + + await tester.pumpAndSettle(); + + await tester.tap(find.byType(StreamBackButton)); + + await tester.pumpAndSettle(); + + expect(find.text('Home'), findsOneWidget); + }, + ); + + testWidgets( + 'it should not throw errors if cannot pop', + (WidgetTester tester) async { + final theme = ThemeData(); + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Center( + child: StreamChatTheme( + data: StreamChatThemeData.fromTheme(theme), + child: const StreamBackButton(), + ), + ), + ), + ), + ); + + await tester.pumpAndSettle(); + + await tester.tap(find.byType(StreamBackButton)); + + await tester.pumpAndSettle(); + + expect(find.byType(StreamBackButton), findsOneWidget); + }, + ); + + testWidgets( + 'BackButton onPressed overrides default pop behavior', + (WidgetTester tester) async { + final theme = ThemeData(); + var customCallbackWasCalled = false; + await tester.pumpWidget( + MaterialApp( + home: const Material(child: Text('Home')), + routes: { + '/next': (BuildContext context) => Material( + child: Center( + child: StreamChatTheme( + data: StreamChatThemeData.fromTheme(theme), + child: StreamBackButton( + onPressed: () => customCallbackWasCalled = true, + ), + ), + ), + ), + }, + ), + ); + + // ignore: unawaited_futures + tester.state(find.byType(Navigator)).pushNamed('/next'); + + await tester.pumpAndSettle(); + + expect(find.text('Home'), findsNothing); // Start off on the second page. + expect( + customCallbackWasCalled, + false, + ); // customCallbackWasCalled should still be false. + await tester.tap(find.byType(StreamBackButton)); + + await tester.pumpAndSettle(); + + // We're still on the second page. + expect(find.text('Home'), findsNothing); + // But the custom callback is called. + expect(customCallbackWasCalled, true); + }, + ); + + testWidgets( + 'it should show unread count', + (WidgetTester tester) async { + final client = MockClient(); + final clientState = MockClientState(); + + when(() => client.state).thenReturn(clientState); + when(() => clientState.totalUnreadCount).thenAnswer((_) => 0); + when(() => clientState.totalUnreadCountStream) + .thenAnswer((_) => Stream.value(0)); + + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Center( + child: StreamChat( + client: client, + child: const StreamBackButton( + showUnreads: true, + ), + ), + ), + ), + ), + ); + + expect(find.byType(UnreadIndicator), findsOneWidget); + }, + ); +} diff --git a/packages/stream_chat_flutter/test/src/channel_header_test.dart b/packages/stream_chat_flutter/test/src/channel_header_test.dart new file mode 100644 index 00000000..211a7132 --- /dev/null +++ b/packages/stream_chat_flutter/test/src/channel_header_test.dart @@ -0,0 +1,426 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:stream_chat_flutter/src/channel_info.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; + +import 'mocks.dart'; + +void main() { + testWidgets( + 'it should show basic channel information', + (WidgetTester tester) async { + final client = MockClient(); + final clientState = MockClientState(); + final channel = MockChannel(); + final channelState = MockChannelState(); + final user = OwnUser(id: 'user-id'); + final lastMessageAt = DateTime.parse('2020-06-22 12:00:00'); + + when(() => client.state).thenReturn(clientState); + when(() => clientState.user).thenReturn(user); + when(() => clientState.userStream).thenAnswer((_) => Stream.value(user)); + when(() => channel.lastMessageAt).thenReturn(lastMessageAt); + when(() => channel.state).thenReturn(channelState); + when(() => channel.client).thenReturn(client); + when(() => channel.isMuted).thenReturn(false); + when(() => channel.isMutedStream).thenAnswer((i) => Stream.value(false)); + when(() => channel.extraDataStream).thenAnswer((i) => Stream.value({ + 'name': 'test', + })); + when(() => channel.extraData).thenReturn({ + 'name': 'test', + }); + when(() => channelState.unreadCount).thenReturn(1); + when(() => client.wsConnectionStatusStream) + .thenAnswer((_) => Stream.value(ConnectionStatus.connected)); + when(() => channelState.unreadCountStream) + .thenAnswer((i) => Stream.value(1)); + when(() => clientState.totalUnreadCount).thenAnswer((i) => 1); + when(() => clientState.totalUnreadCountStream) + .thenAnswer((i) => Stream.value(1)); + when(() => channelState.membersStream).thenAnswer((i) => Stream.value([ + Member( + userId: 'user-id', + user: User(id: 'user-id'), + ) + ])); + when(() => channelState.members).thenReturn([ + Member( + userId: 'user-id', + user: User(id: 'user-id'), + ), + ]); + + await tester.pumpWidget(MaterialApp( + home: StreamChat( + client: client, + child: StreamChannel( + channel: channel, + child: const Scaffold( + body: ChannelHeader(), + ), + ), + ), + )); + + expect(find.text('test'), findsOneWidget); + expect(find.byType(ChannelAvatar), findsOneWidget); + expect(find.byType(StreamBackButton), findsOneWidget); + expect(find.byType(ChannelInfo), findsOneWidget); + }, + ); + + testWidgets( + 'it should show the InfoTile message if disconnected', + (WidgetTester tester) async { + final client = MockClient(); + final clientState = MockClientState(); + final channel = MockChannel(); + final channelState = MockChannelState(); + final user = OwnUser(id: 'user-id'); + final lastMessageAt = DateTime.parse('2020-06-22 12:00:00'); + + when(() => client.state).thenReturn(clientState); + when(() => clientState.user).thenReturn(user); + when(() => clientState.userStream).thenAnswer((_) => Stream.value(user)); + when(() => channel.lastMessageAt).thenReturn(lastMessageAt); + when(() => channel.state).thenReturn(channelState); + when(() => channel.client).thenReturn(client); + when(() => channel.isMuted).thenReturn(false); + when(() => channel.isMutedStream).thenAnswer((i) => Stream.value(false)); + when(() => channel.extraDataStream).thenAnswer((i) => Stream.value({ + 'name': 'test', + })); + when(() => channel.extraData).thenReturn({ + 'name': 'test', + }); + when(() => channelState.unreadCount).thenReturn(1); + when(() => channelState.unreadCountStream) + .thenAnswer((i) => Stream.value(1)); + when(() => channelState.membersStream).thenAnswer((i) => Stream.value([ + Member( + userId: 'user-id', + user: User(id: 'user-id'), + ) + ])); + when(() => channelState.members).thenReturn([ + Member( + userId: 'user-id', + user: User(id: 'user-id'), + ), + ]); + when(() => client.wsConnectionStatusStream) + .thenAnswer((_) => Stream.value(ConnectionStatus.disconnected)); + when(() => client.wsConnectionStatus) + .thenReturn(ConnectionStatus.disconnected); + when(() => clientState.totalUnreadCount).thenAnswer((i) => 1); + when(() => clientState.totalUnreadCountStream) + .thenAnswer((i) => Stream.value(1)); + + await tester.pumpWidget(MaterialApp( + home: StreamChat( + client: client, + child: StreamChannel( + channel: channel, + child: const Scaffold( + body: ChannelHeader( + showConnectionStateTile: true, + ), + ), + ), + ), + )); + + expect(tester.widget(find.byType(InfoTile)).showMessage, true); + expect(tester.widget(find.byType(InfoTile)).message, + 'Disconnected'); + }, + ); + + testWidgets( + 'it should show the InfoTile message if connecting', + (WidgetTester tester) async { + final client = MockClient(); + final clientState = MockClientState(); + final channel = MockChannel(); + final channelState = MockChannelState(); + final user = OwnUser(id: 'user-id'); + final lastMessageAt = DateTime.parse('2020-06-22 12:00:00'); + + when(() => client.state).thenReturn(clientState); + when(() => clientState.user).thenReturn(user); + when(() => clientState.userStream).thenAnswer((_) => Stream.value(user)); + when(() => channel.lastMessageAt).thenReturn(lastMessageAt); + when(() => channel.state).thenReturn(channelState); + when(() => channel.client).thenReturn(client); + when(() => channel.isMuted).thenReturn(false); + when(() => channel.isMutedStream).thenAnswer((i) => Stream.value(false)); + when(() => channel.extraDataStream).thenAnswer((i) => Stream.value({ + 'name': 'test', + })); + when(() => channel.extraData).thenReturn({ + 'name': 'test', + }); + when(() => channelState.unreadCount).thenReturn(1); + when(() => channelState.unreadCountStream) + .thenAnswer((i) => Stream.value(1)); + when(() => channelState.membersStream).thenAnswer((i) => Stream.value([ + Member( + userId: 'user-id', + user: User(id: 'user-id'), + ) + ])); + when(() => channelState.members).thenReturn([ + Member( + userId: 'user-id', + user: User(id: 'user-id'), + ), + ]); + when(() => client.wsConnectionStatusStream) + .thenAnswer((_) => Stream.value(ConnectionStatus.connecting)); + when(() => clientState.totalUnreadCount).thenAnswer((i) => 1); + when(() => clientState.totalUnreadCountStream) + .thenAnswer((i) => Stream.value(1)); + + await tester.pumpWidget(MaterialApp( + home: StreamChat( + client: client, + child: StreamChannel( + channel: channel, + showLoading: false, + child: const Scaffold( + body: ChannelHeader( + showConnectionStateTile: true, + ), + ), + ), + ), + )); + + await tester.pump(); + + expect(tester.widget(find.byType(InfoTile)).showMessage, true); + expect(tester.widget(find.byType(InfoTile)).message, + 'Reconnecting...'); + }, + ); + + testWidgets( + 'it should apply passed properties', + (WidgetTester tester) async { + final client = MockClient(); + final clientState = MockClientState(); + final channel = MockChannel(); + final channelState = MockChannelState(); + final user = OwnUser(id: 'user-id'); + final lastMessageAt = DateTime.parse('2020-06-22 12:00:00'); + + when(() => client.state).thenReturn(clientState); + when(() => clientState.user).thenReturn(user); + when(() => clientState.userStream).thenAnswer((_) => Stream.value(user)); + when(() => channel.lastMessageAt).thenReturn(lastMessageAt); + when(() => channel.state).thenReturn(channelState); + when(() => channel.client).thenReturn(client); + when(() => channel.isMuted).thenReturn(false); + when(() => channel.isMutedStream).thenAnswer((i) => Stream.value(false)); + when(() => channel.extraDataStream).thenAnswer((i) => Stream.value({ + 'name': 'test', + })); + when(() => channel.extraData).thenReturn({ + 'name': 'test', + }); + when(() => channelState.unreadCount).thenReturn(1); + when(() => channelState.unreadCountStream) + .thenAnswer((i) => Stream.value(1)); + when(() => channelState.membersStream).thenAnswer((i) => Stream.value([ + Member( + userId: 'user-id', + user: User(id: 'user-id'), + ) + ])); + when(() => channelState.members).thenReturn([ + Member( + userId: 'user-id', + user: User(id: 'user-id'), + ), + ]); + when(() => client.wsConnectionStatusStream) + .thenAnswer((_) => Stream.value(ConnectionStatus.connecting)); + when(() => clientState.totalUnreadCountStream) + .thenAnswer((i) => Stream.value(1)); + + await tester.pumpWidget(MaterialApp( + home: StreamChat( + client: client, + child: StreamChannel( + channel: channel, + child: const Scaffold( + body: ChannelHeader( + leading: Text('leading'), + subtitle: Text('subtitle'), + actions: [ + Text('action'), + ], + title: Text('title'), + ), + ), + ), + ), + )); + + expect(find.text('test'), findsNothing); + expect(find.byType(StreamBackButton), findsNothing); + expect(find.byType(ChannelAvatar), findsNothing); + expect(find.byType(ChannelInfo), findsNothing); + expect(find.text('leading'), findsOneWidget); + expect(find.text('title'), findsOneWidget); + expect(find.text('subtitle'), findsOneWidget); + expect(find.text('action'), findsOneWidget); + }, + ); + + testWidgets( + 'showBackButton: false should hide the StreamBackButton and ' + 'showTypingIndicator: false should hide the typing indicator and ' + 'showConnectionStateTile: false should be passed to the infotile', + (WidgetTester tester) async { + final client = MockClient(); + final clientState = MockClientState(); + final channel = MockChannel(); + final channelState = MockChannelState(); + final user = OwnUser(id: 'user-id'); + final lastMessageAt = DateTime.parse('2020-06-22 12:00:00'); + + when(() => client.state).thenReturn(clientState); + when(() => clientState.user).thenReturn(user); + when(() => clientState.userStream).thenAnswer((_) => Stream.value(user)); + when(() => channel.lastMessageAt).thenReturn(lastMessageAt); + when(() => channel.state).thenReturn(channelState); + when(() => channel.client).thenReturn(client); + when(() => channel.isMuted).thenReturn(false); + when(() => channel.isMutedStream).thenAnswer((i) => Stream.value(false)); + when(() => channel.extraDataStream).thenAnswer((i) => Stream.value({ + 'name': 'test', + })); + when(() => channel.extraData).thenReturn({ + 'name': 'test', + }); + when(() => channelState.unreadCount).thenReturn(1); + when(() => channelState.unreadCountStream) + .thenAnswer((i) => Stream.value(1)); + when(() => channelState.membersStream).thenAnswer((i) => Stream.value([ + Member( + userId: 'user-id', + user: User(id: 'user-id'), + ) + ])); + when(() => channelState.members).thenReturn([ + Member( + userId: 'user-id', + user: User(id: 'user-id'), + ), + ]); + when(() => client.wsConnectionStatusStream) + .thenAnswer((_) => Stream.value(ConnectionStatus.disconnected)); + + await tester.pumpWidget(MaterialApp( + home: StreamChat( + client: client, + child: StreamChannel( + channel: channel, + child: const Scaffold( + body: ChannelHeader( + showTypingIndicator: false, + showBackButton: false, + ), + ), + ), + ), + )); + + expect(find.byType(StreamBackButton), findsNothing); + expect( + tester + .widget(find.byType(ChannelInfo)) + .showTypingIndicator, + false); + expect(tester.widget(find.byType(InfoTile)).showMessage, false); + }, + ); + + testWidgets( + 'should apply passed callbacks', + (WidgetTester tester) async { + final client = MockClient(); + final clientState = MockClientState(); + final channel = MockChannel(); + final channelState = MockChannelState(); + final user = OwnUser(id: 'user-id'); + final lastMessageAt = DateTime.parse('2020-06-22 12:00:00'); + + when(() => client.state).thenReturn(clientState); + when(() => clientState.user).thenReturn(user); + when(() => clientState.userStream).thenAnswer((_) => Stream.value(user)); + when(() => channel.lastMessageAt).thenReturn(lastMessageAt); + when(() => channel.state).thenReturn(channelState); + when(() => channel.client).thenReturn(client); + when(() => channel.isMuted).thenReturn(false); + when(() => channel.isMutedStream).thenAnswer((i) => Stream.value(false)); + when(() => channel.extraDataStream).thenAnswer((i) => Stream.value({ + 'name': 'test', + })); + when(() => channel.extraData).thenReturn({ + 'name': 'test', + }); + when(() => channelState.unreadCount).thenReturn(1); + when(() => channelState.unreadCountStream) + .thenAnswer((i) => Stream.value(1)); + when(() => channelState.membersStream).thenAnswer((i) => Stream.value([ + Member( + userId: 'user-id', + user: User(id: 'user-id'), + ) + ])); + when(() => channelState.members).thenReturn([ + Member( + userId: 'user-id', + user: User(id: 'user-id'), + ), + ]); + when(() => client.wsConnectionStatusStream) + .thenAnswer((_) => Stream.value(ConnectionStatus.connecting)); + when(() => clientState.totalUnreadCount).thenAnswer((i) => 1); + when(() => clientState.totalUnreadCountStream) + .thenAnswer((i) => Stream.value(1)); + + var backPressed = false; + var imageTapped = false; + var titleTapped = false; + + await tester.pumpWidget(MaterialApp( + home: StreamChat( + client: client, + child: StreamChannel( + channel: channel, + child: Scaffold( + body: ChannelHeader( + onBackPressed: () => backPressed = true, + onImageTap: () => imageTapped = true, + onTitleTap: () => titleTapped = true, + ), + ), + ), + ), + )); + + await tester.tap(find.byType(StreamBackButton)); + await tester.tap(find.byType(ChannelAvatar)); + await tester.tap(find.byType(ChannelName)); + + expect(backPressed, true); + expect(imageTapped, true); + expect(titleTapped, true); + }, + ); +} diff --git a/packages/stream_chat_flutter/test/src/channel_image_test.dart b/packages/stream_chat_flutter/test/src/channel_image_test.dart new file mode 100644 index 00000000..7c620774 --- /dev/null +++ b/packages/stream_chat_flutter/test/src/channel_image_test.dart @@ -0,0 +1,239 @@ +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:stream_chat_flutter/src/group_avatar.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; + +import 'mocks.dart'; + +void main() { + testWidgets( + 'it should show the image in channel.extraData', + (tester) async { + final client = MockClient(); + final clientState = MockClientState(); + final channel = MockChannel(); + final channelState = MockChannelState(); + + when(() => client.state).thenReturn(clientState); + when(() => clientState.user).thenReturn(OwnUser(id: 'user-id')); + when(() => channel.state).thenReturn(channelState); + when(() => channel.client).thenReturn(client); + when(() => channel.extraDataStream).thenAnswer((i) => Stream.value({ + 'name': 'test', + 'image': 'imagetest', + })); + when(() => channel.extraData).thenReturn({ + 'name': 'test', + 'image': 'imagetest', + }); + + await tester.pumpWidget(MaterialApp( + home: StreamChat( + client: client, + child: StreamChannel( + channel: channel, + child: const Scaffold( + body: ChannelAvatar(), + ), + ), + ), + )); + + final image = + tester.widget(find.byType(CachedNetworkImage)); + expect(image.imageUrl, 'imagetest'); + }, + ); + + testWidgets( + 'it should show the the other member image', + (tester) async { + final client = MockClient(); + final clientState = MockClientState(); + final channel = MockChannel(); + final channelState = MockChannelState(); + + when(() => client.state).thenReturn(clientState); + when(() => clientState.user).thenReturn(OwnUser(id: 'user-id')); + when(() => channel.state).thenReturn(channelState); + when(() => channel.client).thenReturn(client); + when(() => channel.extraDataStream).thenAnswer((i) => Stream.value({ + 'name': 'test', + })); + when(() => channel.extraData).thenReturn({ + 'name': 'test', + }); + when(() => channelState.membersStream).thenAnswer((i) => Stream.value([ + Member( + userId: 'user-id', + user: User(id: 'user-id'), + ), + Member( + userId: 'user-id2', + user: User( + id: 'user-id2', + extraData: const { + 'image': 'testimage', + }, + ), + ) + ])); + when(() => channelState.members).thenReturn([ + Member( + userId: 'user-id2', + user: User( + id: 'user-id2', + extraData: const { + 'image': 'testimage', + }, + ), + ), + Member( + userId: 'user-id', + user: User(id: 'user-id'), + ) + ]); + when(() => clientState.usersStream).thenAnswer((i) => Stream.value({ + 'user-id2': User( + id: 'user-id2', + extraData: const { + 'image': 'testimage', + }, + ), + })); + when(() => channel.extraData).thenReturn({ + 'name': 'test', + }); + + await tester.pumpWidget(MaterialApp( + home: StreamChat( + client: client, + child: StreamChannel( + channel: channel, + child: const Scaffold( + body: ChannelAvatar(), + ), + ), + ), + )); + + final image = + tester.widget(find.byType(CachedNetworkImage)); + expect(image.imageUrl, 'testimage'); + }, + ); + + testWidgets( + 'it should use a groupimage if more than 2 members', + (tester) async { + final client = MockClient(); + final clientState = MockClientState(); + final channel = MockChannel(); + final channelState = MockChannelState(); + final currentUser = OwnUser(id: 'user-id'); + + when(() => client.state).thenReturn(clientState); + when(() => clientState.user).thenReturn(currentUser); + when(() => channel.state).thenReturn(channelState); + when(() => channel.client).thenReturn(client); + when(() => channel.extraDataStream).thenAnswer((i) => Stream.value({ + 'name': 'test', + })); + when(() => channel.extraData).thenReturn({ + 'name': 'test', + }); + final members = [ + Member( + userId: 'user-id', + user: User( + id: 'user-id', + extraData: const { + 'image': 'testimage1', + }, + ), + ), + Member( + userId: 'user-id2', + user: User( + id: 'user-id2', + extraData: const { + 'image': 'testimage2', + }, + ), + ), + Member( + userId: 'user-id3', + user: User( + id: 'user-id3', + extraData: const { + 'image': 'testimage3', + }, + ), + ), + ]; + when(() => channelState.members).thenReturn(members); + when(() => channelState.membersStream) + .thenAnswer((_) => Stream.value(members)); + + await tester.pumpWidget(MaterialApp( + home: StreamChat( + client: client, + child: StreamChannel( + channel: channel, + child: const Scaffold( + body: ChannelAvatar(), + ), + ), + ), + )); + + final image = tester.widget(find.byType(GroupAvatar)); + final otherMembers = members.where((it) => it.userId != currentUser.id); + expect( + image.members.map((it) => it.user?.id), + otherMembers.map((it) => it.user?.id), + ); + }, + ); + + testWidgets( + 'using select: true should show a selection border', + (tester) async { + final client = MockClient(); + final clientState = MockClientState(); + final channel = MockChannel(); + final channelState = MockChannelState(); + + when(() => client.state).thenReturn(clientState); + when(() => clientState.user).thenReturn(OwnUser(id: 'user-id')); + when(() => channel.state).thenReturn(channelState); + when(() => channel.client).thenReturn(client); + when(() => channel.extraDataStream).thenAnswer((i) => Stream.value({ + 'name': 'test', + 'image': 'imagetest', + })); + when(() => channel.extraData).thenReturn({ + 'name': 'test', + 'image': 'imagetest', + }); + + await tester.pumpWidget(MaterialApp( + home: StreamChat( + client: client, + child: StreamChannel( + channel: channel, + child: const Scaffold( + body: ChannelAvatar( + selected: true, + ), + ), + ), + ), + )); + + expect(find.byKey(const Key('selectedImage')), findsOneWidget); + }, + ); +} diff --git a/packages/stream_chat_flutter/test/src/channel_list_header_test.dart b/packages/stream_chat_flutter/test/src/channel_list_header_test.dart new file mode 100644 index 00000000..f2f054d4 --- /dev/null +++ b/packages/stream_chat_flutter/test/src/channel_list_header_test.dart @@ -0,0 +1,205 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; + +import 'mocks.dart'; + +void main() { + testWidgets( + 'control test', + (WidgetTester tester) async { + final client = MockClient(); + final clientState = MockClientState(); + + when(() => client.state).thenReturn(clientState); + when(() => clientState.user).thenReturn(OwnUser(id: 'user-id')); + when(() => client.wsConnectionStatusStream) + .thenAnswer((_) => Stream.value(ConnectionStatus.connected)); + + await tester.pumpWidget( + MaterialApp( + home: StreamChat( + client: client, + child: const Scaffold( + body: ChannelListHeader(), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + final userAvatar = tester.widget(find.byType(UserAvatar)); + expect(userAvatar.user, clientState.user); + expect(find.byType(StreamNeumorphicButton), findsOneWidget); + expect(find.text('Stream Chat'), findsOneWidget); + }, + ); + + testWidgets( + 'it should show the InfoTile message if disconnected', + (WidgetTester tester) async { + final client = MockClient(); + final clientState = MockClientState(); + + when(() => client.state).thenReturn(clientState); + when(() => clientState.user).thenReturn(OwnUser(id: 'user-id')); + when(() => client.wsConnectionStatusStream) + .thenAnswer((_) => Stream.value(ConnectionStatus.disconnected)); + + await tester.pumpWidget( + MaterialApp( + home: StreamChat( + client: client, + child: const Scaffold( + body: ChannelListHeader( + showConnectionStateTile: true, + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('Disconnected'), findsOneWidget); + }, + ); + + testWidgets( + 'it should show the InfoTile message if connecting', + (WidgetTester tester) async { + final client = MockClient(); + final clientState = MockClientState(); + + when(() => client.state).thenReturn(clientState); + when(() => clientState.user).thenReturn(OwnUser(id: 'user-id')); + when(() => client.wsConnectionStatusStream) + .thenAnswer((_) => Stream.value(ConnectionStatus.connecting)); + + await tester.pumpWidget( + MaterialApp( + home: StreamChat( + client: client, + child: const Scaffold( + body: ChannelListHeader( + showConnectionStateTile: true, + ), + ), + ), + ), + ); + await tester.pump(); + + expect(find.text('Reconnecting...'), findsOneWidget); + }, + ); + + testWidgets( + 'it should apply passed properties', + (WidgetTester tester) async { + final client = MockClient(); + final clientState = MockClientState(); + + when(() => client.state).thenReturn(clientState); + when(() => clientState.user).thenReturn(OwnUser(id: 'user-id')); + when(() => client.wsConnectionStatusStream) + .thenAnswer((_) => Stream.value(ConnectionStatus.connecting)); + + await tester.pumpWidget( + MaterialApp( + home: StreamChat( + client: client, + child: Scaffold( + body: ChannelListHeader( + titleBuilder: (context, status, client) => const Text('TITLE'), + subtitle: const Text('SUBTITLE'), + leading: const Text('LEADING'), + actions: const [ + Text('ACTION'), + ], + client: client, + ), + ), + ), + ), + ); + await tester.pump(); + + expect(find.text('TITLE'), findsOneWidget); + expect(find.text('SUBTITLE'), findsOneWidget); + expect(find.text('LEADING'), findsOneWidget); + expect(find.text('ACTION'), findsOneWidget); + }, + ); + + testWidgets( + 'it should apply prenavigationcallback', + (WidgetTester tester) async { + final client = MockClient(); + final clientState = MockClientState(); + + when(() => client.state).thenReturn(clientState); + when(() => clientState.user).thenReturn(OwnUser(id: 'user-id')); + when(() => client.wsConnectionStatusStream) + .thenAnswer((_) => Stream.value(ConnectionStatus.connecting)); + + var tapped = false; + + await tester.pumpWidget( + MaterialApp( + home: StreamChat( + client: client, + child: Scaffold( + body: ChannelListHeader( + preNavigationCallback: () { + tapped = true; + }, + ), + ), + ), + ), + ); + await tester.pump(); + + await tester.tap(find.byType(UserAvatar)); + expect(tapped, true); + }, + ); + + testWidgets( + 'it should apply passed callbacks', + (WidgetTester tester) async { + final client = MockClient(); + final clientState = MockClientState(); + + when(() => client.state).thenReturn(clientState); + when(() => clientState.user).thenReturn(OwnUser(id: 'user-id')); + when(() => client.wsConnectionStatusStream) + .thenAnswer((_) => Stream.value(ConnectionStatus.connecting)); + + var tapped = 0; + await tester.pumpWidget( + MaterialApp( + home: StreamChat( + client: client, + child: Scaffold( + body: ChannelListHeader( + onUserAvatarTap: (u) { + tapped++; + }, + onNewChatButtonTap: () { + tapped++; + }, + ), + ), + ), + ), + ); + await tester.pump(); + + await tester.tap(find.byType(UserAvatar)); + await tester.tap(find.byType(StreamNeumorphicButton)); + expect(tapped, 2); + }, + ); +} diff --git a/packages/stream_chat_flutter/test/src/channel_name_test.dart b/packages/stream_chat_flutter/test/src/channel_name_test.dart new file mode 100644 index 00000000..c8d3df9b --- /dev/null +++ b/packages/stream_chat_flutter/test/src/channel_name_test.dart @@ -0,0 +1,74 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; + +import 'mocks.dart'; + +void main() { + testWidgets( + 'it should show channel name', + (WidgetTester tester) async { + final client = MockClient(); + final clientState = MockClientState(); + final channel = MockChannel(); + final channelState = MockChannelState(); + final lastMessageAt = DateTime.parse('2020-06-22 12:00:00'); + + when(() => client.state).thenReturn(clientState); + when(() => clientState.user).thenReturn(OwnUser(id: 'user-id')); + when(() => channel.lastMessageAt).thenReturn(lastMessageAt); + when(() => channel.state).thenReturn(channelState); + when(() => channel.client).thenReturn(client); + when(() => channel.isMuted).thenReturn(false); + when(() => channel.isMutedStream).thenAnswer((i) => Stream.value(false)); + when(() => channel.extraDataStream).thenAnswer((i) => Stream.value({ + 'name': 'test', + })); + when(() => channel.extraData).thenReturn({ + 'name': 'test', + }); + when(() => channelState.unreadCount).thenReturn(1); + when(() => channelState.unreadCountStream) + .thenAnswer((i) => Stream.value(1)); + when(() => channelState.membersStream).thenAnswer((i) => Stream.value([ + Member( + userId: 'user-id', + user: User(id: 'user-id'), + ) + ])); + when(() => channelState.members).thenReturn([ + Member( + userId: 'user-id', + user: User(id: 'user-id'), + ), + ]); + when(() => channelState.messages).thenReturn([ + Message( + text: 'hello', + user: User(id: 'other-user'), + ) + ]); + when(() => channelState.messagesStream).thenAnswer((i) => Stream.value([ + Message( + text: 'hello', + user: User(id: 'other-user'), + ) + ])); + + await tester.pumpWidget(MaterialApp( + home: StreamChat( + client: client, + child: StreamChannel( + channel: channel, + child: const Scaffold( + body: ChannelName(), + ), + ), + ), + )); + + expect(find.text('test'), findsOneWidget); + }, + ); +} diff --git a/packages/stream_chat_flutter/test/src/channel_preview_test.dart b/packages/stream_chat_flutter/test/src/channel_preview_test.dart index 094c74a5..6add0c60 100644 --- a/packages/stream_chat_flutter/test/src/channel_preview_test.dart +++ b/packages/stream_chat_flutter/test/src/channel_preview_test.dart @@ -1,6 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:mockito/mockito.dart'; +import 'package:mocktail/mocktail.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; import 'mocks.dart'; @@ -13,42 +13,49 @@ void main() { final clientState = MockClientState(); final channel = MockChannel(); final channelState = MockChannelState(); + final user = OwnUser(id: 'user-id'); final lastMessageAt = DateTime.parse('2020-06-22 12:00:00'); - when(client.state).thenReturn(clientState); - when(clientState.user).thenReturn(OwnUser(id: 'user-id')); - when(channel.lastMessageAt).thenReturn(lastMessageAt); - when(channel.state).thenReturn(channelState); - when(channel.client).thenReturn(client); - when(channel.isMuted).thenReturn(false); - when(channel.isMutedStream).thenAnswer((i) => Stream.value(false)); - when(channel.extraDataStream).thenAnswer((i) => Stream.value({ + when(() => channel.cid).thenReturn('cid'); + when(() => client.state).thenReturn(clientState); + when(() => clientState.user).thenReturn(user); + when(() => clientState.userStream).thenAnswer((_) => Stream.value(user)); + when(() => channel.lastMessageAt).thenReturn(lastMessageAt); + when(() => channel.state).thenReturn(channelState); + when(() => channel.client).thenReturn(client); + when(() => channel.isMuted).thenReturn(false); + when(() => channel.isMutedStream).thenAnswer((i) => Stream.value(false)); + when(() => channel.extraDataStream).thenAnswer((i) => Stream.value({ 'name': 'test name', })); - when(channel.extraData).thenReturn({ + when(() => channel.extraData).thenReturn({ 'name': 'test name', }); - when(channelState.unreadCount).thenReturn(1); - when(channelState.unreadCountStream).thenAnswer((i) => Stream.value(1)); - when(channelState.membersStream).thenAnswer((i) => Stream.value([ + when(() => clientState.channels).thenReturn({ + channel.cid!: channel, + }); + when(() => channelState.unreadCount).thenReturn(1); + when(() => channelState.unreadCountStream) + .thenAnswer((i) => Stream.value(1)); + when(() => channelState.membersStream).thenAnswer((i) => Stream.value([ Member( userId: 'user-id', user: User(id: 'user-id'), ) ])); - when(channelState.members).thenReturn([ + when(() => channelState.members).thenReturn([ Member( userId: 'user-id', user: User(id: 'user-id'), ), ]); - when(channelState.messages).thenReturn([ + when(() => channelState.messages).thenReturn([ Message( text: 'hello', user: User(id: 'other-user'), ) ]); - when(channelState.messagesStream).thenAnswer((i) => Stream.value([ + when(() => channelState.messagesStream).thenAnswer((i) => Stream.value([ Message( text: 'hello', user: User(id: 'other-user'), @@ -73,7 +80,7 @@ void main() { expect(find.text('test name'), findsOneWidget); expect(find.text('1'), findsOneWidget); expect(find.text('hello'), findsOneWidget); - expect(find.byType(ChannelImage), findsOneWidget); + expect(find.byType(ChannelAvatar), findsOneWidget); }, ); } diff --git a/packages/stream_chat_flutter/test/src/date_divider_test.dart b/packages/stream_chat_flutter/test/src/date_divider_test.dart new file mode 100644 index 00000000..eb5e9b70 --- /dev/null +++ b/packages/stream_chat_flutter/test/src/date_divider_test.dart @@ -0,0 +1,32 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; + +import 'mocks.dart'; + +void main() { + testWidgets( + 'it should show basic channel information', + (WidgetTester tester) async { + final client = MockClient(); + final clientState = MockClientState(); + + when(() => client.state).thenReturn(clientState); + when(() => clientState.user).thenReturn(OwnUser(id: 'user-id')); + + await tester.pumpWidget(MaterialApp( + home: StreamChat( + client: client, + child: Scaffold( + body: DateDivider( + dateTime: DateTime.now(), + ), + ), + ), + )); + + expect(find.text('Today'), findsOneWidget); + }, + ); +} diff --git a/packages/stream_chat_flutter/test/src/deleted_message_test.dart b/packages/stream_chat_flutter/test/src/deleted_message_test.dart new file mode 100644 index 00000000..0e30d2b3 --- /dev/null +++ b/packages/stream_chat_flutter/test/src/deleted_message_test.dart @@ -0,0 +1,201 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:golden_toolkit/golden_toolkit.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; + +import 'mocks.dart'; + +void main() { + testWidgets( + 'control test', + (WidgetTester tester) async { + final client = MockClient(); + final clientState = MockClientState(); + + when(() => client.state).thenReturn(clientState); + when(() => clientState.user).thenReturn(OwnUser(id: 'user-id')); + + await tester.pumpWidget(MaterialApp( + home: StreamChat( + client: client, + child: const Scaffold( + body: DeletedMessage( + messageTheme: MessageTheme( + createdAt: TextStyle( + color: Colors.black, + ), + messageText: TextStyle(), + ), + ), + ), + ), + )); + + expect(find.text('Message deleted'), findsOneWidget); + }, + ); + + testGoldens( + 'control golden light', + (WidgetTester tester) async { + final client = MockClient(); + final clientState = MockClientState(); + final channel = MockChannel(); + final channelState = MockChannelState(); + final lastMessageAt = DateTime.parse('2020-06-22 12:00:00'); + + when(() => client.state).thenReturn(clientState); + when(() => clientState.user).thenReturn(OwnUser(id: 'user-id')); + when(() => channel.lastMessageAt).thenReturn(lastMessageAt); + when(() => channel.state).thenReturn(channelState); + when(() => channel.client).thenReturn(client); + when(() => channel.extraDataStream).thenAnswer((i) => Stream.value({ + 'name': 'test', + })); + when(() => channel.extraData).thenReturn({ + 'name': 'test', + }); + + when(() => clientState.totalUnreadCount).thenReturn(10); + when(() => clientState.totalUnreadCountStream) + .thenAnswer((i) => Stream.value(10)); + + final materialTheme = ThemeData.light(); + final theme = StreamChatThemeData.fromTheme(materialTheme); + await tester.pumpWidgetBuilder( + materialAppWrapper( + theme: materialTheme, + )( + StreamChat( + streamChatThemeData: theme, + client: client, + connectivityStream: Stream.value(ConnectivityResult.mobile), + child: StreamChannel( + showLoading: false, + channel: channel, + child: Center( + child: DeletedMessage( + messageTheme: theme.ownMessageTheme, + ), + ), + ), + ), + ), + surfaceSize: const Size.square(200), + ); + + await screenMatchesGolden(tester, 'deleted_message_light'); + }, + ); + + testGoldens( + 'control golden dark', + (WidgetTester tester) async { + final client = MockClient(); + final clientState = MockClientState(); + final channel = MockChannel(); + final channelState = MockChannelState(); + final lastMessageAt = DateTime.parse('2020-06-22 12:00:00'); + + when(() => client.state).thenReturn(clientState); + when(() => clientState.user).thenReturn(OwnUser(id: 'user-id')); + when(() => channel.lastMessageAt).thenReturn(lastMessageAt); + when(() => channel.state).thenReturn(channelState); + when(() => channel.client).thenReturn(client); + when(() => channel.extraDataStream).thenAnswer((i) => Stream.value({ + 'name': 'test', + })); + when(() => channel.extraData).thenReturn({ + 'name': 'test', + }); + + when(() => clientState.totalUnreadCount).thenReturn(10); + when(() => clientState.totalUnreadCountStream) + .thenAnswer((i) => Stream.value(10)); + + final materialTheme = ThemeData.dark(); + final theme = StreamChatThemeData.fromTheme(materialTheme); + await tester.pumpWidgetBuilder( + materialAppWrapper( + theme: materialTheme, + )( + StreamChat( + streamChatThemeData: theme, + client: client, + connectivityStream: Stream.value(ConnectivityResult.mobile), + child: StreamChannel( + showLoading: false, + channel: channel, + child: Center( + child: DeletedMessage( + messageTheme: theme.ownMessageTheme, + ), + ), + ), + ), + ), + surfaceSize: const Size.square(200), + ); + + await screenMatchesGolden(tester, 'deleted_message_dark'); + }, + ); + + testGoldens( + 'golden customization test', + (WidgetTester tester) async { + final client = MockClient(); + final clientState = MockClientState(); + final channel = MockChannel(); + final channelState = MockChannelState(); + final lastMessageAt = DateTime.parse('2020-06-22 12:00:00'); + + when(() => client.state).thenReturn(clientState); + when(() => clientState.user).thenReturn(OwnUser(id: 'user-id')); + when(() => channel.lastMessageAt).thenReturn(lastMessageAt); + when(() => channel.state).thenReturn(channelState); + when(() => channel.client).thenReturn(client); + when(() => channel.extraDataStream).thenAnswer((i) => Stream.value({ + 'name': 'test', + })); + when(() => channel.extraData).thenReturn({ + 'name': 'test', + }); + + when(() => clientState.totalUnreadCount).thenReturn(10); + when(() => clientState.totalUnreadCountStream) + .thenAnswer((i) => Stream.value(10)); + + final materialTheme = ThemeData.light(); + final theme = StreamChatThemeData.fromTheme(materialTheme); + await tester.pumpWidgetBuilder( + materialAppWrapper( + theme: materialTheme, + )( + StreamChat( + streamChatThemeData: theme, + client: client, + connectivityStream: Stream.value(ConnectivityResult.mobile), + child: StreamChannel( + showLoading: false, + channel: channel, + child: Center( + child: DeletedMessage( + messageTheme: theme.ownMessageTheme, + reverse: true, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + ), + ), + ), + ), + surfaceSize: const Size.square(200), + ); + + await screenMatchesGolden(tester, 'deleted_message_custom'); + }, + ); +} diff --git a/packages/stream_chat_flutter/test/src/full_screen_media_test.dart b/packages/stream_chat_flutter/test/src/full_screen_media_test.dart new file mode 100644 index 00000000..03f6ea65 --- /dev/null +++ b/packages/stream_chat_flutter/test/src/full_screen_media_test.dart @@ -0,0 +1,91 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:photo_view/photo_view.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; + +import 'mocks.dart'; + +void main() { + testWidgets( + 'it should show channel typing', + (WidgetTester tester) async { + final client = MockClient(); + final clientState = MockClientState(); + final channel = MockChannel(); + final channelState = MockChannelState(); + final lastMessageAt = DateTime.parse('2020-06-22 12:00:00'); + + when(() => client.state).thenReturn(clientState); + when(() => clientState.user).thenReturn(OwnUser(id: 'user-id')); + when(() => channel.lastMessageAt).thenReturn(lastMessageAt); + when(() => channel.state).thenReturn(channelState); + when(() => channel.client).thenReturn(client); + when(() => channel.isMuted).thenReturn(false); + when(() => channel.isMutedStream).thenAnswer((i) => Stream.value(false)); + when(() => channel.extraDataStream).thenAnswer((i) => Stream.value({ + 'name': 'test', + })); + when(() => channel.extraData).thenReturn({ + 'name': 'test', + }); + when(() => channelState.membersStream).thenAnswer((i) => Stream.value([ + Member( + userId: 'user-id', + user: User(id: 'user-id'), + ) + ])); + when(() => channelState.members).thenReturn([ + Member( + userId: 'user-id', + user: User(id: 'user-id'), + ), + ]); + when(() => channelState.messages).thenReturn([ + Message( + text: 'hello', + user: User(id: 'other-user'), + ) + ]); + when(() => channelState.messagesStream).thenAnswer((i) => Stream.value([ + Message( + text: 'hello', + user: User(id: 'other-user'), + ) + ])); + when(() => channelState.typingEvents).thenAnswer((i) => { + User(id: 'other-user', extraData: const {'name': 'demo'}): + Event(type: EventType.typingStart), + }); + when(() => channelState.typingEventsStream) + .thenAnswer((i) => Stream.value({ + User(id: 'other-user', extraData: const {'name': 'demo'}): + Event(type: EventType.typingStart), + })); + + await tester.pumpWidget(MaterialApp( + home: StreamChat( + client: client, + child: StreamChannel( + channel: channel, + child: FullScreenMedia( + mediaAttachments: [ + Attachment( + type: 'image', + title: 'demo image', + imageUrl: '', + ), + ], + message: Message( + createdAt: DateTime.now(), + ), + ), + ), + ), + )); + + expect(find.byType(PhotoView), findsOneWidget); + expect(find.byType(StreamSvgIcon), findsNWidgets(4)); + }, + ); +} diff --git a/packages/stream_chat_flutter/test/src/gallery_footer_theme_test.dart b/packages/stream_chat_flutter/test/src/gallery_footer_theme_test.dart new file mode 100644 index 00000000..fb8d7035 --- /dev/null +++ b/packages/stream_chat_flutter/test/src/gallery_footer_theme_test.dart @@ -0,0 +1,185 @@ +import 'package:flutter/material.dart' hide TextTheme; +import 'package:flutter_test/flutter_test.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; +import 'package:mocktail/mocktail.dart'; + +class MockStreamChatClient extends Mock implements StreamChatClient {} + +void main() { + test('GalleryFooterThemeData copyWith, ==, hashCode basics', () { + expect(const GalleryFooterThemeData(), + const GalleryFooterThemeData().copyWith()); + expect(const GalleryFooterThemeData().hashCode, + const GalleryFooterThemeData().copyWith().hashCode); + }); + + test( + '''Light GalleryFooterThemeData lerps completely to dark GalleryFooterThemeData''', + () { + expect( + const GalleryFooterThemeData().lerp(_galleryFooterThemeDataControl, + _galleryFooterThemeDataControlDark, 1), + _galleryFooterThemeDataControlDark); + }); + + test( + '''Light GalleryFooterThemeData lerps halfway to dark GalleryFooterThemeData''', + () { + expect( + const GalleryFooterThemeData().lerp(_galleryFooterThemeDataControl, + _galleryFooterThemeDataControlDark, 0.5), + _galleryFooterThemeDataControlMidLerp); + }); + + test( + '''Dark GalleryFooterThemeData lerps completely to light GalleryFooterThemeData''', + () { + expect( + const GalleryFooterThemeData().lerp(_galleryFooterThemeDataControlDark, + _galleryFooterThemeDataControl, 1), + _galleryFooterThemeDataControl); + }); + + test('Merging dark and light themes results in a dark theme', () { + expect( + _galleryFooterThemeDataControl + .merge(_galleryFooterThemeDataControlDark), + _galleryFooterThemeDataControlDark); + }); + + test('Merging dark and light themes results in a dark theme', () { + expect( + _galleryFooterThemeDataControlDark + .merge(_galleryFooterThemeDataControl), + _galleryFooterThemeDataControl); + }); + + testWidgets( + 'Passing no GalleryFooterThemeData returns default light theme values', + (WidgetTester tester) async { + late BuildContext _context; + await tester.pumpWidget( + MaterialApp( + builder: (context, child) => StreamChat( + client: MockStreamChatClient(), + child: child, + ), + home: Builder( + builder: (context) { + _context = context; + return Scaffold( + appBar: GalleryFooter( + message: Message(), + ), + ); + }, + ), + ), + ); + + final imageFooterTheme = GalleryFooterTheme.of(_context); + expect(imageFooterTheme.backgroundColor, + _galleryFooterThemeDataControl.backgroundColor); + expect(imageFooterTheme.shareIconColor, + _galleryFooterThemeDataControl.shareIconColor); + expect(imageFooterTheme.titleTextStyle, + _galleryFooterThemeDataControl.titleTextStyle); + expect(imageFooterTheme.gridIconButtonColor, + _galleryFooterThemeDataControl.gridIconButtonColor); + expect(imageFooterTheme.bottomSheetBarrierColor, + _galleryFooterThemeDataControl.bottomSheetBarrierColor); + expect(imageFooterTheme.bottomSheetBackgroundColor, + _galleryFooterThemeDataControl.bottomSheetBackgroundColor); + expect(imageFooterTheme.bottomSheetCloseIconColor, + _galleryFooterThemeDataControl.bottomSheetCloseIconColor); + expect(imageFooterTheme.bottomSheetPhotosTextStyle, + _galleryFooterThemeDataControl.bottomSheetPhotosTextStyle); + }); + + testWidgets( + 'Passing no GalleryFooterThemeData returns default dark theme values', + (WidgetTester tester) async { + late BuildContext _context; + await tester.pumpWidget( + MaterialApp( + builder: (context, child) => StreamChat( + client: MockStreamChatClient(), + streamChatThemeData: StreamChatThemeData.dark(), + child: child, + ), + home: Builder( + builder: (context) { + _context = context; + return Scaffold( + appBar: GalleryFooter( + message: Message(), + ), + ); + }, + ), + ), + ); + + final imageFooterTheme = GalleryFooterTheme.of(_context); + expect(imageFooterTheme.backgroundColor, + _galleryFooterThemeDataControlDark.backgroundColor); + expect(imageFooterTheme.shareIconColor, + _galleryFooterThemeDataControlDark.shareIconColor); + expect(imageFooterTheme.titleTextStyle, + _galleryFooterThemeDataControlDark.titleTextStyle); + expect(imageFooterTheme.gridIconButtonColor, + _galleryFooterThemeDataControlDark.gridIconButtonColor); + expect(imageFooterTheme.bottomSheetBarrierColor, + _galleryFooterThemeDataControlDark.bottomSheetBarrierColor); + expect(imageFooterTheme.bottomSheetBackgroundColor, + _galleryFooterThemeDataControlDark.bottomSheetBackgroundColor); + expect(imageFooterTheme.bottomSheetCloseIconColor, + _galleryFooterThemeDataControlDark.bottomSheetCloseIconColor); + expect(imageFooterTheme.bottomSheetPhotosTextStyle, + _galleryFooterThemeDataControlDark.bottomSheetPhotosTextStyle); + }); +} + +// Light theme control +final _galleryFooterThemeDataControl = GalleryFooterThemeData( + backgroundColor: ColorTheme.light().barsBg, + shareIconColor: ColorTheme.light().textHighEmphasis, + titleTextStyle: TextTheme.light().headlineBold, + gridIconButtonColor: ColorTheme.light().textHighEmphasis, + bottomSheetBackgroundColor: ColorTheme.light().barsBg, + bottomSheetBarrierColor: ColorTheme.light().overlay, + bottomSheetCloseIconColor: ColorTheme.light().textHighEmphasis, + bottomSheetPhotosTextStyle: TextTheme.light().headlineBold, +); + +// Mid-lerp theme control +const _galleryFooterThemeDataControlMidLerp = GalleryFooterThemeData( + backgroundColor: Color(0xff87898b), + shareIconColor: Color(0xff7f7f7f), + titleTextStyle: TextStyle( + color: Color(0xff7f7f7f), + fontSize: 16, + fontWeight: FontWeight.bold, + ), + gridIconButtonColor: Color(0xff7f7f7f), + bottomSheetBarrierColor: Color(0x4c000000), + bottomSheetBackgroundColor: Color(0xff87898b), + bottomSheetPhotosTextStyle: TextStyle( + color: Color(0xff7f7f7f), + fontSize: 16, + fontWeight: FontWeight.bold, + ), + bottomSheetCloseIconColor: Color(0xff7f7f7f), +); + +// Dark theme control +final _galleryFooterThemeDataControlDark = GalleryFooterThemeData( + backgroundColor: ColorTheme.dark().barsBg, + shareIconColor: ColorTheme.dark().textHighEmphasis, + titleTextStyle: TextTheme.dark().headlineBold, + gridIconButtonColor: ColorTheme.dark().textHighEmphasis, + bottomSheetBackgroundColor: ColorTheme.dark().barsBg, + bottomSheetBarrierColor: ColorTheme.dark().overlay, + bottomSheetCloseIconColor: ColorTheme.dark().textHighEmphasis, + bottomSheetPhotosTextStyle: TextTheme.dark().headlineBold, +); diff --git a/packages/stream_chat_flutter/test/src/gallery_header_theme_test.dart b/packages/stream_chat_flutter/test/src/gallery_header_theme_test.dart new file mode 100644 index 00000000..cbcc07a6 --- /dev/null +++ b/packages/stream_chat_flutter/test/src/gallery_header_theme_test.dart @@ -0,0 +1,190 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; +import 'package:mocktail/mocktail.dart'; + +class MockStreamChatClient extends Mock implements StreamChatClient {} + +void main() { + test('GalleryHeaderThemeData copyWith, ==, hashCode basics', () { + expect(const GalleryHeaderThemeData(), + const GalleryHeaderThemeData().copyWith()); + expect(const GalleryHeaderThemeData().hashCode, + const GalleryHeaderThemeData().copyWith().hashCode); + }); + + test( + '''Light GalleryHeaderThemeData lerps completely to dark GalleryHeaderThemeData''', + () { + expect( + const GalleryHeaderThemeData().lerp(_galleryHeaderThemeDataControl, + _galleryHeaderThemeDataDarkControl, 1), + _galleryHeaderThemeDataDarkControl); + }); + + test( + '''Light GalleryHeaderThemeData lerps halfway to dark GalleryHeaderThemeData''', + () { + expect( + const GalleryHeaderThemeData().lerp(_galleryHeaderThemeDataControl, + _galleryHeaderThemeDataDarkControl, 0.5), + _galleryHeaderThemeDataHalfLerpControl); + }); + + test( + '''Dark GalleryHeaderThemeData lerps completely to light GalleryHeaderThemeData''', + () { + expect( + const GalleryHeaderThemeData().lerp(_galleryHeaderThemeDataDarkControl, + _galleryHeaderThemeDataControl, 1), + _galleryHeaderThemeDataControl); + }); + + test('Merging dark and light themes results in a dark theme', () { + expect( + _galleryHeaderThemeDataControl + .merge(_galleryHeaderThemeDataDarkControl), + _galleryHeaderThemeDataDarkControl); + }); + + test('Merging dark and light themes results in a dark theme', () { + expect( + _galleryHeaderThemeDataDarkControl + .merge(_galleryHeaderThemeDataControl), + _galleryHeaderThemeDataControl); + }); + + testWidgets( + 'Passing no GalleryHeaderThemeData returns default light theme values', + (WidgetTester tester) async { + late BuildContext _context; + await tester.pumpWidget( + MaterialApp( + builder: (context, child) => StreamChat( + client: MockStreamChatClient(), + child: child, + ), + home: Builder( + builder: (context) { + _context = context; + return Scaffold( + appBar: GalleryHeader( + message: Message(), + ), + ); + }, + ), + ), + ); + + final imageHeaderTheme = GalleryHeaderTheme.of(_context); + expect(imageHeaderTheme.closeButtonColor, + _galleryHeaderThemeDataControl.closeButtonColor); + expect(imageHeaderTheme.backgroundColor, + _galleryHeaderThemeDataControl.backgroundColor); + expect(imageHeaderTheme.iconMenuPointColor, + _galleryHeaderThemeDataControl.iconMenuPointColor); + expect(imageHeaderTheme.titleTextStyle, + _galleryHeaderThemeDataControl.titleTextStyle); + expect(imageHeaderTheme.subtitleTextStyle, + _galleryHeaderThemeDataControl.subtitleTextStyle); + expect(imageHeaderTheme.bottomSheetBarrierColor, + _galleryHeaderThemeDataControl.bottomSheetBarrierColor); + }); + + testWidgets( + 'Passing no GalleryHeaderThemeData returns default dark theme values', + (WidgetTester tester) async { + late BuildContext _context; + await tester.pumpWidget( + MaterialApp( + builder: (context, child) => StreamChat( + client: MockStreamChatClient(), + streamChatThemeData: StreamChatThemeData.dark(), + child: child, + ), + home: Builder( + builder: (context) { + _context = context; + return Scaffold( + appBar: GalleryHeader( + message: Message(), + ), + ); + }, + ), + ), + ); + + final imageHeaderTheme = GalleryHeaderTheme.of(_context); + expect(imageHeaderTheme.closeButtonColor, + _galleryHeaderThemeDataDarkControl.closeButtonColor); + expect(imageHeaderTheme.backgroundColor, + _galleryHeaderThemeDataDarkControl.backgroundColor); + expect(imageHeaderTheme.iconMenuPointColor, + _galleryHeaderThemeDataDarkControl.iconMenuPointColor); + expect(imageHeaderTheme.titleTextStyle, + _galleryHeaderThemeDataDarkControl.titleTextStyle); + expect(imageHeaderTheme.subtitleTextStyle, + _galleryHeaderThemeDataDarkControl.subtitleTextStyle); + expect(imageHeaderTheme.bottomSheetBarrierColor, + _galleryHeaderThemeDataDarkControl.bottomSheetBarrierColor); + }); +} + +// Light theme test control. +final _galleryHeaderThemeDataControl = GalleryHeaderThemeData( + closeButtonColor: const Color(0xff000000), + backgroundColor: const Color(0xffffffff), + iconMenuPointColor: const Color(0xff000000), + titleTextStyle: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: Colors.black, + ), + subtitleTextStyle: const TextStyle( + fontSize: 12, + color: Colors.black, + ).copyWith( + color: const Color(0xff7A7A7A), + ), + bottomSheetBarrierColor: const Color.fromRGBO(0, 0, 0, 0.2), +); + +// Light theme test control. +final _galleryHeaderThemeDataHalfLerpControl = GalleryHeaderThemeData( + closeButtonColor: const Color(0xff7f7f7f), + backgroundColor: const Color(0xff87898b), + iconMenuPointColor: const Color(0xff7f7f7f), + titleTextStyle: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: Color(0xff7f7f7f), + ), + subtitleTextStyle: const TextStyle( + fontSize: 12, + color: Color(0xff7a7a7a), + ).copyWith( + color: const Color(0xff7A7A7A), + ), + bottomSheetBarrierColor: const Color(0x4c000000), +); + +// Dark theme test control. +final _galleryHeaderThemeDataDarkControl = GalleryHeaderThemeData( + closeButtonColor: const Color(0xffffffff), + backgroundColor: const Color(0xff101418), + iconMenuPointColor: const Color(0xffffffff), + titleTextStyle: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: Colors.white, + ), + subtitleTextStyle: const TextStyle( + fontSize: 12, + color: Colors.white, + ).copyWith( + color: const Color(0xff7A7A7A), + ), + bottomSheetBarrierColor: const Color.fromRGBO(0, 0, 0, 0.4), +); diff --git a/packages/stream_chat_flutter/test/src/goldens/deleted_message_custom.png b/packages/stream_chat_flutter/test/src/goldens/deleted_message_custom.png new file mode 100644 index 00000000..c464bc01 Binary files /dev/null and b/packages/stream_chat_flutter/test/src/goldens/deleted_message_custom.png differ diff --git a/packages/stream_chat_flutter/test/src/goldens/deleted_message_dark.png b/packages/stream_chat_flutter/test/src/goldens/deleted_message_dark.png new file mode 100644 index 00000000..7db0a6ef Binary files /dev/null and b/packages/stream_chat_flutter/test/src/goldens/deleted_message_dark.png differ diff --git a/packages/stream_chat_flutter/test/src/goldens/deleted_message_light.png b/packages/stream_chat_flutter/test/src/goldens/deleted_message_light.png new file mode 100644 index 00000000..d5a453ff Binary files /dev/null and b/packages/stream_chat_flutter/test/src/goldens/deleted_message_light.png differ diff --git a/packages/stream_chat_flutter/test/src/goldens/message_text.png b/packages/stream_chat_flutter/test/src/goldens/message_text.png new file mode 100644 index 00000000..ecd93721 Binary files /dev/null and b/packages/stream_chat_flutter/test/src/goldens/message_text.png differ diff --git a/packages/stream_chat_flutter/test/src/goldens/reaction_bubble_0.png b/packages/stream_chat_flutter/test/src/goldens/reaction_bubble_0.png new file mode 100644 index 00000000..18f898ad Binary files /dev/null and b/packages/stream_chat_flutter/test/src/goldens/reaction_bubble_0.png differ diff --git a/packages/stream_chat_flutter/test/src/goldens/reaction_bubble_2.png b/packages/stream_chat_flutter/test/src/goldens/reaction_bubble_2.png new file mode 100644 index 00000000..05cfdbbe Binary files /dev/null and b/packages/stream_chat_flutter/test/src/goldens/reaction_bubble_2.png differ diff --git a/packages/stream_chat_flutter/test/src/goldens/reaction_bubble_3_dark.png b/packages/stream_chat_flutter/test/src/goldens/reaction_bubble_3_dark.png new file mode 100644 index 00000000..cb7e4894 Binary files /dev/null and b/packages/stream_chat_flutter/test/src/goldens/reaction_bubble_3_dark.png differ diff --git a/packages/stream_chat_flutter/test/src/goldens/reaction_bubble_3_light.png b/packages/stream_chat_flutter/test/src/goldens/reaction_bubble_3_light.png new file mode 100644 index 00000000..8caefa4f Binary files /dev/null and b/packages/stream_chat_flutter/test/src/goldens/reaction_bubble_3_light.png differ diff --git a/packages/stream_chat_flutter/test/src/goldens/reaction_bubble_like_dark.png b/packages/stream_chat_flutter/test/src/goldens/reaction_bubble_like_dark.png new file mode 100644 index 00000000..84444215 Binary files /dev/null and b/packages/stream_chat_flutter/test/src/goldens/reaction_bubble_like_dark.png differ diff --git a/packages/stream_chat_flutter/test/src/goldens/reaction_bubble_like_light.png b/packages/stream_chat_flutter/test/src/goldens/reaction_bubble_like_light.png new file mode 100644 index 00000000..b198aef5 Binary files /dev/null and b/packages/stream_chat_flutter/test/src/goldens/reaction_bubble_like_light.png differ diff --git a/packages/stream_chat_flutter/test/src/goldens/system_message_dark.png b/packages/stream_chat_flutter/test/src/goldens/system_message_dark.png new file mode 100644 index 00000000..9055f705 Binary files /dev/null and b/packages/stream_chat_flutter/test/src/goldens/system_message_dark.png differ diff --git a/packages/stream_chat_flutter/test/src/goldens/system_message_light.png b/packages/stream_chat_flutter/test/src/goldens/system_message_light.png new file mode 100644 index 00000000..d82d8d60 Binary files /dev/null and b/packages/stream_chat_flutter/test/src/goldens/system_message_light.png differ diff --git a/packages/stream_chat_flutter/test/src/image_footer_test.dart b/packages/stream_chat_flutter/test/src/image_footer_test.dart new file mode 100644 index 00000000..0fe3349a --- /dev/null +++ b/packages/stream_chat_flutter/test/src/image_footer_test.dart @@ -0,0 +1,52 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; + +import 'mocks.dart'; + +void main() { + testWidgets( + 'it should show channel typing', + (WidgetTester tester) async { + final client = MockClient(); + final clientState = MockClientState(); + final channel = MockChannel(); + final channelState = MockChannelState(); + final lastMessageAt = DateTime.parse('2020-06-22 12:00:00'); + + when(() => client.state).thenReturn(clientState); + when(() => clientState.user).thenReturn(OwnUser(id: 'user-id')); + when(() => channel.lastMessageAt).thenReturn(lastMessageAt); + when(() => channel.state).thenReturn(channelState); + when(() => channel.client).thenReturn(client); + when(() => channel.isMuted).thenReturn(false); + when(() => channel.isMutedStream).thenAnswer((i) => Stream.value(false)); + when(() => channel.extraDataStream).thenAnswer((i) => Stream.value({ + 'name': 'test', + })); + when(() => channel.extraData).thenReturn({ + 'name': 'test', + }); + + await tester.pumpWidget(MaterialApp( + home: StreamChat( + client: client, + child: StreamChannel( + channel: channel, + child: WillPopScope( + onWillPop: () async => false, + child: Scaffold( + body: GalleryFooter( + message: Message(), + ), + ), + ), + ), + ), + )); + + expect(find.byType(StreamSvgIcon), findsNWidgets(2)); + }, + ); +} diff --git a/packages/stream_chat_flutter/test/src/info_tile_test.dart b/packages/stream_chat_flutter/test/src/info_tile_test.dart new file mode 100644 index 00000000..02387edc --- /dev/null +++ b/packages/stream_chat_flutter/test/src/info_tile_test.dart @@ -0,0 +1,69 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_portal/flutter_portal.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; + +import 'mocks.dart'; + +void main() { + testWidgets( + 'control test', + (WidgetTester tester) async { + final client = MockClient(); + final clientState = MockClientState(); + + when(() => client.state).thenReturn(clientState); + when(() => clientState.user).thenReturn(OwnUser(id: 'user-id')); + + await tester.pumpWidget(MaterialApp( + home: StreamChat( + client: client, + child: const Scaffold( + body: Portal( + child: SizedBox( + child: InfoTile( + showMessage: true, + message: 'message', + child: Text('test'), + ), + ), + ), + ), + ), + )); + + expect(find.text('message'), findsOneWidget); + }, + ); + + testWidgets( + 'it should hide when passing showMessage: false', + (WidgetTester tester) async { + final client = MockClient(); + final clientState = MockClientState(); + + when(() => client.state).thenReturn(clientState); + when(() => clientState.user).thenReturn(OwnUser(id: 'user-id')); + + await tester.pumpWidget(MaterialApp( + home: StreamChat( + client: client, + child: const Scaffold( + body: Portal( + child: SizedBox( + child: InfoTile( + showMessage: false, + message: 'message', + child: Text('test'), + ), + ), + ), + ), + ), + )); + + expect(find.text('message'), findsNothing); + }, + ); +} diff --git a/packages/stream_chat_flutter/test/src/message_action_modal_test.dart b/packages/stream_chat_flutter/test/src/message_action_modal_test.dart index 3cb916d1..953434de 100644 --- a/packages/stream_chat_flutter/test/src/message_action_modal_test.dart +++ b/packages/stream_chat_flutter/test/src/message_action_modal_test.dart @@ -1,30 +1,36 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:mockito/mockito.dart'; +import 'package:mocktail/mocktail.dart'; import 'package:stream_chat_flutter/src/message_actions_modal.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; import 'mocks.dart'; void main() { + setUpAll(() { + registerFallbackValue( + MaterialPageRoute(builder: (context) => const SizedBox())); + registerFallbackValue(Message()); + }); + testWidgets( 'it should show the all actions', (WidgetTester tester) async { final client = MockClient(); final clientState = MockClientState(); - when(client.state).thenReturn(clientState); - when(clientState.user).thenReturn(OwnUser(id: 'user-id')); + when(() => client.state).thenReturn(clientState); + when(() => clientState.user).thenReturn(OwnUser(id: 'user-id')); final themeData = ThemeData(); - final streamTheme = StreamChatThemeData.getDefaultTheme(themeData); + final streamTheme = StreamChatThemeData.fromTheme(themeData); await tester.pumpWidget( MaterialApp( theme: themeData, home: StreamChat( streamChatThemeData: streamTheme, client: client, - child: Container( + child: SizedBox( child: MessageActionsModal( message: Message( text: 'test', @@ -32,40 +38,45 @@ void main() { id: 'user-id', ), ), + messageWidget: const Text( + 'test', + key: Key('MessageWidget'), + ), messageTheme: streamTheme.ownMessageTheme, ), ), ), ), ); - await tester.pump(); + await tester.pumpAndSettle(); - await tester.pump(Duration(milliseconds: 1000)); - expect(find.byKey(Key('MessageWidget')), findsOneWidget); + expect(find.byKey(const Key('MessageWidget')), findsOneWidget); expect(find.text('Thread Reply'), findsOneWidget); + expect(find.text('Reply'), findsOneWidget); expect(find.text('Edit Message'), findsOneWidget); expect(find.text('Delete Message'), findsOneWidget); expect(find.text('Copy Message'), findsOneWidget); }, ); + testWidgets( 'it should show some actions', (WidgetTester tester) async { final client = MockClient(); final clientState = MockClientState(); - when(client.state).thenReturn(clientState); - when(clientState.user).thenReturn(OwnUser(id: 'user-id')); + when(() => client.state).thenReturn(clientState); + when(() => clientState.user).thenReturn(OwnUser(id: 'user-id')); final themeData = ThemeData(); - final streamTheme = StreamChatThemeData.getDefaultTheme(themeData); + final streamTheme = StreamChatThemeData.fromTheme(themeData); await tester.pumpWidget( MaterialApp( theme: themeData, home: StreamChat( streamChatThemeData: streamTheme, client: client, - child: Container( + child: SizedBox( child: MessageActionsModal( showEditMessage: false, showCopyMessage: false, @@ -79,15 +90,18 @@ void main() { ), ), messageTheme: streamTheme.ownMessageTheme, + messageWidget: const Text( + 'test', + key: Key('MessageWidget'), + ), ), ), ), ), ); - await tester.pump(); + await tester.pumpAndSettle(); - await tester.pump(Duration(milliseconds: 1000)); - expect(find.byKey(Key('MessageWidget')), findsOneWidget); + expect(find.byKey(const Key('MessageWidget')), findsOneWidget); expect(find.text('Reply'), findsNothing); expect(find.text('Thread reply'), findsNothing); expect(find.text('Edit message'), findsNothing); @@ -95,4 +109,676 @@ void main() { expect(find.text('Copy message'), findsNothing); }, ); + + testWidgets( + 'it should show custom actions', + (WidgetTester tester) async { + final client = MockClient(); + final clientState = MockClientState(); + + when(() => client.state).thenReturn(clientState); + when(() => clientState.user).thenReturn(OwnUser(id: 'user-id')); + + final themeData = ThemeData(); + final streamTheme = StreamChatThemeData.fromTheme(themeData); + + var tapped = false; + await tester.pumpWidget( + MaterialApp( + theme: themeData, + home: StreamChat( + streamChatThemeData: streamTheme, + client: client, + child: SizedBox( + child: MessageActionsModal( + messageWidget: const Text('test'), + message: Message( + text: 'test', + user: User( + id: 'user-id', + ), + ), + messageTheme: streamTheme.ownMessageTheme, + customActions: [ + MessageAction( + leading: const Icon(Icons.check), + title: const Text('title'), + onTap: (m) { + tapped = true; + }, + ), + ], + ), + ), + ), + ), + ); + + await tester.pumpAndSettle(); + + expect(find.byIcon(Icons.check), findsOneWidget); + expect(find.text('title'), findsOneWidget); + + await tester.tap(find.text('title')); + + expect(tapped, true); + }, + ); + + testWidgets( + 'tapping on reply should call the callback', + (WidgetTester tester) async { + final client = MockClient(); + final clientState = MockClientState(); + + when(() => client.state).thenReturn(clientState); + when(() => clientState.user).thenReturn(OwnUser(id: 'user-id')); + + final themeData = ThemeData(); + final streamTheme = StreamChatThemeData.fromTheme(themeData); + + var tapped = false; + + await tester.pumpWidget( + MaterialApp( + theme: themeData, + home: StreamChat( + streamChatThemeData: streamTheme, + client: client, + child: SizedBox( + child: MessageActionsModal( + messageWidget: const Text('test'), + onReplyTap: (m) { + tapped = true; + }, + message: Message( + text: 'test', + user: User( + id: 'user-id', + ), + ), + messageTheme: streamTheme.ownMessageTheme, + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Reply')); + + expect(tapped, true); + }, + ); + + testWidgets( + 'tapping on thread reply should call the callback', + (WidgetTester tester) async { + final client = MockClient(); + final clientState = MockClientState(); + + when(() => client.state).thenReturn(clientState); + when(() => clientState.user).thenReturn(OwnUser(id: 'user-id')); + + final themeData = ThemeData(); + final streamTheme = StreamChatThemeData.fromTheme(themeData); + + var tapped = false; + + await tester.pumpWidget( + MaterialApp( + theme: themeData, + home: StreamChat( + streamChatThemeData: streamTheme, + client: client, + child: SizedBox( + child: MessageActionsModal( + messageWidget: const Text('test'), + onThreadReplyTap: (m) { + tapped = true; + }, + message: Message( + text: 'test', + user: User( + id: 'user-id', + ), + ), + messageTheme: streamTheme.ownMessageTheme, + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Thread Reply')); + + expect(tapped, true); + }, + ); + + testWidgets( + 'tapping on edit should show the edit bottom sheet', + (WidgetTester tester) async { + final client = MockClient(); + final clientState = MockClientState(); + final channel = MockChannel(); + + when(() => client.state).thenReturn(clientState); + when(() => clientState.user).thenReturn(OwnUser(id: 'user-id')); + + final themeData = ThemeData(); + final streamTheme = StreamChatThemeData.fromTheme(themeData); + + await tester.pumpWidget( + MaterialApp( + builder: (context, child) => StreamChat( + client: client, + streamChatThemeData: streamTheme, + child: child, + ), + theme: themeData, + home: StreamChannel( + showLoading: false, + channel: channel, + child: SizedBox( + child: MessageActionsModal( + messageWidget: const Text('test'), + message: Message( + text: 'test', + user: User( + id: 'user-id', + ), + ), + messageTheme: streamTheme.ownMessageTheme, + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Edit Message')); + + await tester.pumpAndSettle(); + + expect(find.byType(MessageInput), findsOneWidget); + }, + ); + + testWidgets( + 'tapping on edit should show use the custom builder', + (WidgetTester tester) async { + final client = MockClient(); + final clientState = MockClientState(); + final channel = MockChannel(); + + when(() => client.state).thenReturn(clientState); + when(() => clientState.user).thenReturn(OwnUser(id: 'user-id')); + + final themeData = ThemeData(); + final streamTheme = StreamChatThemeData.fromTheme(themeData); + + await tester.pumpWidget( + MaterialApp( + builder: (context, child) => StreamChat( + client: client, + streamChatThemeData: streamTheme, + child: child, + ), + theme: themeData, + home: StreamChannel( + showLoading: false, + channel: channel, + child: SizedBox( + child: MessageActionsModal( + messageWidget: const Text('test'), + editMessageInputBuilder: (context, m) => const Text('test'), + message: Message( + text: 'test', + user: User( + id: 'user-id', + ), + ), + messageTheme: streamTheme.ownMessageTheme, + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Edit Message')); + + await tester.pumpAndSettle(); + + expect(find.text('test'), findsOneWidget); + }, + ); + + testWidgets( + 'tapping on copy should use the callback', + (WidgetTester tester) async { + final client = MockClient(); + final clientState = MockClientState(); + final channel = MockChannel(); + + when(() => client.state).thenReturn(clientState); + when(() => clientState.user).thenReturn(OwnUser(id: 'user-id')); + + final themeData = ThemeData(); + final streamTheme = StreamChatThemeData.fromTheme(themeData); + + var tapped = false; + + await tester.pumpWidget( + MaterialApp( + builder: (context, child) => StreamChat( + client: client, + streamChatThemeData: streamTheme, + child: child, + ), + theme: themeData, + home: StreamChannel( + showLoading: false, + channel: channel, + child: SizedBox( + child: MessageActionsModal( + messageWidget: const Text('test'), + onCopyTap: (m) => tapped = true, + message: Message( + text: 'test', + user: User( + id: 'user-id', + ), + ), + messageTheme: streamTheme.ownMessageTheme, + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Copy Message')); + + expect(tapped, true); + }, + ); + + testWidgets( + 'tapping on resend should call send message', + (WidgetTester tester) async { + final client = MockClient(); + final clientState = MockClientState(); + final channel = MockChannel(); + + when(() => client.state).thenReturn(clientState); + when(() => clientState.user).thenReturn(OwnUser(id: 'user-id')); + when(() => channel.sendMessage(any())) + .thenAnswer((_) async => SendMessageResponse()); + + final themeData = ThemeData(); + final streamTheme = StreamChatThemeData.fromTheme(themeData); + + await tester.pumpWidget( + MaterialApp( + builder: (context, child) => StreamChat( + client: client, + streamChatThemeData: streamTheme, + child: child, + ), + theme: themeData, + home: StreamChannel( + showLoading: false, + channel: channel, + child: SizedBox( + child: MessageActionsModal( + messageWidget: const Text('test'), + message: Message( + status: MessageSendingStatus.failed, + text: 'test', + user: User( + id: 'user-id', + ), + ), + messageTheme: streamTheme.ownMessageTheme, + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Resend')); + + verify(() => channel.sendMessage(any())).called(1); + }, + ); + + testWidgets( + 'tapping on resend should call update message if editing the message', + (WidgetTester tester) async { + final client = MockClient(); + final clientState = MockClientState(); + final channel = MockChannel(); + + when(() => client.state).thenReturn(clientState); + when(() => clientState.user).thenReturn(OwnUser(id: 'user-id')); + when(() => channel.updateMessage(any())) + .thenAnswer((_) async => UpdateMessageResponse()); + + final themeData = ThemeData(); + final streamTheme = StreamChatThemeData.fromTheme(themeData); + + await tester.pumpWidget( + MaterialApp( + builder: (context, child) => StreamChat( + client: client, + streamChatThemeData: streamTheme, + child: child, + ), + theme: themeData, + home: StreamChannel( + showLoading: false, + channel: channel, + child: SizedBox( + child: MessageActionsModal( + messageWidget: const Text('test'), + message: Message( + status: MessageSendingStatus.failed_update, + text: 'test', + user: User( + id: 'user-id', + ), + ), + messageTheme: streamTheme.ownMessageTheme, + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Resend Edited Message')); + + verify(() => channel.updateMessage(any())).called(1); + }, + ); + + testWidgets( + 'tapping on flag message should show the dialog', + (WidgetTester tester) async { + final client = MockClient(); + final clientState = MockClientState(); + final channel = MockChannel(); + + when(() => client.state).thenReturn(clientState); + when(() => clientState.user).thenReturn(OwnUser(id: 'user-id')); + + final themeData = ThemeData(); + final streamTheme = StreamChatThemeData.fromTheme(themeData); + + await tester.pumpWidget( + MaterialApp( + builder: (context, child) => StreamChat( + client: client, + streamChatThemeData: streamTheme, + child: child, + ), + theme: themeData, + home: StreamChannel( + showLoading: false, + channel: channel, + child: SizedBox( + child: MessageActionsModal( + messageWidget: const Text('test'), + message: Message( + id: 'testid', + text: 'test', + user: User( + id: 'user-id', + ), + ), + messageTheme: streamTheme.ownMessageTheme, + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Flag Message')); + await tester.pumpAndSettle(); + + expect(find.text('Flag Message'), findsNWidgets(2)); + + await tester.tap(find.text('FLAG')); + await tester.pumpAndSettle(); + + verify(() => client.flagMessage('testid')).called(1); + }, + ); + + testWidgets( + 'if flagging a message throws an error the error dialog should appear', + (WidgetTester tester) async { + final client = MockClient(); + final clientState = MockClientState(); + final channel = MockChannel(); + + when(() => client.state).thenReturn(clientState); + when(() => clientState.user).thenReturn(OwnUser(id: 'user-id')); + when(() => client.flagMessage(any())) + .thenThrow(StreamChatNetworkError(ChatErrorCode.internalSystemError)); + + final themeData = ThemeData(); + final streamTheme = StreamChatThemeData.fromTheme(themeData); + + await tester.pumpWidget( + MaterialApp( + builder: (context, child) => StreamChat( + client: client, + streamChatThemeData: streamTheme, + child: child, + ), + theme: themeData, + home: StreamChannel( + showLoading: false, + channel: channel, + child: SizedBox( + child: MessageActionsModal( + messageWidget: const Text('test'), + message: Message( + id: 'testid', + text: 'test', + user: User( + id: 'user-id', + ), + ), + messageTheme: streamTheme.ownMessageTheme, + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Flag Message')); + await tester.pumpAndSettle(); + + expect(find.text('Flag Message'), findsNWidgets(2)); + + await tester.tap(find.text('FLAG')); + await tester.pumpAndSettle(); + + expect(find.text('Something went wrong'), findsOneWidget); + }, + ); + + testWidgets( + 'if flagging an already flagged message no error should appear', + (WidgetTester tester) async { + final client = MockClient(); + final clientState = MockClientState(); + final channel = MockChannel(); + + when(() => client.state).thenReturn(clientState); + when(() => clientState.user).thenReturn(OwnUser(id: 'user-id')); + when(() => client.flagMessage(any())) + .thenThrow(StreamChatNetworkError(ChatErrorCode.inputError)); + + final themeData = ThemeData(); + final streamTheme = StreamChatThemeData.fromTheme(themeData); + + await tester.pumpWidget( + MaterialApp( + builder: (context, child) => StreamChat( + client: client, + streamChatThemeData: streamTheme, + child: child, + ), + theme: themeData, + home: StreamChannel( + showLoading: false, + channel: channel, + child: SizedBox( + child: MessageActionsModal( + messageWidget: const Text('test'), + message: Message( + id: 'testid', + text: 'test', + user: User( + id: 'user-id', + ), + ), + messageTheme: streamTheme.ownMessageTheme, + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Flag Message')); + await tester.pumpAndSettle(); + + expect(find.text('Flag Message'), findsNWidgets(2)); + + await tester.tap(find.text('FLAG')); + await tester.pumpAndSettle(); + + expect(find.text('Message flagged'), findsOneWidget); + }, + ); + + testWidgets( + 'tapping on delete message should call client.delete', + (WidgetTester tester) async { + final client = MockClient(); + final clientState = MockClientState(); + final channel = MockChannel(); + + when(() => client.state).thenReturn(clientState); + when(() => clientState.user).thenReturn(OwnUser(id: 'user-id')); + + final themeData = ThemeData(); + final streamTheme = StreamChatThemeData.fromTheme(themeData); + + await tester.pumpWidget( + MaterialApp( + builder: (context, child) => StreamChat( + client: client, + streamChatThemeData: streamTheme, + child: child, + ), + theme: themeData, + home: StreamChannel( + showLoading: false, + channel: channel, + child: SizedBox( + child: MessageActionsModal( + messageWidget: const Text('test'), + message: Message( + id: 'testid', + text: 'test', + user: User( + id: 'user-id', + ), + ), + messageTheme: streamTheme.ownMessageTheme, + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Delete Message')); + await tester.pumpAndSettle(); + + expect(find.text('Delete message'), findsOneWidget); + + await tester.tap(find.text('DELETE')); + await tester.pumpAndSettle(); + + verify(() => channel.deleteMessage(any())).called(1); + }, + ); + + testWidgets( + 'tapping on delete message should call client.delete', + (WidgetTester tester) async { + final client = MockClient(); + final clientState = MockClientState(); + final channel = MockChannel(); + + when(() => client.state).thenReturn(clientState); + when(() => clientState.user).thenReturn(OwnUser(id: 'user-id')); + when(() => channel.deleteMessage(any())) + .thenThrow(StreamChatNetworkError(ChatErrorCode.internalSystemError)); + + final themeData = ThemeData(); + final streamTheme = StreamChatThemeData.fromTheme(themeData); + + await tester.pumpWidget( + MaterialApp( + builder: (context, child) => StreamChat( + client: client, + streamChatThemeData: streamTheme, + child: child, + ), + theme: themeData, + home: StreamChannel( + showLoading: false, + channel: channel, + child: SizedBox( + child: MessageActionsModal( + messageWidget: const Text('test'), + message: Message( + id: 'testid', + text: 'test', + user: User( + id: 'user-id', + ), + ), + messageTheme: streamTheme.ownMessageTheme, + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Delete Message')); + await tester.pumpAndSettle(); + + expect(find.text('Delete message'), findsOneWidget); + + await tester.tap(find.text('DELETE')); + await tester.pumpAndSettle(); + + expect(find.text('Something went wrong'), findsOneWidget); + }, + ); } diff --git a/packages/stream_chat_flutter/test/src/message_input_test.dart b/packages/stream_chat_flutter/test/src/message_input_test.dart new file mode 100644 index 00000000..f07f518d --- /dev/null +++ b/packages/stream_chat_flutter/test/src/message_input_test.dart @@ -0,0 +1,72 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; + +import 'mocks.dart'; + +void main() { + testWidgets( + 'checks message input features', + (WidgetTester tester) async { + final client = MockClient(); + final clientState = MockClientState(); + final channel = MockChannel(); + final channelState = MockChannelState(); + final lastMessageAt = DateTime.parse('2020-06-22 12:00:00'); + + when(() => client.state).thenReturn(clientState); + when(() => clientState.user).thenReturn(OwnUser(id: 'user-id')); + when(() => channel.lastMessageAt).thenReturn(lastMessageAt); + when(() => channel.state).thenReturn(channelState); + when(() => channel.client).thenReturn(client); + when(() => channel.isMuted).thenReturn(false); + when(() => channel.isMutedStream).thenAnswer((i) => Stream.value(false)); + when(() => channel.extraDataStream).thenAnswer((i) => Stream.value({ + 'name': 'test', + })); + when(() => channel.extraData).thenReturn({ + 'name': 'test', + }); + when(() => channelState.membersStream).thenAnswer((i) => Stream.value([ + Member( + userId: 'user-id', + user: User(id: 'user-id'), + ) + ])); + when(() => channelState.members).thenReturn([ + Member( + userId: 'user-id', + user: User(id: 'user-id'), + ), + ]); + when(() => channelState.messages).thenReturn([ + Message( + text: 'hello', + user: User(id: 'other-user'), + ) + ]); + when(() => channelState.messagesStream).thenAnswer((i) => Stream.value([ + Message( + text: 'hello', + user: User(id: 'other-user'), + ) + ])); + + await tester.pumpWidget(MaterialApp( + home: StreamChat( + client: client, + child: StreamChannel( + channel: channel, + child: const Scaffold( + body: MessageInput(), + ), + ), + ), + )); + + expect(find.byType(TextField), findsOneWidget); + expect(find.byKey(const Key('messageInputText')), findsOneWidget); + }, + ); +} diff --git a/packages/stream_chat_flutter/test/src/message_reaction_modal_test.dart b/packages/stream_chat_flutter/test/src/message_reaction_modal_test.dart deleted file mode 100644 index 4e515a09..00000000 --- a/packages/stream_chat_flutter/test/src/message_reaction_modal_test.dart +++ /dev/null @@ -1,102 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:mockito/mockito.dart'; -import 'package:stream_chat_flutter/src/message_reactions_modal.dart'; -import 'package:stream_chat_flutter/stream_chat_flutter.dart'; - -import 'mocks.dart'; - -void main() { - testWidgets( - 'it should show one thumbs from the picker', - (WidgetTester tester) async { - final client = MockClient(); - final clientState = MockClientState(); - final themeData = ThemeData(); - - when(client.state).thenReturn(clientState); - when(clientState.user).thenReturn(OwnUser(id: 'user-id')); - - final streamTheme = StreamChatThemeData.getDefaultTheme(themeData); - - await tester.pumpWidget( - MaterialApp( - theme: themeData, - home: StreamChat( - client: client, - streamChatThemeData: streamTheme, - child: MessageReactionsModal( - message: Message( - id: 'test', - text: 'test message', - user: User( - id: 'test-user', - ), - ), - messageTheme: streamTheme.ownMessageTheme, - ), - ), - ), - ); - - await tester.pump(Duration(milliseconds: 1000)); - - expect(find.byKey(Key('MessageWidget')), findsOneWidget); - expect(find.byKey(Key('StreamSvgIcon-Icon_thumbs_up_reaction.svg')), - findsOneWidget); - }, - ); - - testWidgets( - 'it should show two reactions', - (WidgetTester tester) async { - final client = MockClient(); - final clientState = MockClientState(); - - when(client.state).thenReturn(clientState); - when(clientState.user).thenReturn(OwnUser(id: 'user-id')); - - final themeData = ThemeData(); - final streamTheme = StreamChatThemeData.getDefaultTheme(themeData); - final testUserId = 'test user'; - - await tester.pumpWidget( - MaterialApp( - theme: themeData, - home: StreamChat( - streamChatThemeData: streamTheme, - client: client, - child: MessageReactionsModal( - message: Message( - text: 'test message', - user: User( - id: 'test-user', - ), - latestReactions: [ - Reaction( - type: 'like', - user: User(id: testUserId), - ), - Reaction( - type: 'love', - user: User(id: testUserId), - ), - ], - ), - messageTheme: streamTheme.ownMessageTheme, - ), - ), - ), - ); - await tester.pump(); - - await tester.pump(Duration(milliseconds: 1000)); - expect(find.byKey(Key('MessageWidget')), findsOneWidget); - expect(find.byKey(Key('StreamSvgIcon-Icon_thumbs_up_reaction.svg')), - findsNWidgets(2)); - expect(find.byKey(Key('StreamSvgIcon-Icon_love_reaction.svg')), - findsNWidgets(2)); - expect(find.text(testUserId.split(' ')[0]), findsNWidgets(2)); - }, - ); -} diff --git a/packages/stream_chat_flutter/test/src/message_reactions_modal_test.dart b/packages/stream_chat_flutter/test/src/message_reactions_modal_test.dart new file mode 100644 index 00000000..9452e7e6 --- /dev/null +++ b/packages/stream_chat_flutter/test/src/message_reactions_modal_test.dart @@ -0,0 +1,115 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:stream_chat_flutter/src/message_reactions_modal.dart'; +import 'package:stream_chat_flutter/src/reaction_bubble.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; + +import 'mocks.dart'; + +void main() { + testWidgets( + 'control test', + (WidgetTester tester) async { + final client = MockClient(); + final clientState = MockClientState(); + final themeData = ThemeData(); + + when(() => client.state).thenReturn(clientState); + when(() => clientState.user).thenReturn(OwnUser(id: 'user-id')); + + final streamTheme = StreamChatThemeData.fromTheme(themeData); + + final message = Message( + id: 'test', + text: 'test message', + user: User( + id: 'test-user', + ), + ); + await tester.pumpWidget( + MaterialApp( + theme: themeData, + home: StreamChat( + client: client, + streamChatThemeData: streamTheme, + child: MessageReactionsModal( + messageWidget: const Text( + 'test', + key: Key('MessageWidget'), + ), + message: message, + messageTheme: streamTheme.ownMessageTheme, + ), + ), + ), + ); + + await tester.pump(const Duration(milliseconds: 1000)); + + expect(find.byType(ReactionBubble), findsNothing); + + expect(find.byType(UserAvatar), findsNothing); + }, + ); + + testWidgets( + 'it should apply passed parameters', + (WidgetTester tester) async { + final client = MockClient(); + final clientState = MockClientState(); + final themeData = ThemeData(); + + when(() => client.state).thenReturn(clientState); + when(() => clientState.user).thenReturn(OwnUser(id: 'user-id')); + + final streamTheme = StreamChatThemeData.fromTheme(themeData); + + final message = Message( + id: 'test', + text: 'test message', + user: User( + id: 'test-user', + ), + latestReactions: [ + Reaction( + messageId: 'test', + user: User(id: 'testid'), + type: 'test', + ), + ], + ); + + // ignore: prefer_function_declarations_over_variables + final onUserAvatarTap = (u) => print('ok'); + + await tester.pumpWidget( + MaterialApp( + theme: themeData, + home: StreamChat( + client: client, + streamChatThemeData: streamTheme, + child: MessageReactionsModal( + messageWidget: const Text( + 'test', + key: Key('MessageWidget'), + ), + message: message, + messageTheme: streamTheme.ownMessageTheme, + reverse: true, + showReactions: false, + onUserAvatarTap: onUserAvatarTap, + ), + ), + ), + ); + + await tester.pump(const Duration(milliseconds: 1000)); + + expect(find.byKey(const Key('MessageWidget')), findsOneWidget); + + expect(find.byType(ReactionBubble), findsOneWidget); + expect(find.byType(UserAvatar), findsOneWidget); + }, + ); +} diff --git a/packages/stream_chat_flutter/test/src/message_text_test.dart b/packages/stream_chat_flutter/test/src/message_text_test.dart new file mode 100644 index 00000000..f64e2bf1 --- /dev/null +++ b/packages/stream_chat_flutter/test/src/message_text_test.dart @@ -0,0 +1,110 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_markdown/flutter_markdown.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:golden_toolkit/golden_toolkit.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; + +import 'mocks.dart'; +import 'simple_frame.dart'; + +void main() { + testWidgets( + 'it should show correct message text', + (WidgetTester tester) async { + final client = MockClient(); + final clientState = MockClientState(); + final channel = MockChannel(); + final channelState = MockChannelState(); + final lastMessageAt = DateTime.parse('2020-06-22 12:00:00'); + final themeData = ThemeData(); + final streamTheme = StreamChatThemeData.fromTheme(themeData); + + when(() => client.state).thenReturn(clientState); + when(() => clientState.user).thenReturn(OwnUser(id: 'user-id')); + when(() => channel.lastMessageAt).thenReturn(lastMessageAt); + when(() => channel.state).thenReturn(channelState); + when(() => channel.client).thenReturn(client); + when(() => channel.isMuted).thenReturn(false); + when(() => channel.isMutedStream).thenAnswer((i) => Stream.value(false)); + when(() => channel.extraDataStream).thenAnswer((i) => Stream.value({ + 'name': 'test', + })); + when(() => channel.extraData).thenReturn({ + 'name': 'test', + }); + + await tester.pumpWidget(MaterialApp( + home: StreamChat( + client: client, + child: StreamChannel( + channel: channel, + child: Scaffold( + body: MessageText( + message: Message( + text: 'demo', + ), + messageTheme: streamTheme.otherMessageTheme), + ), + ), + ), + )); + + expect(find.byType(MarkdownBody), findsOneWidget); + }, + ); + + testGoldens( + 'control test', + (WidgetTester tester) async { + final client = MockClient(); + final clientState = MockClientState(); + final channel = MockChannel(); + final channelState = MockChannelState(); + final lastMessageAt = DateTime.parse('2020-06-22 12:00:00'); + final themeData = ThemeData(); + final streamTheme = StreamChatThemeData.fromTheme(themeData); + + when(() => client.state).thenReturn(clientState); + when(() => clientState.user).thenReturn(OwnUser(id: 'user-id')); + when(() => channel.lastMessageAt).thenReturn(lastMessageAt); + when(() => channel.state).thenReturn(channelState); + when(() => channel.client).thenReturn(client); + when(() => channel.isMuted).thenReturn(false); + when(() => channel.isMutedStream).thenAnswer((i) => Stream.value(false)); + when(() => channel.extraDataStream).thenAnswer((i) => Stream.value({ + 'name': 'test', + })); + when(() => channel.extraData).thenReturn({ + 'name': 'test', + }); + + const messageText = ''' + a message. +with multiple lines +and a list: +- a. okasd +- b lllll + +cool.'''; + + await tester.pumpWidgetBuilder( + materialAppWrapper()(SimpleFrame( + child: StreamChannel( + channel: channel, + child: Scaffold( + body: MessageText( + message: Message( + text: messageText, + ), + messageTheme: streamTheme.otherMessageTheme, + ), + ), + ), + )), + surfaceSize: const Size(500, 500), + ); + await screenMatchesGolden(tester, 'message_text'); + }, + ); +} diff --git a/packages/stream_chat_flutter/test/src/mocks.dart b/packages/stream_chat_flutter/test/src/mocks.dart index 0abbf3ca..28e00e4b 100644 --- a/packages/stream_chat_flutter/test/src/mocks.dart +++ b/packages/stream_chat_flutter/test/src/mocks.dart @@ -1,10 +1,35 @@ -import 'package:mockito/mockito.dart'; +import 'package:flutter/material.dart'; +import 'package:mocktail/mocktail.dart'; import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart'; -class MockClient extends Mock implements StreamChatClient {} +class MockClient extends Mock implements StreamChatClient { + MockClient() { + when(() => wsConnectionStatus).thenReturn(ConnectionStatus.connected); + } +} class MockClientState extends Mock implements ClientState {} -class MockChannel extends Mock implements Channel {} +class MockChannel extends Mock implements Channel { + @override + Future get initialized async => true; -class MockChannelState extends Mock implements ChannelClientState {} + @override + // ignore: prefer_expression_function_bodies + Future keyStroke([String? parentId]) async { + return; + } +} + +class MockChannelState extends Mock implements ChannelClientState { + MockChannelState() { + when(() => typingEvents).thenReturn({}); + when(() => typingEventsStream).thenAnswer((_) => Stream.value({})); + } +} + +class MockNavigatorObserver extends Mock implements NavigatorObserver {} + +class MockVoidCallback extends Mock { + void call(); +} diff --git a/packages/stream_chat_flutter/test/src/reaction_bubble_test.dart b/packages/stream_chat_flutter/test/src/reaction_bubble_test.dart index 0b6c90ef..1e3c9f5e 100644 --- a/packages/stream_chat_flutter/test/src/reaction_bubble_test.dart +++ b/packages/stream_chat_flutter/test/src/reaction_bubble_test.dart @@ -1,20 +1,22 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:mockito/mockito.dart'; +import 'package:golden_toolkit/golden_toolkit.dart'; +import 'package:mocktail/mocktail.dart'; import 'package:stream_chat_flutter/src/reaction_bubble.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; import 'mocks.dart'; +import 'simple_frame.dart'; void main() { - testWidgets( + testGoldens( 'it should show no reactions', (WidgetTester tester) async { - await tester.pumpWidget( - MaterialApp( - home: StreamChatTheme( + await tester.pumpWidgetBuilder( + SimpleFrame( + child: StreamChatTheme( data: StreamChatThemeData(), - child: Container( + child: const SizedBox( child: ReactionBubble( reactions: [], borderColor: Colors.black, @@ -24,91 +26,219 @@ void main() { ), ), ), + surfaceSize: const Size(100, 100), ); - - expect(find.byKey(Key('StreamSvgIcon-Icon_thumbs_up_reaction.svg')), - findsNothing); + await screenMatchesGolden(tester, 'reaction_bubble_0'); }, ); - testWidgets( - 'it should show a like', + testGoldens( + 'it should show a like - light theme', + (WidgetTester tester) async { + final client = MockClient(); + final clientState = MockClientState(); + final themeData = ThemeData.light(); + + when(() => client.state).thenReturn(clientState); + when(() => clientState.user).thenReturn(OwnUser(id: 'user-id')); + + final theme = StreamChatThemeData.fromTheme(themeData); + await tester.pumpWidgetBuilder( + StreamChat( + client: client, + streamChatThemeData: theme, + connectivityStream: Stream.value(ConnectivityResult.mobile), + child: SizedBox( + child: ReactionBubble( + reactions: [ + Reaction( + type: 'like', + user: User(id: 'test'), + ), + ], + borderColor: theme.ownMessageTheme.reactionsBorderColor!, + backgroundColor: theme.ownMessageTheme.reactionsBackgroundColor!, + maskColor: theme.ownMessageTheme.reactionsMaskColor!, + ), + ), + ), + surfaceSize: const Size(100, 100), + ); + await screenMatchesGolden(tester, 'reaction_bubble_like_light'); + }, + ); + + testGoldens( + 'it should show a like - dark theme', + (WidgetTester tester) async { + final client = MockClient(); + final clientState = MockClientState(); + final themeData = ThemeData.dark(); + final theme = StreamChatThemeData.fromTheme(themeData); + + when(() => client.state).thenReturn(clientState); + when(() => clientState.user).thenReturn(OwnUser(id: 'user-id')); + + await tester.pumpWidgetBuilder( + StreamChat( + client: client, + streamChatThemeData: StreamChatThemeData.fromTheme(themeData), + connectivityStream: Stream.value(ConnectivityResult.mobile), + child: Container( + color: Colors.black, + child: ReactionBubble( + reactions: [ + Reaction( + type: 'like', + user: User(id: 'test'), + ), + ], + borderColor: theme.ownMessageTheme.reactionsBorderColor!, + backgroundColor: theme.ownMessageTheme.reactionsBackgroundColor!, + maskColor: theme.ownMessageTheme.reactionsMaskColor!, + ), + ), + ), + surfaceSize: const Size(100, 100), + ); + await screenMatchesGolden(tester, 'reaction_bubble_like_dark'); + }, + ); + + testGoldens( + 'it should show three reactions - light theme', + (WidgetTester tester) async { + final client = MockClient(); + final clientState = MockClientState(); + final themeData = ThemeData.light(); + final theme = StreamChatThemeData.fromTheme(themeData); + + when(() => client.state).thenReturn(clientState); + when(() => clientState.user).thenReturn(OwnUser(id: 'user-id')); + + await tester.pumpWidgetBuilder( + StreamChat( + client: client, + streamChatThemeData: StreamChatThemeData.fromTheme(themeData), + connectivityStream: Stream.value(ConnectivityResult.mobile), + child: Container( + color: Colors.black, + child: ReactionBubble( + reactions: [ + Reaction( + type: 'like', + user: User(id: 'test'), + ), + Reaction( + type: 'like', + user: User(id: 'user-id'), + ), + Reaction( + type: 'like', + user: User(id: 'test'), + ), + ], + borderColor: theme.ownMessageTheme.reactionsBorderColor!, + backgroundColor: theme.ownMessageTheme.reactionsBackgroundColor!, + maskColor: theme.ownMessageTheme.reactionsMaskColor!, + ), + ), + ), + surfaceSize: const Size(140, 140), + ); + await screenMatchesGolden(tester, 'reaction_bubble_3_light'); + }, + ); + + testGoldens( + 'it should show three reactions - dark theme', + (WidgetTester tester) async { + final client = MockClient(); + final clientState = MockClientState(); + final themeData = ThemeData.dark(); + final theme = StreamChatThemeData.fromTheme(themeData); + + when(() => client.state).thenReturn(clientState); + when(() => clientState.user).thenReturn(OwnUser(id: 'user-id')); + + await tester.pumpWidgetBuilder( + StreamChat( + client: client, + streamChatThemeData: StreamChatThemeData.fromTheme(themeData), + connectivityStream: Stream.value(ConnectivityResult.mobile), + child: Container( + color: Colors.black, + child: ReactionBubble( + reactions: [ + Reaction( + type: 'like', + user: User(id: 'test'), + ), + Reaction( + type: 'like', + user: User(id: 'user-id'), + ), + Reaction( + type: 'like', + user: User(id: 'test'), + ), + ], + borderColor: theme.ownMessageTheme.reactionsBorderColor!, + backgroundColor: theme.ownMessageTheme.reactionsBackgroundColor!, + maskColor: theme.ownMessageTheme.reactionsMaskColor!, + ), + ), + ), + surfaceSize: const Size(140, 140), + ); + await screenMatchesGolden(tester, 'reaction_bubble_3_dark'); + }, + ); + + testGoldens( + 'it should show two reactions with customized ui', (WidgetTester tester) async { final client = MockClient(); final clientState = MockClientState(); final themeData = ThemeData(); - when(client.state).thenReturn(clientState); - when(clientState.user).thenReturn(OwnUser(id: 'user-id')); + when(() => client.state).thenReturn(clientState); + when(() => clientState.user).thenReturn(OwnUser(id: 'user-id')); - await tester.pumpWidget( - MaterialApp( - theme: themeData, - home: StreamChat( - client: client, - streamChatThemeData: StreamChatThemeData.getDefaultTheme(themeData), - child: Container( - child: ReactionBubble( - reactions: [ - Reaction( - type: 'like', - user: User(id: 'test'), - ), - ], - borderColor: Colors.black, - backgroundColor: Colors.white, - maskColor: Colors.white, - ), + await tester.pumpWidgetBuilder( + StreamChat( + client: client, + connectivityStream: Stream.value(ConnectivityResult.mobile), + streamChatThemeData: StreamChatThemeData.fromTheme(themeData), + child: SizedBox( + child: ReactionBubble( + reactions: [ + Reaction( + type: 'like', + user: User(id: 'test'), + ), + Reaction( + type: 'love', + user: User(id: 'user-id'), + ), + Reaction( + type: 'unknown', + user: User(id: 'test'), + ), + ], + borderColor: Colors.red, + backgroundColor: Colors.blue, + maskColor: Colors.green, + reverse: true, + flipTail: true, + tailCirclesSpacing: 4, ), ), ), + surfaceSize: const Size(200, 200), ); - expect(find.byKey(Key('StreamSvgIcon-Icon_thumbs_up_reaction.svg')), - findsOneWidget); - }, - ); - testWidgets( - 'it should show two reactions', - (WidgetTester tester) async { - final client = MockClient(); - final clientState = MockClientState(); - final themeData = ThemeData(); - - when(client.state).thenReturn(clientState); - when(clientState.user).thenReturn(OwnUser(id: 'user-id')); - - await tester.pumpWidget( - MaterialApp( - theme: themeData, - home: StreamChat( - client: client, - streamChatThemeData: StreamChatThemeData.getDefaultTheme(themeData), - child: Container( - child: ReactionBubble( - reactions: [ - Reaction( - type: 'like', - user: User(id: 'test'), - ), - Reaction( - type: 'love', - user: User(id: 'test'), - ), - ], - borderColor: Colors.black, - backgroundColor: Colors.white, - maskColor: Colors.white, - ), - ), - ), - ), - ); - - expect(find.byKey(Key('StreamSvgIcon-Icon_thumbs_up_reaction.svg')), - findsOneWidget); - expect(find.byKey(Key('StreamSvgIcon-Icon_love_reaction.svg')), - findsOneWidget); + await screenMatchesGolden(tester, 'reaction_bubble_2'); }, ); } diff --git a/packages/stream_chat_flutter/test/src/simple_frame.dart b/packages/stream_chat_flutter/test/src/simple_frame.dart new file mode 100644 index 00000000..0633a3b8 --- /dev/null +++ b/packages/stream_chat_flutter/test/src/simple_frame.dart @@ -0,0 +1,17 @@ +import 'package:flutter/material.dart'; + +class SimpleFrame extends StatelessWidget { + const SimpleFrame({Key? key, required this.child}) : super(key: key); + + final Widget child; + + @override + Widget build(BuildContext context) => Container( + padding: const EdgeInsets.all(4), + decoration: BoxDecoration( + color: const Color(0xFFFFFFFF), + border: Border.all(color: const Color(0xFF9E9E9E)), + ), + child: child, + ); +} diff --git a/packages/stream_chat_flutter/test/src/system_message_test.dart b/packages/stream_chat_flutter/test/src/system_message_test.dart new file mode 100644 index 00000000..2e221d57 --- /dev/null +++ b/packages/stream_chat_flutter/test/src/system_message_test.dart @@ -0,0 +1,164 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:golden_toolkit/golden_toolkit.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; + +import 'mocks.dart'; + +void main() { + testWidgets( + 'it should show total unread count', + (WidgetTester tester) async { + final client = MockClient(); + final clientState = MockClientState(); + final channel = MockChannel(); + final channelState = MockChannelState(); + final lastMessageAt = DateTime.parse('2020-06-22 12:00:00'); + + when(() => client.state).thenReturn(clientState); + when(() => clientState.user).thenReturn(OwnUser(id: 'user-id')); + when(() => channel.lastMessageAt).thenReturn(lastMessageAt); + when(() => channel.state).thenReturn(channelState); + when(() => channel.client).thenReturn(client); + when(() => channel.extraDataStream).thenAnswer((i) => Stream.value({ + 'name': 'test', + })); + when(() => channel.extraData).thenReturn({ + 'name': 'test', + }); + + when(() => clientState.totalUnreadCount).thenReturn(10); + when(() => clientState.totalUnreadCountStream) + .thenAnswer((i) => Stream.value(10)); + + var tapped = false; + + await tester.pumpWidget(MaterialApp( + home: StreamChat( + client: client, + child: StreamChannel( + channel: channel, + child: Scaffold( + body: SystemMessage( + onMessageTap: (m) => tapped = true, + message: Message( + text: 'demo message', + ), + ), + ), + ), + ), + )); + + await tester.tap(find.byType(SystemMessage)); + + expect(find.text('demo message'), findsOneWidget); + expect(tapped, true); + }, + ); + + testGoldens( + 'control golden light', + (WidgetTester tester) async { + final client = MockClient(); + final clientState = MockClientState(); + final channel = MockChannel(); + final channelState = MockChannelState(); + final lastMessageAt = DateTime.parse('2020-06-22 12:00:00'); + + when(() => client.state).thenReturn(clientState); + when(() => clientState.user).thenReturn(OwnUser(id: 'user-id')); + when(() => channel.lastMessageAt).thenReturn(lastMessageAt); + when(() => channel.state).thenReturn(channelState); + when(() => channel.client).thenReturn(client); + when(() => channel.extraDataStream).thenAnswer((i) => Stream.value({ + 'name': 'test', + })); + when(() => channel.extraData).thenReturn({ + 'name': 'test', + }); + + when(() => clientState.totalUnreadCount).thenReturn(10); + when(() => clientState.totalUnreadCountStream) + .thenAnswer((i) => Stream.value(10)); + + await tester.pumpWidgetBuilder( + materialAppWrapper( + theme: ThemeData.light(), + )( + StreamChat( + client: client, + connectivityStream: Stream.value(ConnectivityResult.mobile), + child: StreamChannel( + showLoading: false, + channel: channel, + child: Center( + child: SystemMessage( + message: Message( + text: 'demo message', + ), + ), + ), + ), + ), + ), + surfaceSize: const Size.square(200), + ); + + await screenMatchesGolden(tester, 'system_message_light'); + }, + ); + + testGoldens( + 'control golden dark', + (WidgetTester tester) async { + final client = MockClient(); + final clientState = MockClientState(); + final channel = MockChannel(); + final channelState = MockChannelState(); + final lastMessageAt = DateTime.parse('2020-06-22 12:00:00'); + + when(() => client.state).thenReturn(clientState); + when(() => clientState.user).thenReturn(OwnUser(id: 'user-id')); + when(() => channel.lastMessageAt).thenReturn(lastMessageAt); + when(() => channel.state).thenReturn(channelState); + when(() => channel.client).thenReturn(client); + when(() => channel.extraDataStream).thenAnswer((i) => Stream.value({ + 'name': 'test', + })); + when(() => channel.extraData).thenReturn({ + 'name': 'test', + }); + + when(() => clientState.totalUnreadCount).thenReturn(10); + when(() => clientState.totalUnreadCountStream) + .thenAnswer((i) => Stream.value(10)); + + await tester.pumpWidgetBuilder( + materialAppWrapper( + theme: ThemeData.dark(), + )( + StreamChat( + client: client, + connectivityStream: Stream.value(ConnectivityResult.mobile), + child: StreamChannel( + showLoading: false, + channel: channel, + child: Center( + child: SystemMessage( + message: Message( + text: 'demo message', + ), + ), + ), + ), + ), + ), + surfaceSize: const Size.square(200), + ); + + await screenMatchesGolden(tester, 'system_message_dark'); + }, + ); +} diff --git a/packages/stream_chat_flutter/test/src/thread_header_test.dart b/packages/stream_chat_flutter/test/src/thread_header_test.dart new file mode 100644 index 00000000..2b649be3 --- /dev/null +++ b/packages/stream_chat_flutter/test/src/thread_header_test.dart @@ -0,0 +1,145 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; + +import 'mocks.dart'; + +void main() { + testWidgets( + 'control test', + (WidgetTester tester) async { + final client = MockClient(); + final clientState = MockClientState(); + final channel = MockChannel(); + final channelState = MockChannelState(); + final lastMessageAt = DateTime.parse('2020-06-22 12:00:00'); + + when(() => client.state).thenReturn(clientState); + when(() => clientState.user).thenReturn(OwnUser(id: 'user-id')); + when(() => channel.lastMessageAt).thenReturn(lastMessageAt); + when(() => channel.state).thenReturn(channelState); + when(() => channel.client).thenReturn(client); + when(() => channel.isMuted).thenReturn(false); + when(() => channel.isMutedStream).thenAnswer((i) => Stream.value(false)); + when(() => channel.extraDataStream).thenAnswer((i) => Stream.value({ + 'name': 'test', + })); + when(() => channel.extraData).thenReturn({ + 'name': 'test', + }); + when(() => channelState.unreadCount).thenReturn(1); + when(() => channelState.unreadCountStream) + .thenAnswer((i) => Stream.value(1)); + when(() => channelState.membersStream).thenAnswer((i) => Stream.value([ + Member( + userId: 'user-id', + user: User(id: 'user-id'), + ) + ])); + when(() => channelState.members).thenReturn([ + Member( + userId: 'user-id', + user: User(id: 'user-id'), + ), + ]); + when(() => client.wsConnectionStatusStream) + .thenAnswer((_) => Stream.value(ConnectionStatus.connecting)); + when(() => clientState.totalUnreadCount).thenAnswer((i) => 1); + when(() => clientState.totalUnreadCountStream) + .thenAnswer((i) => Stream.value(1)); + + await tester.pumpWidget(MaterialApp( + home: StreamChat( + client: client, + child: StreamChannel( + channel: channel, + child: Scaffold( + body: ThreadHeader( + parent: Message(), + ), + ), + ), + ), + )); + await tester.pumpAndSettle(); + + expect(find.text('with '), findsOneWidget); + expect(find.byType(ChannelName), findsOneWidget); + expect(find.byType(StreamBackButton), findsOneWidget); + expect(find.text('Thread Reply'), findsOneWidget); + }, + ); + + testWidgets( + 'it should apply passed props', + (WidgetTester tester) async { + final client = MockClient(); + final clientState = MockClientState(); + final channel = MockChannel(); + final channelState = MockChannelState(); + final lastMessageAt = DateTime.parse('2020-06-22 12:00:00'); + + when(() => client.state).thenReturn(clientState); + when(() => clientState.user).thenReturn(OwnUser(id: 'user-id')); + when(() => channel.lastMessageAt).thenReturn(lastMessageAt); + when(() => channel.state).thenReturn(channelState); + when(() => channel.client).thenReturn(client); + when(() => channel.isMuted).thenReturn(false); + when(() => channel.isMutedStream).thenAnswer((i) => Stream.value(false)); + when(() => channel.extraDataStream).thenAnswer((i) => Stream.value({ + 'name': 'test', + })); + when(() => channel.extraData).thenReturn({ + 'name': 'test', + }); + when(() => channelState.unreadCount).thenReturn(1); + when(() => channelState.unreadCountStream) + .thenAnswer((i) => Stream.value(1)); + when(() => channelState.membersStream).thenAnswer((i) => Stream.value([ + Member( + userId: 'user-id', + user: User(id: 'user-id'), + ) + ])); + when(() => channelState.members).thenReturn([ + Member( + userId: 'user-id', + user: User(id: 'user-id'), + ), + ]); + + var tapped = false; + await tester.pumpWidget(MaterialApp( + home: StreamChat( + client: client, + child: StreamChannel( + channel: channel, + child: Scaffold( + body: ThreadHeader( + parent: Message(), + subtitle: const Text('subtitle'), + leading: const Text('leading'), + title: const Text('title'), + onTitleTap: () { + tapped = true; + }, + actions: const [ + Text('action'), + ], + ), + ), + ), + ), + )); + await tester.pumpAndSettle(); + + expect(find.text('title'), findsOneWidget); + await tester.tap(find.text('title')); + expect(tapped, true); + expect(find.text('subtitle'), findsOneWidget); + expect(find.text('action'), findsOneWidget); + expect(find.text('leading'), findsOneWidget); + }, + ); +} diff --git a/packages/stream_chat_flutter/test/src/typing_indicator_test.dart b/packages/stream_chat_flutter/test/src/typing_indicator_test.dart new file mode 100644 index 00000000..6c8a3351 --- /dev/null +++ b/packages/stream_chat_flutter/test/src/typing_indicator_test.dart @@ -0,0 +1,81 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; + +import 'mocks.dart'; + +void main() { + testWidgets( + 'it should show channel typing', + (WidgetTester tester) async { + final client = MockClient(); + final clientState = MockClientState(); + final channel = MockChannel(); + final channelState = MockChannelState(); + final lastMessageAt = DateTime.parse('2020-06-22 12:00:00'); + + when(() => client.state).thenReturn(clientState); + when(() => clientState.user).thenReturn(OwnUser(id: 'user-id')); + when(() => channel.lastMessageAt).thenReturn(lastMessageAt); + when(() => channel.state).thenReturn(channelState); + when(() => channel.client).thenReturn(client); + when(() => channel.isMuted).thenReturn(false); + when(() => channel.isMutedStream).thenAnswer((i) => Stream.value(false)); + when(() => channel.extraDataStream).thenAnswer((i) => Stream.value({ + 'name': 'test', + })); + when(() => channel.extraData).thenReturn({ + 'name': 'test', + }); + when(() => channelState.membersStream).thenAnswer((i) => Stream.value([ + Member( + userId: 'user-id', + user: User(id: 'user-id'), + ) + ])); + when(() => channelState.members).thenReturn([ + Member( + userId: 'user-id', + user: User(id: 'user-id'), + ), + ]); + when(() => channelState.messages).thenReturn([ + Message( + text: 'hello', + user: User(id: 'other-user'), + ) + ]); + when(() => channelState.messagesStream).thenAnswer((i) => Stream.value([ + Message( + text: 'hello', + user: User(id: 'other-user'), + ) + ])); + + when(() => channelState.typingEvents).thenAnswer((i) => { + User(id: 'other-user', extraData: const {'name': 'demo'}): + Event(type: EventType.typingStart), + }); + when(() => channelState.typingEventsStream) + .thenAnswer((i) => Stream.value({ + User(id: 'other-user', extraData: const {'name': 'demo'}): + Event(type: EventType.typingStart), + })); + + await tester.pumpWidget(MaterialApp( + home: StreamChat( + client: client, + child: StreamChannel( + channel: channel, + child: const Scaffold( + body: TypingIndicator(), + ), + ), + ), + )); + + expect(find.byKey(const Key('typings')), findsOneWidget); + }, + ); +} diff --git a/packages/stream_chat_flutter/test/src/unread_indicator_test.dart b/packages/stream_chat_flutter/test/src/unread_indicator_test.dart new file mode 100644 index 00000000..96a6c678 --- /dev/null +++ b/packages/stream_chat_flutter/test/src/unread_indicator_test.dart @@ -0,0 +1,129 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; + +import 'mocks.dart'; + +void main() { + testWidgets( + 'it should show total unread count', + (WidgetTester tester) async { + final client = MockClient(); + final clientState = MockClientState(); + final channel = MockChannel(); + final channelState = MockChannelState(); + final lastMessageAt = DateTime.parse('2020-06-22 12:00:00'); + + when(() => client.state).thenReturn(clientState); + when(() => clientState.user).thenReturn(OwnUser(id: 'user-id')); + when(() => channel.lastMessageAt).thenReturn(lastMessageAt); + when(() => channel.state).thenReturn(channelState); + when(() => channel.client).thenReturn(client); + when(() => channel.extraDataStream).thenAnswer((i) => Stream.value({ + 'name': 'test', + })); + when(() => channel.extraData).thenReturn({ + 'name': 'test', + }); + + when(() => clientState.totalUnreadCount).thenReturn(10); + when(() => clientState.totalUnreadCountStream) + .thenAnswer((i) => Stream.value(10)); + + await tester.pumpWidget(MaterialApp( + home: StreamChat( + client: client, + child: StreamChannel( + channel: channel, + child: const Scaffold( + body: UnreadIndicator(), + ), + ), + ), + )); + + expect(find.text('10'), findsOneWidget); + }, + ); + + testWidgets( + 'it should show nothing if no unread messages', + (WidgetTester tester) async { + final client = MockClient(); + final clientState = MockClientState(); + final channel = MockChannel(); + final channelState = MockChannelState(); + when(() => channel.cid).thenReturn('cid'); + final lastMessageAt = DateTime.parse('2020-06-22 12:00:00'); + + when(() => client.state).thenReturn(clientState); + when(() => clientState.user).thenReturn(OwnUser(id: 'user-id')); + when(() => clientState.channels).thenReturn({ + channel.cid!: channel, + }); + when(() => channel.lastMessageAt).thenReturn(lastMessageAt); + when(() => channel.state).thenReturn(channelState); + when(() => channel.client).thenReturn(client); + when(() => channelState.unreadCount).thenReturn(0); + when(() => channelState.unreadCountStream) + .thenAnswer((i) => Stream.value(0)); + + await tester.pumpWidget(MaterialApp( + home: StreamChat( + client: client, + child: StreamChannel( + channel: channel, + child: Scaffold( + body: UnreadIndicator( + cid: channel.cid, + ), + ), + ), + ), + )); + + expect(find.text('0'), findsNothing); + }, + ); + + testWidgets( + 'it should show 99+ if more than 99 unreads', + (WidgetTester tester) async { + final client = MockClient(); + final clientState = MockClientState(); + final channel = MockChannel(); + when(() => channel.cid).thenReturn('cid'); + final channelState = MockChannelState(); + final lastMessageAt = DateTime.parse('2020-06-22 12:00:00'); + + when(() => client.state).thenReturn(clientState); + when(() => clientState.user).thenReturn(OwnUser(id: 'user-id')); + when(() => clientState.channels).thenReturn({ + channel.cid!: channel, + }); + when(() => channel.lastMessageAt).thenReturn(lastMessageAt); + when(() => channel.state).thenReturn(channelState); + when(() => channel.client).thenReturn(client); + when(() => channelState.unreadCount).thenReturn(100); + when(() => channelState.unreadCountStream) + .thenAnswer((i) => Stream.value(100)); + + await tester.pumpWidget(MaterialApp( + home: StreamChat( + client: client, + child: StreamChannel( + channel: channel, + child: Scaffold( + body: UnreadIndicator( + cid: channel.cid, + ), + ), + ), + ), + )); + + expect(find.text('99+'), findsOneWidget); + }, + ); +} diff --git a/packages/stream_chat_flutter_core/CHANGELOG.md b/packages/stream_chat_flutter_core/CHANGELOG.md index d976c039..2f7abfe2 100644 --- a/packages/stream_chat_flutter_core/CHANGELOG.md +++ b/packages/stream_chat_flutter_core/CHANGELOG.md @@ -1,3 +1,69 @@ +## 2.0.0 + +🛑️ Breaking Changes from `1.5.3` + +- migrate this package to null safety +- `channelsBloc.queryChannels()`, `ChannelListCore` options param/property is removed in favor of individual params/properties + - `options.state` -> bool state + - `options.watch` -> bool watch + - `options.presence` -> bool presence +- `usersBloc.queryUsers()`, `UserListCore` options param/property is removed in favor of individual params/properties + - `options.presence` -> bool presence + +✅ Added + +- Monitor connection using `connectivity_plus` package + +🐞 Fixed + +- Minor fixes +- Performance improvements + +## 2.0.0-nullsafety.9 +- Update llc dependency + +## 2.0.0-nullsafety.8 + +🛑️ Breaking Changes from `2.0.0-nullsafety.7` + +- `channelsBloc.queryChannels()`, `ChannelListCore` options param/property is removed in favor of individual + params/properties + - `options.state` -> bool state + - `options.watch` -> bool watch + - `options.presence` -> bool presence +- `usersBloc.queryUsers()`, `UserListCore` options param/property is removed in favor of individual params/properties + - `options.presence` -> bool presence + +## 2.0.0-nullsafety.7 + +* Fixed a bug with connectivity implementation + +## 2.0.0-nullsafety.6 + +* Update llc dependency +* Minor fixes and improvements + +## 2.0.0-nullsafety.5 + +* Update llc dependency +* Minor fixes and improvements +* Performance improvements +* Monitor connection using `connectivity_plus` package + +## 2.0.0-nullsafety.3 + +* Update llc dependency +* Minor fixes and improvements + +## 2.0.0-nullsafety.2 + +* Fix ChannelsBloc not performing calls if pagination ended + +## 2.0.0-nullsafety.1 + +* Migrate this package to null safety +* Update llc dependency + ## 1.5.3 * Fix ChannelsBloc not performing calls if pagination ended diff --git a/packages/stream_chat_flutter_core/README.md b/packages/stream_chat_flutter_core/README.md index 0bf15559..4aaf27d9 100644 --- a/packages/stream_chat_flutter_core/README.md +++ b/packages/stream_chat_flutter_core/README.md @@ -1,4 +1,4 @@ -# Official Core Flutter SDK for [Stream Chat](https://getstream.io/chat/) +# Official Core [Flutter SDK](https://getstream.io/chat/sdk/flutter/) for [Stream Chat](https://getstream.io/chat/) > The official Flutter core components for Stream Chat, a service for > building chat applications. diff --git a/packages/stream_chat_flutter_core/example/lib/main.dart b/packages/stream_chat_flutter_core/example/lib/main.dart index aa705db1..4989f38f 100644 --- a/packages/stream_chat_flutter_core/example/lib/main.dart +++ b/packages/stream_chat_flutter_core/example/lib/main.dart @@ -2,8 +2,8 @@ import 'package:flutter/material.dart'; import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart'; Future main() async { - /// Create a new instance of [StreamChatClient] passing the apikey obtained from your - /// project dashboard. + /// Create a new instance of [StreamChatClient] passing the apikey obtained + /// from your project dashboard. final client = StreamChatClient('b67pax5b2wdq'); /// Set the current user. In a production scenario, this should be done using @@ -13,12 +13,13 @@ Future main() async { await client.connectUser( User( id: 'cool-shadow-7', - extraData: { + extraData: const { 'image': 'https://getstream.io/random_png/?id=cool-shadow-7&name=Cool+shadow', }, ), - 'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VyX2lkIjoiY29vbC1zaGFkb3ctNyJ9.gkOlCRb1qgy4joHPaxFwPOdXcGvSPvp6QY0S4mpRkVo', + 'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VyX2lkIjoiY29vbC1zaGFkb3ctNyJ9' + '.gkOlCRb1qgy4joHPaxFwPOdXcGvSPvp6QY0S4mpRkVo', ); runApp( @@ -29,36 +30,36 @@ Future main() async { } /// Example application using Stream Chat core widgets. -/// Stream Chat Core is a set of Flutter wrappers which provide basic functionality -/// for building Flutter applications using Stream. +/// Stream Chat Core is a set of Flutter wrappers which provide basic +/// functionality for building Flutter applications using Stream. +/// /// If you'd prefer using pre-made UI widgets for your app, please see our other /// package, `stream_chat_flutter`. class StreamExample extends StatelessWidget { /// Minimal example using Stream's core Flutter package. - /// If you'd prefer using pre-made UI widgets for your app, please see our other - /// package, `stream_chat_flutter`. + /// + /// If you'd prefer using pre-made UI widgets for your app, please see our + /// other package, `stream_chat_flutter`. const StreamExample({ - Key key, - @required this.client, + Key? key, + required this.client, }) : super(key: key); /// Instance of Stream Client. - /// Stream's [StreamChatClient] can be used to connect to our servers and set the default - /// user for the application. Performing these actions trigger a websocket connection - /// allowing for real-time updates. + /// Stream's [StreamChatClient] can be used to connect to our servers and + /// set the default user for the application. Performing these actions + /// trigger a websocket connection allowing for real-time updates. final StreamChatClient client; @override - Widget build(BuildContext context) { - return MaterialApp( - title: 'Stream Chat Core Example', - home: HomeScreen(), - builder: (context, child) => StreamChatCore( - client: client, - child: child, - ), - ); - } + Widget build(BuildContext context) => MaterialApp( + title: 'Stream Chat Core Example', + home: HomeScreen(), + builder: (context, child) => StreamChatCore( + client: client, + child: child!, + ), + ); } /// Basic layout displaying a list of [Channel]s the user is a part of. @@ -67,110 +68,115 @@ class StreamExample extends StatelessWidget { /// [ChannelListCore] is a `builder` with callbacks for constructing UIs based /// on different scenarios. class HomeScreen extends StatelessWidget { + /// Builds a basic layout displaying a list of [Channel]s the user is a + /// part of. + HomeScreen({Key? key}) : super(key: key); + + /// Controller used for loading more data and controlling pagination in + /// [ChannelListCore]. final channelListController = ChannelListController(); @override - Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar( - title: Text('Channels'), - ), - body: ChannelsBloc( - child: ChannelListCore( - channelListController: channelListController, - filter: { - 'type': 'messaging', - 'members': { - r'$in': [ - StreamChatCore.of(context).user.id, - ] - } - }, - emptyBuilder: (BuildContext context) { - return Center( + Widget build(BuildContext context) => Scaffold( + appBar: AppBar( + title: const Text('Channels'), + ), + body: ChannelsBloc( + child: ChannelListCore( + channelListController: channelListController, + filter: Filter.and([ + Filter.equal('type', 'messaging'), + Filter.in_('members', [ + StreamChatCore.of(context).user!.id, + ]) + ]), + emptyBuilder: (BuildContext context) => const Center( child: Text('Looks like you are not in any channels'), - ); - }, - loadingBuilder: (BuildContext context) { - return Center( + ), + loadingBuilder: (BuildContext context) => const Center( child: SizedBox( - height: 100.0, - width: 100.0, + height: 100, + width: 100, child: CircularProgressIndicator(), ), - ); - }, - errorBuilder: (BuildContext context, dynamic error) { - return Center( + ), + errorBuilder: ( + BuildContext context, + dynamic error, + ) => + Center( child: Text( - 'Oh no, something went wrong. Please check your config.'), - ); - }, - listBuilder: ( - BuildContext context, - List channels, - ) => - LazyLoadScrollView( - onEndOfPage: () async { - channelListController.paginateData(); - }, - child: ListView.builder( - itemCount: channels.length, - itemBuilder: (BuildContext context, int index) { - final _item = channels[index]; - return ListTile( - title: Text(_item.name), - subtitle: StreamBuilder( - stream: _item.state.lastMessageStream, - initialData: _item.state.lastMessage, - builder: (context, snapshot) { - if (snapshot.hasData) { - return Text(snapshot.data.text); - } - - return SizedBox(); - }, - ), - onTap: () { - /// Display a list of messages when the user taps on an item. - /// We can use [StreamChannel] to wrap our [MessageScreen] screen - /// with the selected channel. - /// - /// This allows us to use a built-in inherited widget for accessing - /// our `channel` later on. - Navigator.of(context).push( - MaterialPageRoute( - builder: (context) => StreamChannel( - channel: _item, - child: MessageScreen(), - ), - ), - ); - }, - ); + 'Oh no, something went wrong. ' + 'Please check your config. $error', + ), + ), + listBuilder: ( + BuildContext context, + List channels, + ) => + LazyLoadScrollView( + onEndOfPage: () async { + channelListController.paginateData!(); }, + child: ListView.builder( + itemCount: channels.length, + itemBuilder: (BuildContext context, int index) { + final _item = channels[index]; + return ListTile( + title: Text(_item.name!), + subtitle: StreamBuilder( + stream: _item.state!.lastMessageStream, + initialData: _item.state!.lastMessage, + builder: (context, snapshot) { + if (snapshot.hasData) { + return Text(snapshot.data!.text!); + } + + return const SizedBox(); + }, + ), + onTap: () { + /// Display a list of messages when the user taps on + /// an item. We can use [StreamChannel] to wrap our + /// [MessageScreen] screen with the selected channel. + /// + /// This allows us to use a built-in inherited widget + /// for accessing our `channel` later on. + Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => StreamChannel( + channel: _item, + child: const MessageScreen(), + ), + ), + ); + }, + ); + }, + ), ), ), ), - ), - ); - } + ); } /// A list of messages sent in the current channel. -/// When a user taps on a channel in [HomeScreen], a navigator push [MessageScreen] -/// to display the list of messages in the selected channel. +/// When a user taps on a channel in [HomeScreen], a navigator push +/// [MessageScreen] to display the list of messages in the selected channel. /// /// This is implemented using [MessageListCore], a convenience builder with /// callbacks for building UIs based on different api results. class MessageScreen extends StatefulWidget { + /// Build a MessageScreen + const MessageScreen({Key? key}) : super(key: key); + @override _MessageScreenState createState() => _MessageScreenState(); } class _MessageScreenState extends State { - TextEditingController _controller; - ScrollController _scrollController; + late final TextEditingController _controller; + late final ScrollController _scrollController; final messageListController = MessageListController(); @override @@ -197,19 +203,19 @@ class _MessageScreenState extends State { @override Widget build(BuildContext context) { - /// To access the current channel, we can use the `.of()` method on [StreamChannel] - /// to fetch the closest instance. + /// To access the current channel, we can use the `.of()` method on + /// [StreamChannel] to fetch the closest instance. final channel = StreamChannel.of(context).channel; return Scaffold( appBar: AppBar( - title: StreamBuilder>( - initialData: channel.state.typingEvents, - stream: channel.state.typingEventsStream, + title: StreamBuilder>( + initialData: channel.state?.typingEvents.keys, + stream: channel.state?.typingEventsStream.map((it) => it.keys), builder: (context, snapshot) { - if (snapshot.hasData && snapshot.data.isNotEmpty) { - return Text('${snapshot.data.first.name} is typing...'); + if (snapshot.hasData && snapshot.data!.isNotEmpty) { + return Text('${snapshot.data!.first.name} is typing...'); } - return SizedBox(); + return const SizedBox(); }, ), ), @@ -219,60 +225,56 @@ class _MessageScreenState extends State { Expanded( child: LazyLoadScrollView( onEndOfPage: () async { - messageListController.paginateData(); + messageListController.paginateData!(); }, child: MessageListCore( - emptyBuilder: (BuildContext context) { - return Center( - child: Text('Nothing here yet'), - ); - }, - loadingBuilder: (BuildContext context) { - return Center( - child: SizedBox( - height: 100.0, - width: 100.0, - child: CircularProgressIndicator(), - ), - ); - }, + messageListController: messageListController, + emptyBuilder: (BuildContext context) => const Center( + child: Text('Nothing here yet'), + ), + loadingBuilder: (BuildContext context) => const Center( + child: SizedBox( + height: 100, + width: 100, + child: CircularProgressIndicator(), + ), + ), messageListBuilder: ( BuildContext context, List messages, - ) { - return ListView.builder( - controller: _scrollController, - itemCount: messages.length, - reverse: true, - itemBuilder: (BuildContext context, int index) { - final item = messages[index]; - final client = StreamChatCore.of(context).client; - if (item.user.id == client.uid) { - return Align( - alignment: Alignment.centerRight, - child: Padding( - padding: const EdgeInsets.all(8.0), - child: Text(item.text), - ), - ); - } else { - return Align( - alignment: Alignment.centerLeft, - child: Padding( - padding: const EdgeInsets.all(8.0), - child: Text(item.text), - ), - ); - } - }, - ); - }, - errorWidgetBuilder: (BuildContext context, error) { - print(error?.toString()); - return Center( + ) => + ListView.builder( + controller: _scrollController, + itemCount: messages.length, + reverse: true, + itemBuilder: (BuildContext context, int index) { + final item = messages[index]; + final client = StreamChatCore.of(context).client; + if (item.user!.id == client.uid) { + return Align( + alignment: Alignment.centerRight, + child: Padding( + padding: const EdgeInsets.all(8), + child: Text(item.text!), + ), + ); + } else { + return Align( + alignment: Alignment.centerLeft, + child: Padding( + padding: const EdgeInsets.all(8), + child: Text(item.text!), + ), + ); + } + }, + ), + errorBuilder: (BuildContext context, error) { + print(error.toString()); + return const Center( child: SizedBox( - height: 100.0, - width: 100.0, + height: 100, + width: 100, child: Text('Oh no, an error occured. Please see logs.'), ), @@ -282,7 +284,7 @@ class _MessageScreenState extends State { ), ), Padding( - padding: const EdgeInsets.all(8.0), + padding: const EdgeInsets.all(8), child: Row( children: [ Expanded( @@ -303,12 +305,14 @@ class _MessageScreenState extends State { await channel.sendMessage( Message(text: _controller.value.text), ); - _controller.clear(); - _updateList(); + if (mounted) { + _controller.clear(); + _updateList(); + } } }, child: const Padding( - padding: EdgeInsets.all(8.0), + padding: EdgeInsets.all(8), child: Center( child: Icon( Icons.send, @@ -332,15 +336,15 @@ class _MessageScreenState extends State { /// below, we add two simple extensions to the [StreamChatClient] and [Channel]. extension on StreamChatClient { /// Fetches the current user id. - String get uid => state.user.id; + String get uid => state.user!.id; } extension on Channel { /// Fetches the name of the channel by accessing [extraData] or [cid]. - String get name { + String? get name { final _channelName = extraData['name']; if (_channelName != null) { - return _channelName; + return _channelName as String; } else { return cid; } diff --git a/packages/stream_chat_flutter_core/example/pubspec.yaml b/packages/stream_chat_flutter_core/example/pubspec.yaml index b0071176..6390abc4 100644 --- a/packages/stream_chat_flutter_core/example/pubspec.yaml +++ b/packages/stream_chat_flutter_core/example/pubspec.yaml @@ -18,17 +18,17 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev version: 1.0.0+1 environment: - sdk: ">=2.7.0 <3.0.0" + sdk: '>=2.12.0 <3.0.0' dependencies: + # The following adds the Cupertino Icons font to your application. + # Use with the CupertinoIcons class for iOS style icons. + cupertino_icons: ^1.0.3 flutter: sdk: flutter stream_chat_flutter_core: path: ../ - # The following adds the Cupertino Icons font to your application. - # Use with the CupertinoIcons class for iOS style icons. - cupertino_icons: ^1.0.0 dev_dependencies: flutter_test: diff --git a/packages/stream_chat_flutter_core/lib/src/better_stream_builder.dart b/packages/stream_chat_flutter_core/lib/src/better_stream_builder.dart new file mode 100644 index 00000000..871a8a1d --- /dev/null +++ b/packages/stream_chat_flutter_core/lib/src/better_stream_builder.dart @@ -0,0 +1,108 @@ +import 'dart:async'; + +import 'package:flutter/widgets.dart'; + +/// A more efficient [StreamBuilder] +/// It requires [initialData] and will rebuild +/// only when the new data is different than the current data +/// The [comparator] is used to check if the new data is different +class BetterStreamBuilder extends StatefulWidget { + /// Creates a new BetterStreamBuilder + const BetterStreamBuilder({ + required this.stream, + required this.initialData, + required this.builder, + this.loadingBuilder, + this.errorBuilder, + this.comparator, + Key? key, + }) : super(key: key); + + /// The stream to listen to + final Stream? stream; + + /// The initial data available + final T initialData; + + /// Comparator used to check if the new data is different than the last one + final bool Function(T?, T?)? comparator; + + /// Builder that builds based on the new snapshot + final Widget Function(BuildContext context, T data) builder; + + /// Builder that builds when the data is null + final Widget Function(BuildContext context)? loadingBuilder; + + /// Builder used when there is an error + final Widget Function(BuildContext context, Object error)? errorBuilder; + + @override + _BetterStreamBuilderState createState() => _BetterStreamBuilderState(); +} + +class _BetterStreamBuilderState extends State> { + T? _lastEvent; + StreamSubscription? _subscription; + Object? _lastError; + + @override + Widget build(BuildContext context) { + if (_lastError != null) { + return widget.errorBuilder!(context, _lastError!); + } + + if (_lastEvent == null) { + return widget.loadingBuilder?.call(context) ?? const Offstage(); + } + return widget.builder(context, _lastEvent ?? widget.initialData); + } + + @override + void initState() { + _lastEvent = widget.initialData; + _subscription = widget.stream?.listen( + _onEvent, + onError: _onError, + ); + super.initState(); + } + + @override + void didUpdateWidget(covariant BetterStreamBuilder oldWidget) { + if (oldWidget.stream != widget.stream) { + _subscription?.cancel(); + _subscription = widget.stream?.listen( + _onEvent, + onError: _onError, + ); + } + super.didUpdateWidget(oldWidget); + } + + @override + void dispose() { + _subscription?.cancel(); + super.dispose(); + } + + void _onError(error) { + if (widget.errorBuilder != null && error != _lastError) { + if (mounted) { + setState(() {}); + } + _lastError = error; + } + } + + void _onEvent(T event) { + _lastError = null; + final isEqual = + widget.comparator?.call(_lastEvent, event) ?? event == _lastEvent; + if (!isEqual) { + if (mounted) { + setState(() {}); + } + _lastEvent = event; + } + } +} diff --git a/packages/stream_chat_flutter_core/lib/src/channel_list_core.dart b/packages/stream_chat_flutter_core/lib/src/channel_list_core.dart index 3adea312..c59b2030 100644 --- a/packages/stream_chat_flutter_core/lib/src/channel_list_core.dart +++ b/packages/stream_chat_flutter_core/lib/src/channel_list_core.dart @@ -19,16 +19,15 @@ import 'package:stream_chat_flutter_core/src/typedef.dart'; /// Widget build(BuildContext context) { /// return Scaffold( /// body: ChannelListCore( -/// filter: { -/// 'members': { -/// '\$in': [StreamChat.of(context).user.id], -/// } -/// }, +/// filter: Filter.in_( +/// 'members', +/// [StreamChat.of(context).user!.id], +/// ), /// sort: [SortOption('last_message_at')], /// pagination: PaginationParams( /// limit: 20, /// ), -/// errorBuilder: (err) { +/// errorBuilder: (context, err) { /// return Center( /// child: Text('An error has occured'), /// ); @@ -38,7 +37,7 @@ import 'package:stream_chat_flutter_core/src/typedef.dart'; /// child: Text('Nothing here...'), /// ); /// }, -/// emptyBuilder: (context) { +/// loadingBuilder: (context) { /// return Center( /// child: CircularProgressIndicator(), /// ); @@ -57,41 +56,29 @@ import 'package:stream_chat_flutter_core/src/typedef.dart'; class ChannelListCore extends StatefulWidget { /// Instantiate a new ChannelListView const ChannelListCore({ - Key key, - @required this.errorBuilder, - @required this.emptyBuilder, - @required this.loadingBuilder, - @required this.listBuilder, + Key? key, + required this.errorBuilder, + required this.emptyBuilder, + required this.loadingBuilder, + required this.listBuilder, this.filter, - this.options, + this.state = true, + this.watch = true, + this.presence = false, + this.memberLimit, + this.messageLimit, this.sort, this.pagination = const PaginationParams( limit: 25, ), this.channelListController, - }) : assert( - errorBuilder != null, - 'Parameter errorBuilder should not be null', - ), - assert( - emptyBuilder != null, - 'Parameter emptyBuilder should not be null', - ), - assert( - loadingBuilder != null, - 'Parameter loadingBuilder should not be null', - ), - assert( - listBuilder != null, - 'Parameter listBuilder should not be null', - ), - super(key: key); + }) : super(key: key); /// A [ChannelListController] allows reloading and pagination. /// Use [ChannelListController.loadData] and /// [ChannelListController.paginateData] respectively for reloading and /// pagination. - final ChannelListController channelListController; + final ChannelListController? channelListController; /// The builder that will be used in case of error final ErrorBuilder errorBuilder; @@ -108,20 +95,29 @@ class ChannelListCore extends StatefulWidget { /// The query filters to use. /// You can query on any of the custom fields you've defined on the [Channel]. /// You can also filter other built-in channel fields. - final Map filter; - - /// Query channels options. - /// - /// state: if true returns the Channel state - /// watch: if true listen to changes to this Channel in real time. - final Map options; + final Filter? filter; /// The sorting used for the channels matching the filters. /// Sorting is based on field and direction, multiple sorting options can be /// provided. /// You can sort based on last_updated, last_message_at, updated_at, created /// _at or member_count. Direction can be ascending or descending. - final List> sort; + final List>? sort; + + /// If true returns the Channel state + final bool state; + + /// If true listen to changes to this Channel in real time. + final bool watch; + + /// If true you’ll receive user presence updates via the websocket events + final bool presence; + + /// Number of members to fetch in each channel + final int? memberLimit; + + /// Number of messages to fetch in each channel + final int? messageLimit; /// Pagination parameters /// limit: the number of channels to return (max is 30) @@ -135,12 +131,11 @@ class ChannelListCore extends StatefulWidget { /// The current state of the [ChannelListCore]. class ChannelListCoreState extends State { - @override - Widget build(BuildContext context) { - final channelsBloc = ChannelsBloc.of(context); + late ChannelsBlocState _channelsBloc; + StreamChatCoreState? _streamChatCoreState; - return _buildListView(channelsBloc); - } + @override + Widget build(BuildContext context) => _buildListView(_channelsBloc); StreamBuilder> _buildListView( ChannelsBlocState channelsBlocState, @@ -149,12 +144,12 @@ class ChannelListCoreState extends State { stream: channelsBlocState.channelsStream, builder: (context, snapshot) { if (snapshot.hasError) { - return widget.errorBuilder(context, snapshot.error); + return widget.errorBuilder(context, snapshot.error!); } if (!snapshot.hasData) { return widget.loadingBuilder(context); } - final channels = snapshot.data; + final channels = snapshot.data!; if (channels.isEmpty) { return widget.emptyBuilder(context); } @@ -163,49 +158,60 @@ class ChannelListCoreState extends State { ); /// Fetches initial channels and updates the widget - Future loadData() { - final channelsBloc = ChannelsBloc.of(context); - return channelsBloc.queryChannels( - filter: widget.filter, - sortOptions: widget.sort, - paginationParams: widget.pagination, - options: widget.options, - ); - } + Future loadData() => _channelsBloc.queryChannels( + filter: widget.filter, + sortOptions: widget.sort, + state: widget.state, + watch: widget.watch, + presence: widget.presence, + memberLimit: widget.memberLimit, + messageLimit: widget.messageLimit, + paginationParams: widget.pagination, + ); /// Fetches more channels with updated pagination and updates the widget - Future paginateData() { - final channelsBloc = ChannelsBloc.of(context); - return channelsBloc.queryChannels( - filter: widget.filter, - sortOptions: widget.sort, - paginationParams: widget.pagination.copyWith( - offset: channelsBloc.channels?.length ?? 0, - ), - options: widget.options, - ); - } + Future paginateData() => _channelsBloc.queryChannels( + filter: widget.filter, + sortOptions: widget.sort, + state: widget.state, + watch: widget.watch, + presence: widget.presence, + memberLimit: widget.memberLimit, + messageLimit: widget.messageLimit, + paginationParams: widget.pagination.copyWith( + offset: _channelsBloc.channels?.length ?? 0, + ), + ); - StreamSubscription _subscription; + StreamSubscription? _subscription; @override void initState() { super.initState(); - loadData(); - final client = StreamChatCore.of(context).client; - _subscription = client - .on( - EventType.connectionRecovered, - EventType.notificationAddedToChannel, - EventType.notificationMessageNew, - EventType.channelVisible, - ) - .listen((event) => loadData()); + _setupController(); + } - if (widget.channelListController != null) { - widget.channelListController.loadData = loadData; - widget.channelListController.paginateData = paginateData; + @override + void didChangeDependencies() { + _channelsBloc = ChannelsBloc.of(context); + final newStreamChatCoreState = StreamChatCore.of(context); + + if (newStreamChatCoreState != _streamChatCoreState) { + _streamChatCoreState = newStreamChatCoreState; + loadData(); + final client = _streamChatCoreState!.client; + _subscription?.cancel(); + _subscription = client + .on( + EventType.connectionRecovered, + EventType.notificationAddedToChannel, + EventType.notificationMessageNew, + EventType.channelVisible, + ) + .listen((event) => loadData()); } + + super.didChangeDependencies(); } @override @@ -214,16 +220,31 @@ class ChannelListCoreState extends State { if (widget.filter?.toString() != oldWidget.filter?.toString() || jsonEncode(widget.sort) != jsonEncode(oldWidget.sort) || - widget.options?.toString() != oldWidget.options?.toString() || - widget.pagination?.toJson()?.toString() != - oldWidget.pagination?.toJson()?.toString()) { + widget.state != oldWidget.state || + widget.watch != oldWidget.watch || + widget.presence != oldWidget.presence || + widget.messageLimit != oldWidget.messageLimit || + widget.memberLimit != oldWidget.memberLimit || + widget.pagination.toJson().toString() != + oldWidget.pagination.toJson().toString()) { loadData(); } + + if (widget.channelListController != oldWidget.channelListController) { + _setupController(); + } + } + + void _setupController() { + if (widget.channelListController != null) { + widget.channelListController!.loadData = loadData; + widget.channelListController!.paginateData = paginateData; + } } @override void dispose() { - _subscription.cancel(); + _subscription?.cancel(); super.dispose(); } } @@ -233,10 +254,10 @@ class ChannelListCoreState extends State { class ChannelListController { /// This function calls Stream's servers to load a list of channels. /// If there is existing data, calling this function causes a reload. - AsyncCallback loadData; + AsyncCallback? loadData; /// This function is used to load another page of data. Note, [loadData] /// should be used to populate the initial page of data. Calling /// [paginateData] performs a query to load subsequent pages. - AsyncCallback paginateData; + AsyncCallback? paginateData; } diff --git a/packages/stream_chat_flutter_core/lib/src/channels_bloc.dart b/packages/stream_chat_flutter_core/lib/src/channels_bloc.dart index f3ac1223..46aaccee 100644 --- a/packages/stream_chat_flutter_core/lib/src/channels_bloc.dart +++ b/packages/stream_chat_flutter_core/lib/src/channels_bloc.dart @@ -20,13 +20,12 @@ class ChannelsBloc extends StatefulWidget { /// Creates a new [ChannelsBloc]. The parameter [child] must be supplied and /// not null. const ChannelsBloc({ - Key key, - @required this.child, + Key? key, + required this.child, this.lockChannelsOrder = false, this.channelsComparator, this.shouldAddChannel, - }) : assert(child != null, 'Parameter child should not be null.'), - super(key: key); + }) : super(key: key); /// The widget child final Widget child; @@ -36,32 +35,35 @@ class ChannelsBloc extends StatefulWidget { final bool lockChannelsOrder; /// Comparator used to sort the channels when a message.new event is received - final Comparator channelsComparator; + final Comparator? channelsComparator; /// Function used to evaluate if a channel should be added to the list when a /// message.new event is received - final bool Function(Event) shouldAddChannel; + final bool Function(Event)? shouldAddChannel; @override ChannelsBlocState createState() => ChannelsBlocState(); /// Use this method to get the current [ChannelsBlocState] instance static ChannelsBlocState of(BuildContext context) { - ChannelsBlocState streamChatState; + ChannelsBlocState? streamChatState; streamChatState = context.findAncestorStateOfType(); - if (streamChatState == null) { - throw Exception('You must have a ChannelsBloc widget as ancestor'); - } + assert( + streamChatState != null, + 'You must have a ChannelsBloc widget as ancestor', + ); - return streamChatState; + return streamChatState!; } } /// The current state of the [ChannelsBloc]. class ChannelsBlocState extends State with AutomaticKeepAliveClientMixin { + StreamChatCoreState? _streamChatCoreState; + @override Widget build(BuildContext context) { super.build(context); @@ -69,14 +71,15 @@ class ChannelsBlocState extends State } /// The current channel list - List get channels => _channelsController.value; + List? get channels => _channelsController.valueOrNull; /// The current channel list as a stream Stream> get channelsStream => _channelsController.stream; final _queryChannelsLoadingController = BehaviorSubject.seeded(false); - final _channelsController = BehaviorSubject>(); + final BehaviorSubject> _channelsController = + BehaviorSubject>(); /// The stream notifying the state of queryChannel call Stream get queryChannelsLoading => @@ -86,18 +89,23 @@ class ChannelsBlocState extends State bool _paginationEnded = false; + final List _subscriptions = []; + /// Calls [client.queryChannels] updating [queryChannelsLoading] stream Future queryChannels({ - Map filter, - List> sortOptions, - PaginationParams paginationParams, - Map options, + Filter? filter, + List>? sortOptions, + bool state = true, + bool watch = true, + bool presence = false, + int? memberLimit, + int? messageLimit, + bool waitForConnect = true, + PaginationParams paginationParams = const PaginationParams(limit: 30), }) async { - final client = StreamChatCore.of(context).client; + final client = _streamChatCoreState!.client; - final clear = paginationParams == null || - paginationParams.offset == null || - paginationParams.offset == 0; + final clear = paginationParams.offset == 0; if ((!clear && _paginationEnded) || _queryChannelsLoadingController.value == true) { @@ -114,7 +122,12 @@ class ChannelsBlocState extends State await for (final channels in client.queryChannels( filter: filter, sort: sortOptions, - options: options, + state: state, + watch: watch, + presence: presence, + memberLimit: memberLimit, + messageLimit: messageLimit, + waitForConnect: waitForConnect, paginationParams: paginationParams, )) { newChannels = channels; @@ -133,6 +146,8 @@ class ChannelsBlocState extends State _paginationEnded = true; } } catch (e, stk) { + // reset loading controller + _queryChannelsLoadingController.sink.add(false); if (_channelsController.hasValue) { _queryChannelsLoadingController.addError(e, stk); } else { @@ -141,78 +156,88 @@ class ChannelsBlocState extends State } } - final List _subscriptions = []; - @override - void initState() { - super.initState(); + void didChangeDependencies() { + final newStreamChatCoreState = StreamChatCore.of(context); - final client = StreamChatCore.of(context).client; + if (newStreamChatCoreState != _streamChatCoreState) { + _streamChatCoreState = newStreamChatCoreState; + final client = _streamChatCoreState!.client; - if (!widget.lockChannelsOrder) { - _subscriptions.add(client - .on( - EventType.messageNew, - ) - .listen((e) { - final newChannels = List.from(channels ?? []); - final index = newChannels.indexWhere((c) => c.cid == e.cid); - if (index != -1) { - if (index > 0) { - final channel = newChannels.removeAt(index); - newChannels.insert(0, channel); - } - } else if (widget.shouldAddChannel?.call(e) == true) { - final hiddenIndex = _hiddenChannels.indexWhere((c) => c.cid == e.cid); - if (hiddenIndex != -1) { - newChannels.insert(0, _hiddenChannels[hiddenIndex]); - _hiddenChannels.removeAt(hiddenIndex); - } else { - if (client.state?.channels != null && - client.state?.channels[e.cid] != null) { - newChannels.insert(0, client.state.channels[e.cid]); + _cancelSubscriptions(); + if (!widget.lockChannelsOrder) { + _subscriptions.add(client + .on( + EventType.messageNew, + ) + .listen((e) { + final newChannels = List.from(channels ?? []); + final index = newChannels.indexWhere((c) => c.cid == e.cid); + if (index != -1) { + if (index > 0) { + final channel = newChannels.removeAt(index); + newChannels.insert(0, channel); + } + } else if (widget.shouldAddChannel?.call(e) == true) { + final hiddenIndex = + _hiddenChannels.indexWhere((c) => c.cid == e.cid); + if (hiddenIndex != -1) { + newChannels.insert(0, _hiddenChannels[hiddenIndex]); + _hiddenChannels.removeAt(hiddenIndex); + } else { + if (client.state.channels[e.cid] != null) { + newChannels.insert(0, client.state.channels[e.cid]!); + } } } - } - if (widget.channelsComparator != null) { - newChannels.sort(widget.channelsComparator); - } - _channelsController.add(newChannels); - })); + if (widget.channelsComparator != null) { + newChannels.sort(widget.channelsComparator); + } + _channelsController.add(newChannels); + })); + } + + _subscriptions + ..add(client.on(EventType.channelHidden).listen((event) async { + final newChannels = List.from(channels ?? []); + final channelIndex = + newChannels.indexWhere((c) => c.cid == event.cid); + if (channelIndex > -1) { + final channel = newChannels.removeAt(channelIndex); + _hiddenChannels.add(channel); + _channelsController.add(newChannels); + } + })) + ..add(client + .on( + EventType.channelDeleted, + EventType.notificationRemovedFromChannel, + ) + .listen((e) { + final channel = e.channel; + _channelsController.add(List.from( + (channels ?? [])..removeWhere((c) => c.cid == channel?.cid))); + })); } - _subscriptions.add(client.on(EventType.channelHidden).listen((event) async { - final newChannels = List.from(channels ?? []); - final channelIndex = newChannels.indexWhere((c) => c.cid == event.cid); - if (channelIndex > -1) { - final channel = newChannels.removeAt(channelIndex); - _hiddenChannels.add(channel); - _channelsController.add(newChannels); - } - })); - // ignore: cascade_invocations - _subscriptions.add(client - .on( - EventType.channelDeleted, - EventType.notificationRemovedFromChannel, - ) - .listen((e) { - // ignore: cascade_invocations - final channel = e.channel; - _channelsController.add(List.from( - (channels ?? [])..removeWhere((c) => c.cid == channel.cid))); - })); + super.didChangeDependencies(); } @override void dispose() { _channelsController.close(); _queryChannelsLoadingController.close(); - _subscriptions.forEach((s) => s.cancel()); + _cancelSubscriptions(); super.dispose(); } + void _cancelSubscriptions() { + _subscriptions + ..forEach((s) => s.cancel()) + ..clear(); + } + @override bool get wantKeepAlive => true; } diff --git a/packages/stream_chat_flutter_core/lib/src/lazy_load_scroll_view.dart b/packages/stream_chat_flutter_core/lib/src/lazy_load_scroll_view.dart index 808c1dae..fb503bcd 100644 --- a/packages/stream_chat_flutter_core/lib/src/lazy_load_scroll_view.dart +++ b/packages/stream_chat_flutter_core/lib/src/lazy_load_scroll_view.dart @@ -9,34 +9,33 @@ class LazyLoadScrollView extends StatefulWidget { /// Creates a new instance of [LazyLoadScrollView]. The parameter [child] /// must be supplied and not null. const LazyLoadScrollView({ - Key key, - @required this.child, + Key? key, + required this.child, this.onStartOfPage, this.onEndOfPage, this.onPageScrollStart, this.onPageScrollEnd, this.onInBetweenOfPage, this.scrollOffset = 100, - }) : assert(child != null, 'Parameter child should not be null'), - super(key: key); + }) : super(key: key); /// The [Widget] that this widget watches for changes on final Widget child; /// Called when the [child] reaches the start of the list - final AsyncCallback onStartOfPage; + final AsyncCallback? onStartOfPage; /// Called when the [child] reaches the end of the list - final AsyncCallback onEndOfPage; + final AsyncCallback? onEndOfPage; /// Called when the list scrolling starts - final VoidCallback onPageScrollStart; + final VoidCallback? onPageScrollStart; /// Called when the list scrolling ends - final VoidCallback onPageScrollEnd; + final VoidCallback? onPageScrollEnd; /// Called every time the [child] is in-between the list - final VoidCallback onInBetweenOfPage; + final VoidCallback? onInBetweenOfPage; /// The offset to take into account when triggering [onEndOfPage]/[onStartOfPage] in pixels final double scrollOffset; @@ -46,7 +45,7 @@ class LazyLoadScrollView extends StatefulWidget { } class _LazyLoadScrollViewState extends State { - _LoadingStatus _loadMoreStatus = _LoadingStatus.stable; + var _loadMoreStatus = _LoadingStatus.stable; double _scrollPosition = 0; @override @@ -59,13 +58,13 @@ class _LazyLoadScrollViewState extends State { bool _onNotification(ScrollNotification notification) { if (notification is ScrollStartNotification) { if (widget.onPageScrollStart != null) { - widget.onPageScrollStart(); + widget.onPageScrollStart!(); return true; } } if (notification is ScrollEndNotification) { if (widget.onPageScrollEnd != null) { - widget.onPageScrollEnd(); + widget.onPageScrollEnd!(); return true; } } @@ -73,12 +72,12 @@ class _LazyLoadScrollViewState extends State { final pixels = notification.metrics.pixels; final maxScrollExtent = notification.metrics.maxScrollExtent; final minScrollExtent = notification.metrics.minScrollExtent; - final scrollOffset = widget.scrollOffset ?? 0; + final scrollOffset = widget.scrollOffset; if (pixels > (minScrollExtent + scrollOffset) && pixels < (maxScrollExtent - scrollOffset)) { if (widget.onInBetweenOfPage != null) { - widget.onInBetweenOfPage(); + widget.onInBetweenOfPage!(); return true; } } @@ -86,6 +85,7 @@ class _LazyLoadScrollViewState extends State { final extentBefore = notification.metrics.extentBefore; final extentAfter = notification.metrics.extentAfter; final scrollingDown = _scrollPosition < pixels; + _scrollPosition = pixels; if (scrollingDown) { if (extentAfter <= scrollOffset) { @@ -98,8 +98,6 @@ class _LazyLoadScrollViewState extends State { return true; } } - - _scrollPosition = pixels; } if (notification is OverscrollNotification) { if (notification.overscroll > 0) { @@ -114,10 +112,10 @@ class _LazyLoadScrollViewState extends State { } void _onEndOfPage() { - if (_loadMoreStatus != null && _loadMoreStatus == _LoadingStatus.stable) { + if (_loadMoreStatus == _LoadingStatus.stable) { if (widget.onEndOfPage != null) { _loadMoreStatus = _LoadingStatus.loading; - widget.onEndOfPage().whenComplete(() { + widget.onEndOfPage!().whenComplete(() { _loadMoreStatus = _LoadingStatus.stable; }); } @@ -125,10 +123,10 @@ class _LazyLoadScrollViewState extends State { } void _onStartOfPage() { - if (_loadMoreStatus != null && _loadMoreStatus == _LoadingStatus.stable) { + if (_loadMoreStatus == _LoadingStatus.stable) { if (widget.onStartOfPage != null) { _loadMoreStatus = _LoadingStatus.loading; - widget.onStartOfPage().whenComplete(() { + widget.onStartOfPage!().whenComplete(() { _loadMoreStatus = _LoadingStatus.stable; }); } diff --git a/packages/stream_chat_flutter_core/lib/src/message_list_core.dart b/packages/stream_chat_flutter_core/lib/src/message_list_core.dart index 53844447..dc90cc91 100644 --- a/packages/stream_chat_flutter_core/lib/src/message_list_core.dart +++ b/packages/stream_chat_flutter_core/lib/src/message_list_core.dart @@ -1,9 +1,11 @@ import 'dart:async'; +import 'package:collection/collection.dart'; import 'package:flutter/cupertino.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:stream_chat/stream_chat.dart'; +import 'package:stream_chat_flutter_core/src/better_stream_builder.dart'; import 'package:stream_chat_flutter_core/src/stream_channel.dart'; import 'package:stream_chat_flutter_core/src/typedef.dart'; @@ -38,7 +40,7 @@ import 'package:stream_chat_flutter_core/src/typedef.dart'; /// messageListBuilder: (context, list) { /// return MessagesPage(list); /// }, -/// errorWidgetBuilder: (context, err) { +/// errorBuilder: (context, err) { /// return Center( /// child: Text('Error'), /// ); @@ -61,30 +63,19 @@ import 'package:stream_chat_flutter_core/src/typedef.dart'; class MessageListCore extends StatefulWidget { /// Instantiate a new [MessageListView]. const MessageListCore({ - Key key, - @required this.loadingBuilder, - @required this.emptyBuilder, - @required this.messageListBuilder, - @required this.errorWidgetBuilder, - this.showScrollToBottom = true, + Key? key, + required this.loadingBuilder, + required this.emptyBuilder, + required this.messageListBuilder, + required this.errorBuilder, this.parentMessage, this.messageListController, this.messageFilter, - }) : assert(loadingBuilder != null, 'loadingBuilder should not be null'), - assert(emptyBuilder != null, 'emptyBuilder should not be null'), - assert( - messageListBuilder != null, - 'messageListBuilder should not be null', - ), - assert( - errorWidgetBuilder != null, - 'errorWidgetBuilder should not be null', - ), - super(key: key); + }) : super(key: key); /// A [MessageListController] allows pagination. /// Use [ChannelListController.paginateData] pagination. - final MessageListController messageListController; + final MessageListController? messageListController; /// Function called when messages are fetched final Widget Function(BuildContext, List) messageListBuilder; @@ -100,18 +91,14 @@ class MessageListCore extends StatefulWidget { /// /// This parameter can be used to display an error message to users in the /// event of a connection failure. - final ErrorBuilder errorWidgetBuilder; - - /// If true will show a scroll to bottom message when there are new messages - /// and the scroll offset is not zero. - final bool showScrollToBottom; + final ErrorBuilder errorBuilder; /// If the current message belongs to a `thread`, this property represents the /// first message or the parent of the conversation. - final Message parentMessage; + final Message? parentMessage; /// Predicate used to filter messages - final bool Function(Message) messageFilter; + final bool Function(Message)? messageFilter; @override MessageListCoreState createState() => MessageListCoreState(); @@ -119,85 +106,119 @@ class MessageListCore extends StatefulWidget { /// The current state of the [MessageListCore]. class MessageListCoreState extends State { - StreamChannelState _streamChannel; + StreamChannelState? _streamChannel; - bool get _upToDate => _streamChannel.channel.state.isUpToDate; + bool get _upToDate => _streamChannel!.channel.state?.isUpToDate ?? true; bool get _isThreadConversation => widget.parentMessage != null; - OwnUser get _currentUser => _streamChannel.channel.client.state.user; + OwnUser? get _currentUser => _streamChannel!.channel.client.state.user; var _messages = []; @override Widget build(BuildContext context) { final messagesStream = _isThreadConversation - ? _streamChannel.channel.state.threadsStream - .where((threads) => threads.containsKey(widget.parentMessage.id)) - .map((threads) => threads[widget.parentMessage.id]) - : _streamChannel.channel.state?.messagesStream; + ? _streamChannel!.channel.state?.threadsStream + .where((threads) => threads.containsKey(widget.parentMessage!.id)) + .map((threads) => threads[widget.parentMessage!.id]) + : _streamChannel!.channel.state?.messagesStream; + + final initialData = _isThreadConversation + ? _streamChannel!.channel.state?.threads[widget.parentMessage!.id] + : _streamChannel!.channel.state?.messages; bool defaultFilter(Message m) { - final isMyMessage = m.user.id == _currentUser.id; + final isMyMessage = m.user?.id == _currentUser?.id; final isDeletedOrShadowed = m.isDeleted == true || m.shadowed == true; if (isDeletedOrShadowed && !isMyMessage) return false; return true; } - return StreamBuilder>( - stream: messagesStream?.map((messages) => - messages?.where(widget.messageFilter ?? defaultFilter)?.toList()), - builder: (context, snapshot) { - if (snapshot.hasError) { - return widget.errorWidgetBuilder(context, snapshot.error); - } else if (!snapshot.hasData) { - return widget.loadingBuilder(context); - } else { - final messageList = snapshot.data?.reversed?.toList() ?? []; - if (messageList.isEmpty && !_isThreadConversation) { - if (_upToDate) { - return widget.emptyBuilder(context); - } - } else { - _messages = messageList; + return BetterStreamBuilder?>( + initialData: initialData, + comparator: const ListEquality().equals, + stream: messagesStream!.map( + (messages) => + messages?.where(widget.messageFilter ?? defaultFilter).toList( + growable: false, + ), + ), + errorBuilder: widget.errorBuilder, + loadingBuilder: widget.loadingBuilder, + builder: (context, data) { + final messageList = data?.reversed.toList(growable: false) ?? []; + if (messageList.isEmpty && !_isThreadConversation) { + if (_upToDate) { + return widget.emptyBuilder(context); } - return widget.messageListBuilder(context, _messages); + } else { + _messages = messageList; } + return widget.messageListBuilder(context, _messages); }, ); } /// Fetches more messages with updated pagination and updates the widget. /// - /// Optionally pass the fetch direction, defaults to [QueryDirection.bottom] - Future paginateData( - {QueryDirection direction = QueryDirection.bottom}) { + /// Optionally pass the fetch direction, defaults to [QueryDirection.top] + Future paginateData({ + QueryDirection direction = QueryDirection.top, + }) { if (!_isThreadConversation) { - return _streamChannel.queryMessages(direction: direction); + return _streamChannel!.queryMessages(direction: direction); } else { - return _streamChannel.getReplies(widget.parentMessage.id); + return _streamChannel!.getReplies(widget.parentMessage!.id); + } + } + + @override + void didChangeDependencies() { + final newStreamChannel = StreamChannel.of(context); + + if (newStreamChannel != _streamChannel) { + if (_streamChannel == null /*only first time*/ && _isThreadConversation) { + newStreamChannel.getReplies(widget.parentMessage!.id); + } + _streamChannel = newStreamChannel; + } + + super.didChangeDependencies(); + } + + @override + void didUpdateWidget(covariant MessageListCore oldWidget) { + super.didUpdateWidget(oldWidget); + + if (widget.messageListController != oldWidget.messageListController) { + _setupController(); + } + + if (widget.parentMessage?.id != widget.parentMessage?.id) { + if (_isThreadConversation) { + _streamChannel!.getReplies(widget.parentMessage!.id); + } } } @override void initState() { - _streamChannel = StreamChannel.of(context); - - if (_isThreadConversation) { - _streamChannel.getReplies(widget.parentMessage.id); - } - - if (widget.messageListController != null) { - widget.messageListController.paginateData = paginateData; - } + _setupController(); super.initState(); } + void _setupController() { + if (widget.messageListController != null) { + widget.messageListController!.paginateData = paginateData; + } + } + @override void dispose() { if (!_upToDate) { - _streamChannel.reloadChannel(); + _streamChannel!.reloadChannel(); } super.dispose(); } @@ -206,5 +227,5 @@ class MessageListCoreState extends State { /// Controller used for paginating data in [ChannelListView] class MessageListController { /// Call this function to load further data - Future Function({QueryDirection direction}) paginateData; + Future Function({QueryDirection direction})? paginateData; } diff --git a/packages/stream_chat_flutter_core/lib/src/message_search_bloc.dart b/packages/stream_chat_flutter_core/lib/src/message_search_bloc.dart index 1c5c78de..44ac9b3f 100644 --- a/packages/stream_chat_flutter_core/lib/src/message_search_bloc.dart +++ b/packages/stream_chat_flutter_core/lib/src/message_search_bloc.dart @@ -9,14 +9,13 @@ import 'package:stream_chat_flutter_core/src/stream_chat_core.dart'; /// [MessageSearchBloc] can be access at anytime by using the static [of] method /// using Flutter's [BuildContext]. /// -// API docs: https://getstream.io/chat/docs/flutter-dart/send_message/ +/// API docs: https://getstream.io/chat/docs/flutter-dart/send_message/ class MessageSearchBloc extends StatefulWidget { /// Instantiate a new MessageSearchBloc const MessageSearchBloc({ - Key key, - @required this.child, - }) : assert(child != null, 'Parameter child should not be null.'), - super(key: key); + Key? key, + required this.child, + }) : super(key: key); /// The widget child final Widget child; @@ -26,23 +25,27 @@ class MessageSearchBloc extends StatefulWidget { /// Use this method to get the current [MessageSearchBlocState] instance static MessageSearchBlocState of(BuildContext context) { - MessageSearchBlocState state; + MessageSearchBlocState? state; state = context.findAncestorStateOfType(); - if (state == null) { - throw Exception('You must have a MessageSearchBloc widget as ancestor'); - } + assert( + state != null, + 'You must have a MessageSearchBloc widget as ancestor', + ); - return state; + return state!; } } /// The current state of the [MessageSearchBloc] class MessageSearchBlocState extends State with AutomaticKeepAliveClientMixin { + late StreamChatCoreState _streamChatCoreState; + /// The current messages list - List get messageResponses => _messageResponses.value; + List? get messageResponses => + _messageResponses.valueOrNull; /// The current messages list as a stream Stream> get messagesStream => @@ -59,13 +62,13 @@ class MessageSearchBlocState extends State /// Calls [StreamChatClient.search] updating /// [messagesStream] and [queryMessagesLoading] stream Future search({ - Map filter, - Map messageFilter, - List sort, - String query, - PaginationParams pagination, + required Filter filter, + Filter? messageFilter, + List? sort, + String? query, + PaginationParams? pagination, }) async { - final client = StreamChatCore.of(context).client; + final client = _streamChatCoreState.client; if (_queryMessagesLoadingController.value == true) return; @@ -73,9 +76,7 @@ class MessageSearchBlocState extends State _queryMessagesLoadingController.add(true); } try { - final clear = pagination == null || - pagination.offset == null || - pagination.offset == 0; + final clear = pagination == null || pagination.offset == 0; final oldMessages = List.from(messageResponses ?? []); @@ -97,6 +98,8 @@ class MessageSearchBlocState extends State _queryMessagesLoadingController.add(false); } } catch (e, stk) { + // reset loading controller + _queryMessagesLoadingController.add(false); if (_messageResponses.hasValue) { _queryMessagesLoadingController.addError(e, stk); } else { @@ -111,6 +114,12 @@ class MessageSearchBlocState extends State return widget.child; } + @override + void didChangeDependencies() { + _streamChatCoreState = StreamChatCore.of(context); + super.didChangeDependencies(); + } + @override void dispose() { _messageResponses.close(); diff --git a/packages/stream_chat_flutter_core/lib/src/message_search_list_core.dart b/packages/stream_chat_flutter_core/lib/src/message_search_list_core.dart index 72c5ea86..4901c332 100644 --- a/packages/stream_chat_flutter_core/lib/src/message_search_list_core.dart +++ b/packages/stream_chat_flutter_core/lib/src/message_search_list_core.dart @@ -17,12 +17,8 @@ import 'package:stream_chat_flutter_core/src/typedef.dart'; /// Widget build(BuildContext context) { /// return Scaffold( /// body: MessageSearchListCore( -/// messageQuery: _channelQuery, -/// filters: { -/// 'members': { -/// r'$in': [user.id] -/// } -/// }, +/// messageQuery: _messageFilter, +/// filters: _channelsFilter, /// paginationParams: PaginationParams(limit: 20), /// ), /// ); @@ -42,54 +38,50 @@ class MessageSearchListCore extends StatefulWidget { /// * [loadingBuilder] /// * [childBuilder] const MessageSearchListCore({ - Key key, - @required this.emptyBuilder, - @required this.errorBuilder, - @required this.loadingBuilder, - @required this.childBuilder, + Key? key, + required this.emptyBuilder, + required this.errorBuilder, + required this.loadingBuilder, + required this.childBuilder, + required this.filters, this.messageQuery, - this.filters, this.sortOptions, this.paginationParams, this.messageFilters, this.messageSearchListController, - }) : assert(emptyBuilder != null, 'emptyBuilder should not be null'), - assert(errorBuilder != null, 'errorBuilder should not be null'), - assert(loadingBuilder != null, 'loadingBuilder should not be null'), - assert(childBuilder != null, 'childBuilder should not be null'), - super(key: key); + }) : super(key: key); /// A [MessageSearchListController] allows reloading and pagination. /// Use [MessageSearchListController.loadData] and /// [MessageSearchListController.paginateData] respectively for reloading and /// pagination. - final MessageSearchListController messageSearchListController; + final MessageSearchListController? messageSearchListController; /// Message String to search on - final String messageQuery; + final String? messageQuery; /// The query filters to use. /// You can query on any of the custom fields you've defined on the [Channel]. /// You can also filter other built-in channel fields. - final Map filters; + final Filter filters; /// The sorting used for the channels matching the filters. /// Sorting is based on field and direction, multiple sorting options can be /// provided. /// You can sort based on last_updated, last_message_at, updated_at, created_ /// at or member_count. Direction can be ascending or descending. - final List sortOptions; + final List? sortOptions; /// Pagination parameters /// limit: the number of users to return (max is 30) /// offset: the offset (max is 1000) /// message_limit: how many messages should be included to each channel - final PaginationParams paginationParams; + final PaginationParams? paginationParams; /// The message query filters to use. /// You can query on any of the custom fields you've defined on the [Channel]. /// You can also filter other built-in channel fields. - final Map messageFilters; + final Filter? messageFilters; /// The builder that is used when the search messages are fetched final Widget Function(List) childBuilder; @@ -109,86 +101,99 @@ class MessageSearchListCore extends StatefulWidget { /// The current state of the [MessageSearchListCore]. class MessageSearchListCoreState extends State { + MessageSearchBlocState? _messageSearchBloc; + @override void didChangeDependencies() { + final newMessageSearchBloc = MessageSearchBloc.of(context); + + if (newMessageSearchBloc != _messageSearchBloc) { + _messageSearchBloc = newMessageSearchBloc; + loadData(); + } + super.didChangeDependencies(); - loadData(); + } + + void _setupController() { if (widget.messageSearchListController != null) { - widget.messageSearchListController.loadData = loadData; - widget.messageSearchListController.paginateData = paginateData; + widget.messageSearchListController!.loadData = loadData; + widget.messageSearchListController!.paginateData = paginateData; } } @override - Widget build(BuildContext context) { - final messageSearchBloc = MessageSearchBloc.of(context); - return _buildListView(messageSearchBloc); + void initState() { + super.initState(); + _setupController(); } + @override + Widget build(BuildContext context) => _buildListView(_messageSearchBloc!); + Widget _buildListView(MessageSearchBlocState messageSearchBloc) => StreamBuilder>( stream: messageSearchBloc.messagesStream, builder: (context, snapshot) { if (snapshot.hasError) { - return widget.errorBuilder(context, snapshot.error); + return widget.errorBuilder(context, snapshot.error!); } if (!snapshot.hasData) { return widget.loadingBuilder(context); } - final items = snapshot.data; + final items = snapshot.data!; if (items.isEmpty) { return widget.emptyBuilder(context); } - return widget.childBuilder(snapshot.data); + return widget.childBuilder(items); }, ); /// Fetches initial messages and updates the widget - Future loadData() { - final messageSearchBloc = MessageSearchBloc.of(context); - return messageSearchBloc.search( - filter: widget.filters, - sort: widget.sortOptions, - query: widget.messageQuery, - pagination: widget.paginationParams, - messageFilter: widget.messageFilters, - ); - } + Future loadData() => _messageSearchBloc!.search( + filter: widget.filters, + sort: widget.sortOptions, + query: widget.messageQuery, + pagination: widget.paginationParams, + messageFilter: widget.messageFilters, + ); /// Fetches more messages with updated pagination and updates the widget - Future paginateData() { - final messageSearchBloc = MessageSearchBloc.of(context); - return messageSearchBloc.search( - filter: widget.filters, - sort: widget.sortOptions, - pagination: widget.paginationParams.copyWith( - offset: messageSearchBloc.messageResponses?.length ?? 0, - ), - query: widget.messageQuery, - messageFilter: widget.messageFilters, - ); - } + Future paginateData() => _messageSearchBloc!.search( + filter: widget.filters, + sort: widget.sortOptions, + pagination: widget.paginationParams!.copyWith( + offset: _messageSearchBloc!.messageResponses?.length ?? 0, + ), + query: widget.messageQuery, + messageFilter: widget.messageFilters, + ); @override void didUpdateWidget(MessageSearchListCore oldWidget) { super.didUpdateWidget(oldWidget); - if (widget.filters?.toString() != oldWidget.filters?.toString() || + if (widget.filters.toString() != oldWidget.filters.toString() || jsonEncode(widget.sortOptions) != jsonEncode(oldWidget.sortOptions) || widget.messageQuery?.toString() != oldWidget.messageQuery?.toString() || widget.messageFilters?.toString() != oldWidget.messageFilters?.toString() || - widget.paginationParams?.toJson()?.toString() != - oldWidget.paginationParams?.toJson()?.toString()) { + widget.paginationParams?.toJson().toString() != + oldWidget.paginationParams?.toJson().toString()) { loadData(); } + + if (widget.messageSearchListController != + oldWidget.messageSearchListController) { + _setupController(); + } } } /// Controller used for paginating data in [ChannelListView] class MessageSearchListController { /// Call this function to reload data - AsyncCallback loadData; + AsyncCallback? loadData; /// Call this function to load further data - AsyncCallback paginateData; + AsyncCallback? paginateData; } diff --git a/packages/stream_chat_flutter_core/lib/src/stream_channel.dart b/packages/stream_chat_flutter_core/lib/src/stream_channel.dart index 8f86fca4..1dcf9c3c 100644 --- a/packages/stream_chat_flutter_core/lib/src/stream_channel.dart +++ b/packages/stream_chat_flutter_core/lib/src/stream_channel.dart @@ -1,5 +1,6 @@ import 'dart:async'; +import 'package:collection/collection.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:rxdart/rxdart.dart'; @@ -21,16 +22,14 @@ class StreamChannel extends StatefulWidget { /// Creates a new instance of [StreamChannel]. Both [child] and [client] must /// be supplied and not null. const StreamChannel({ - Key key, - @required this.child, - @required this.channel, + Key? key, + required this.child, + required this.channel, this.showLoading = true, this.initialMessageId, - }) : assert(child != null, 'Child should not be null'), - assert(channel != null, 'Channel should not be null'), - super(key: key); + }) : super(key: key); - // ignore: public_member_api_docs + /// The child of the widget final Widget child; /// [channel] specifies the channel with which child should be wrapped @@ -40,21 +39,20 @@ class StreamChannel extends StatefulWidget { final bool showLoading; /// If passed the channel will load from this particular message. - final String initialMessageId; + final String? initialMessageId; /// Use this method to get the current [StreamChannelState] instance static StreamChannelState of(BuildContext context) { - StreamChannelState streamChannelState; + StreamChannelState? streamChannelState; streamChannelState = context.findAncestorStateOfType(); - if (streamChannelState == null) { - throw Exception( - 'You must have a StreamChannel widget at the top of your widget tree', - ); - } + assert( + streamChannelState != null, + 'You must have a StreamChannel widget at the top of your widget tree', + ); - return streamChannelState; + return streamChannelState!; } @override @@ -67,11 +65,11 @@ class StreamChannelState extends State { Channel get channel => widget.channel; /// InitialMessageId - String get initialMessageId => widget.initialMessageId; + String? get initialMessageId => widget.initialMessageId; /// Current channel state stream - Stream get channelStateStream => - widget.channel.state.channelStateStream; + Stream? get channelStateStream => + widget.channel.state?.channelStateStream; final _queryTopMessagesController = BehaviorSubject.seeded(false); final _queryBottomMessagesController = BehaviorSubject.seeded(false); @@ -89,16 +87,18 @@ class StreamChannelState extends State { int limit = 20, bool preferOffline = false, }) async { - if (_topPaginationEnded || _queryTopMessagesController?.value == true) { + if (_topPaginationEnded || + _queryTopMessagesController.value == true || + channel.state == null) { return; } _queryTopMessagesController.add(true); - if (channel.state.messages.isEmpty) { + if (channel.state!.messages.isEmpty) { return _queryTopMessagesController.add(false); } - final oldestMessage = channel.state.messages.first; + final oldestMessage = channel.state!.messages.first; try { final state = await queryBeforeMessage( @@ -120,15 +120,16 @@ class StreamChannelState extends State { bool preferOffline = false, }) async { if (_bottomPaginationEnded || - _queryBottomMessagesController?.value == true || - channel?.state?.isUpToDate == true) return; + _queryBottomMessagesController.value == true || + channel.state == null || + channel.state!.isUpToDate == true) return; _queryBottomMessagesController.add(true); - if (channel.state.messages.isEmpty) { + if (channel.state!.messages.isEmpty) { return _queryBottomMessagesController.add(false); } - final recentMessage = channel.state.messages.last; + final recentMessage = channel.state!.messages.last; try { final state = await queryAfterMessage( @@ -146,7 +147,7 @@ class StreamChannelState extends State { } /// Calls [channel.query] updating [queryMessage] stream - Future queryMessages({QueryDirection direction = QueryDirection.top}) { + Future queryMessages({QueryDirection? direction = QueryDirection.top}) { if (direction == QueryDirection.top) return _queryTopMessages(); return _queryBottomMessages(); } @@ -157,12 +158,14 @@ class StreamChannelState extends State { int limit = 50, bool preferOffline = false, }) async { - if (_topPaginationEnded || _queryTopMessagesController.value) return; + if (_topPaginationEnded || + _queryTopMessagesController.value == true || + channel.state == null) return; _queryTopMessagesController.add(true); - Message message; - if (channel.state.threads.containsKey(parentId)) { - final thread = channel.state.threads[parentId]; + Message? message; + if (channel.state!.threads.containsKey(parentId)) { + final thread = channel.state!.threads[parentId]!; if (thread.isNotEmpty) { message = thread.first; } @@ -171,7 +174,7 @@ class StreamChannelState extends State { try { final response = await channel.getReplies( parentId, - PaginationParams( + options: PaginationParams( lessThan: message?.id, limit: limit, ), @@ -188,21 +191,26 @@ class StreamChannelState extends State { /// Query the channel members and watchers Future queryMembersAndWatchers() async { - await widget.channel.query( - membersPagination: PaginationParams( - offset: channel.state.members?.length, - limit: 100, - ), - watchersPagination: PaginationParams( - offset: channel.state.watchers?.length, - limit: 100, - ), - ); + final _members = channel.state?.members; + if (_members != null) { + await widget.channel.query( + membersPagination: PaginationParams( + offset: _members.length, + limit: 100, + ), + watchersPagination: PaginationParams( + offset: _members.length, + limit: 100, + ), + ); + } else { + return; + } } /// Loads channel at specific message Future loadChannelAtMessage( - String messageId, { + String? messageId, { int before = 20, int after = 20, bool preferOffline = false, @@ -214,15 +222,15 @@ class StreamChannelState extends State { preferOffline: preferOffline, ); - Future _queryAtMessage({ - String messageId, + Future> _queryAtMessage({ + String? messageId, int before = 20, int after = 20, bool preferOffline = false, }) async { - if (channel.state == null) return; - channel.state.isUpToDate = false; - channel.state.truncate(); + if (channel.state == null) return []; + channel.state!.isUpToDate = false; + channel.state!.truncate(); if (messageId == null) { await channel.query( @@ -231,8 +239,8 @@ class StreamChannelState extends State { ), preferOffline: preferOffline, ); - channel.state.isUpToDate = true; - return; + channel.state!.isUpToDate = true; + return []; } return Future.wait([ @@ -277,16 +285,15 @@ class StreamChannelState extends State { preferOffline: preferOffline, ); if (state.messages.isEmpty || state.messages.length < limit) { - channel.state.isUpToDate = true; + channel.state?.isUpToDate = true; } return state; } /// Future getMessage(String messageId) async { - var message = channel.state.messages.firstWhere( + var message = channel.state?.messages.firstWhereOrNull( (it) => it.id == messageId, - orElse: () => null, ); if (message == null) { final response = await channel.getMessagesById([messageId]); @@ -298,7 +305,7 @@ class StreamChannelState extends State { /// Reloads the channel with latest message Future reloadChannel() => _queryAtMessage(before: 30); - List> _futures; + late List> _futures; Future get _loadChannelAtMessage async { try { @@ -349,18 +356,18 @@ class StreamChannelState extends State { if (snapshot.hasError) { var message = snapshot.error.toString(); if (snapshot.error is DioError) { - final dioError = snapshot.error as DioError; - if (dioError.type == DioErrorType.RESPONSE) { - message = dioError.message; + final dioError = snapshot.error as DioError?; + if (dioError?.type == DioErrorType.response) { + message = dioError!.message; } else { message = 'Check your connection and retry'; } } return Center(child: Text(message)); } - final initialized = snapshot.data[0]; + final initialized = snapshot.data![0]; // ignore: avoid_bool_literals_in_conditional_expressions - final dataLoaded = initialMessageId == null ? true : snapshot.data[1]; + final dataLoaded = initialMessageId == null ? true : snapshot.data![1]; if (widget.showLoading && (!initialized || !dataLoaded)) { return const Center( child: CircularProgressIndicator(), diff --git a/packages/stream_chat_flutter_core/lib/src/stream_chat_core.dart b/packages/stream_chat_flutter_core/lib/src/stream_chat_core.dart index 15c2ddf3..6e39d5e3 100644 --- a/packages/stream_chat_flutter_core/lib/src/stream_chat_core.dart +++ b/packages/stream_chat_flutter_core/lib/src/stream_chat_core.dart @@ -1,5 +1,6 @@ import 'dart:async'; +import 'package:connectivity_plus/connectivity_plus.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:stream_chat/stream_chat.dart'; @@ -38,14 +39,13 @@ class StreamChatCore extends StatefulWidget { /// [StreamChatCore] is a stateful widget which reacts to system events and /// updates Stream's connection status accordingly. const StreamChatCore({ - Key key, - @required this.client, - @required this.child, + Key? key, + required this.client, + required this.child, this.onBackgroundEventReceived, this.backgroundKeepAlive = const Duration(minutes: 1), - }) : assert(client != null, 'Stream Chat Client should not be null'), - assert(child != null, 'Child should not be null'), - super(key: key); + this.connectivityStream, + }) : super(key: key); /// Instance of Stream Chat Client containing information about the current /// application. @@ -61,23 +61,28 @@ class StreamChatCore extends StatefulWidget { /// Handler called whenever the [client] receives a new [Event] while the app /// is in background. Can be used to display various notifications depending /// upon the [Event.type] - final EventHandler onBackgroundEventReceived; + final EventHandler? onBackgroundEventReceived; + + /// Stream of connectivity result + /// Visible for testing + @visibleForTesting + final Stream? connectivityStream; @override StreamChatCoreState createState() => StreamChatCoreState(); /// Use this method to get the current [StreamChatCoreState] instance static StreamChatCoreState of(BuildContext context) { - StreamChatCoreState streamChatState; + StreamChatCoreState? streamChatState; streamChatState = context.findAncestorStateOfType(); - if (streamChatState == null) { - throw Exception( - 'You must have a StreamChat widget at the top of your widget tree'); - } + assert( + streamChatState != null, + 'You must have a StreamChat widget at the top of your widget tree', + ); - return streamChatState; + return streamChatState!; } } @@ -87,59 +92,119 @@ class StreamChatCoreState extends State /// Initialized client used throughout the application. StreamChatClient get client => widget.client; - Timer _disconnectTimer; + Timer? _disconnectTimer; @override Widget build(BuildContext context) => widget.child; /// The current user - User get user => client.state?.user; + User? get user => client.state.user; /// The current user as a stream - Stream get userStream => client.state?.userStream; + Stream get userStream => client.state.userStream; + + StreamSubscription? _connectivitySubscription; + + var _isInForeground = true; + var _isConnectionAvailable = true; @override void initState() { super.initState(); - WidgetsBinding.instance.addObserver(this); + WidgetsBinding.instance?.addObserver(this); + _subscribeToConnectivityChange(widget.connectivityStream); } - StreamSubscription _eventSubscription; - - @override - void didChangeAppLifecycleState(AppLifecycleState state) { - if (user != null) { - if (state == AppLifecycleState.paused) { - if (widget.onBackgroundEventReceived == null) { - client.disconnect(); - return; - } - _eventSubscription = client.on().listen( - widget.onBackgroundEventReceived, - ); - - void onTimerComplete() { - _eventSubscription.cancel(); - client.disconnect(); - } - - _disconnectTimer = Timer(widget.backgroundKeepAlive, onTimerComplete); - } else if (state == AppLifecycleState.resumed) { - if (_disconnectTimer?.isActive == true) { - _eventSubscription.cancel(); - _disconnectTimer.cancel(); + void _subscribeToConnectivityChange([ + Stream? connectivityStream, + ]) { + if (_connectivitySubscription == null) { + connectivityStream ??= Connectivity().onConnectivityChanged; + _connectivitySubscription = + connectivityStream.distinct().listen((result) { + _isConnectionAvailable = result != ConnectivityResult.none; + if (!_isInForeground) return; + if (_isConnectionAvailable) { + if (client.wsConnectionStatus == ConnectionStatus.disconnected && + user != null) { + client.openConnection(); + } } else { - if (client.wsConnectionStatus == ConnectionStatus.disconnected) { - client.connect(); + if (client.wsConnectionStatus == ConnectionStatus.connected) { + client.closeConnection(); } } - } + }); + } + } + + void _unsubscribeFromConnectivityChange() { + if (_connectivitySubscription != null) { + _connectivitySubscription?.cancel(); + _connectivitySubscription = null; } } + @override + void didUpdateWidget(StreamChatCore oldWidget) { + super.didUpdateWidget(oldWidget); + final connectivityStream = widget.connectivityStream; + if (connectivityStream != oldWidget.connectivityStream) { + _unsubscribeFromConnectivityChange(); + _subscribeToConnectivityChange(connectivityStream); + } + } + + StreamSubscription? _eventSubscription; + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + _isInForeground = [ + AppLifecycleState.resumed, + AppLifecycleState.inactive, + ].contains(state); + if (user != null) { + if (_isInForeground) { + _onForeground(); + } else { + _onBackground(); + } + } + } + + void _onForeground() { + if (_disconnectTimer?.isActive == true) { + _eventSubscription?.cancel(); + _disconnectTimer?.cancel(); + } else if (client.wsConnectionStatus == ConnectionStatus.disconnected && + _isConnectionAvailable) { + client.openConnection(); + } + } + + void _onBackground() { + if (widget.onBackgroundEventReceived == null) { + if (client.wsConnectionStatus != ConnectionStatus.disconnected) { + client.closeConnection(); + } + return; + } + + _eventSubscription = client.on().listen(widget.onBackgroundEventReceived); + + void onTimerComplete() { + _eventSubscription?.cancel(); + client.closeConnection(); + } + + _disconnectTimer = Timer(widget.backgroundKeepAlive, onTimerComplete); + return; + } + @override void dispose() { - WidgetsBinding.instance.removeObserver(this); + WidgetsBinding.instance?.removeObserver(this); + _unsubscribeFromConnectivityChange(); _eventSubscription?.cancel(); _disconnectTimer?.cancel(); super.dispose(); diff --git a/packages/stream_chat_flutter_core/lib/src/user_list_core.dart b/packages/stream_chat_flutter_core/lib/src/user_list_core.dart index f1bd68ee..a45aa904 100644 --- a/packages/stream_chat_flutter_core/lib/src/user_list_core.dart +++ b/packages/stream_chat_flutter_core/lib/src/user_list_core.dart @@ -4,6 +4,7 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:stream_chat/stream_chat.dart'; import 'package:stream_chat_flutter_core/src/users_bloc.dart'; +import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart'; /// /// [UserListCore] is a simplified class that allows fetching users while @@ -57,30 +58,26 @@ import 'package:stream_chat_flutter_core/src/users_bloc.dart'; class UserListCore extends StatefulWidget { /// Instantiate a new [UserListCore] const UserListCore({ - @required this.errorBuilder, - @required this.emptyBuilder, - @required this.loadingBuilder, - @required this.listBuilder, - Key key, + required this.errorBuilder, + required this.emptyBuilder, + required this.loadingBuilder, + required this.listBuilder, + Key? key, this.filter, - this.options, this.sort, + this.presence, this.pagination, this.groupAlphabetically = false, this.userListController, - }) : assert(errorBuilder != null, ''), - assert(emptyBuilder != null, ''), - assert(loadingBuilder != null, ''), - assert(listBuilder != null, ''), - super(key: key); + }) : super(key: key); /// A [UserListController] allows reloading and pagination. /// Use [UserListController.loadData] and [UserListController.paginateData] /// respectively for reloading and pagination. - final UserListController userListController; + final UserListController? userListController; /// The builder that will be used in case of error - final Widget Function(Object error) errorBuilder; + final ErrorBuilder errorBuilder; /// The builder that will be used to build the list final Widget Function(BuildContext context, List users) listBuilder; @@ -94,25 +91,22 @@ class UserListCore extends StatefulWidget { /// The query filters to use. /// You can query on any of the custom fields you've defined on the [Channel]. /// You can also filter other built-in channel fields. - final Map filter; - - /// Query channels options. - /// - /// state: if true returns the Channel state - /// watch: if true listen to changes to this Channel in real time. - final Map options; + final Filter? filter; /// The sorting used for the channels matching the filters. /// Sorting is based on field and direction, multiple sorting options can be /// provided. You can sort based on last_updated, last_message_at, updated_at, /// created_at or member_count. Direction can be ascending or descending. - final List sort; + final List? sort; + + /// If true you’ll receive user presence updates via the websocket events + final bool? presence; /// Pagination parameters /// limit: the number of users to return (max is 30) /// offset: the offset (max is 1000) /// message_limit: how many messages should be included to each channel - final PaginationParams pagination; + final PaginationParams? pagination; /// Set it to true to group users by their first character /// @@ -126,29 +120,38 @@ class UserListCore extends StatefulWidget { /// The current state of the [UserListCore]. class UserListCoreState extends State with WidgetsBindingObserver { + UsersBlocState? _usersBloc; + @override void didChangeDependencies() { + final newUsersBloc = UsersBloc.of(context); + if (newUsersBloc != _usersBloc) { + _usersBloc = newUsersBloc; + loadData(); + } super.didChangeDependencies(); - loadData(); + } + + @override + void initState() { + super.initState(); + _setupController(); + } + + void _setupController() { if (widget.userListController != null) { - widget.userListController.loadData = loadData; - widget.userListController.paginateData = paginateData; + widget.userListController!.loadData = loadData; + widget.userListController!.paginateData = paginateData; } } @override - Widget build(BuildContext context) { - final _usersBloc = UsersBloc.of(context); - return _buildListView(_usersBloc); - } + Widget build(BuildContext context) => _buildListView(); bool get _isListAlreadySorted => widget.sort?.any((e) => e.field == 'name' && e.direction == 1) ?? false; - Stream> _buildUserStream( - UsersBlocState usersBlocState, - ) => - usersBlocState.usersStream.map( + Stream> _buildUserStream() => _usersBloc!.usersStream.map( (users) { if (widget.groupAlphabetically) { var temp = users; @@ -158,14 +161,14 @@ class UserListCoreState extends State } final groupedUsers = >{}; for (final e in temp) { - final alphabet = e.name[0]?.toUpperCase(); + final alphabet = e.name[0].toUpperCase(); groupedUsers[alphabet] = [...groupedUsers[alphabet] ?? [], e]; } final items = []; for (final key in groupedUsers.keys) { items ..add(ListHeaderItem(key)) - ..addAll(groupedUsers[key].map((e) => ListUserItem(e))); + ..addAll(groupedUsers[key]!.map((e) => ListUserItem(e))); } return items; } @@ -173,19 +176,16 @@ class UserListCoreState extends State }, ); - StreamBuilder> _buildListView( - UsersBlocState usersBlocState, - ) => - StreamBuilder( - stream: _buildUserStream(usersBlocState), + StreamBuilder> _buildListView() => StreamBuilder( + stream: _buildUserStream(), builder: (context, snapshot) { if (snapshot.hasError) { - return widget.errorBuilder(snapshot.error); + return widget.errorBuilder(context, snapshot.error!); } if (!snapshot.hasData) { return widget.loadingBuilder(context); } - final items = snapshot.data; + final items = snapshot.data!; if (items.isEmpty) { return widget.emptyBuilder(context); } @@ -193,40 +193,38 @@ class UserListCoreState extends State }, ); - // ignore: public_member_api_docs - Future loadData() { - final _usersBloc = UsersBloc.of(context); - return _usersBloc.queryUsers( - filter: widget.filter, - sort: widget.sort, - pagination: widget.pagination, - options: widget.options, - ); - } + /// Fetches initial users and updates the widget + Future loadData() => _usersBloc!.queryUsers( + filter: widget.filter, + sort: widget.sort, + presence: widget.presence, + pagination: widget.pagination, + ); - // ignore: public_member_api_docs - Future paginateData() { - final _usersBloc = UsersBloc.of(context); - return _usersBloc.queryUsers( - filter: widget.filter, - sort: widget.sort, - pagination: widget.pagination.copyWith( - offset: _usersBloc.users?.length ?? 0, - ), - options: widget.options, - ); - } + /// Fetches more users with updated pagination and updates the widget + Future paginateData() => _usersBloc!.queryUsers( + filter: widget.filter, + sort: widget.sort, + presence: widget.presence, + pagination: widget.pagination!.copyWith( + offset: _usersBloc!.users?.length ?? 0, + ), + ); @override void didUpdateWidget(UserListCore oldWidget) { super.didUpdateWidget(oldWidget); if (widget.filter?.toString() != oldWidget.filter?.toString() || jsonEncode(widget.sort) != jsonEncode(oldWidget.sort) || - widget.options?.toString() != oldWidget.options?.toString() || - widget.pagination?.toJson()?.toString() != - oldWidget.pagination?.toJson()?.toString()) { + widget.presence != oldWidget.presence || + widget.pagination?.toJson().toString() != + oldWidget.pagination?.toJson().toString()) { loadData(); } + + if (widget.userListController != oldWidget.userListController) { + _setupController(); + } } } @@ -235,7 +233,7 @@ class UserListCoreState extends State /// with `USER`. abstract class ListItem { /// Unique key per list item - String get key { + String? get key { if (this is ListHeaderItem) { final header = (this as ListHeaderItem).heading; return 'HEADER-${header.toLowerCase()}'; @@ -250,8 +248,8 @@ abstract class ListItem { /// Helper function to build widget based on ListItem type // ignore: missing_return Widget when({ - @required Widget Function(String heading) headerItem, - @required Widget Function(User user) userItem, + required Widget Function(String heading) headerItem, + required Widget Function(User user) userItem, }) { if (this is ListHeaderItem) { return headerItem((this as ListHeaderItem).heading); @@ -259,6 +257,7 @@ abstract class ListItem { if (this is ListUserItem) { return userItem((this as ListUserItem).user); } + return Container(); } } @@ -283,8 +282,8 @@ class ListUserItem extends ListItem { /// Controller used for paginating data in [ChannelListView] class UserListController { /// Call this function to reload data - AsyncCallback loadData; + AsyncCallback? loadData; /// Call this function to load further data - AsyncCallback paginateData; + AsyncCallback? paginateData; } diff --git a/packages/stream_chat_flutter_core/lib/src/users_bloc.dart b/packages/stream_chat_flutter_core/lib/src/users_bloc.dart index 92881619..e7916478 100644 --- a/packages/stream_chat_flutter_core/lib/src/users_bloc.dart +++ b/packages/stream_chat_flutter_core/lib/src/users_bloc.dart @@ -14,13 +14,9 @@ class UsersBloc extends StatefulWidget { /// Instantiate a new [UsersBloc]. The parameter [child] must be supplied and /// not null. const UsersBloc({ - @required this.child, - Key key, - }) : assert( - child != null, - 'When constructing a UsersBloc, the parameter ' - 'child should not be null.'), - super(key: key); + required this.child, + Key? key, + }) : super(key: key); /// The widget child final Widget child; @@ -30,15 +26,16 @@ class UsersBloc extends StatefulWidget { /// Use this method to get the current [UsersBlocState] instance static UsersBlocState of(BuildContext context) { - UsersBlocState state; + UsersBlocState? state; state = context.findAncestorStateOfType(); - if (state == null) { - throw Exception('You must have a UsersBloc widget as ancestor'); - } + assert( + state != null, + 'You must have a UsersBloc widget as ancestor', + ); - return state; + return state!; } } @@ -46,7 +43,7 @@ class UsersBloc extends StatefulWidget { class UsersBlocState extends State with AutomaticKeepAliveClientMixin { /// The current users list - List get users => _usersController.value; + List? get users => _usersController.valueOrNull; /// The current users list as a stream Stream> get usersStream => _usersController.stream; @@ -58,16 +55,18 @@ class UsersBlocState extends State /// The stream notifying the state of queryUsers call Stream get queryUsersLoading => _queryUsersLoadingController.stream; + late StreamChatCoreState _streamChatCore; + /// The Query Users method allows you to search for users and see if they are /// online/offline. /// [API Reference](https://getstream.io/chat/docs/flutter-dart/query_users/?language=dart) Future queryUsers({ - Map filter, - List sort, - Map options, - PaginationParams pagination, + Filter? filter, + List? sort, + bool? presence, + PaginationParams? pagination, }) async { - final client = StreamChatCore.of(context).client; + final client = _streamChatCore.client; if (_queryUsersLoadingController.value == true) return; @@ -76,16 +75,14 @@ class UsersBlocState extends State } try { - final clear = pagination == null || - pagination.offset == null || - pagination.offset == 0; + final clear = pagination == null || pagination.offset == 0; final oldUsers = List.from(users ?? []); final usersResponse = await client.queryUsers( filter: filter, sort: sort, - options: options, + presence: presence, pagination: pagination, ); @@ -99,6 +96,8 @@ class UsersBlocState extends State _queryUsersLoadingController.add(false); } } catch (e, stk) { + // reset loading controller + _queryUsersLoadingController.add(false); if (_usersController.hasValue) { _queryUsersLoadingController.addError(e, stk); } else { @@ -107,6 +106,12 @@ class UsersBlocState extends State } } + @override + void didChangeDependencies() { + _streamChatCore = StreamChatCore.of(context); + super.didChangeDependencies(); + } + @override Widget build(BuildContext context) { super.build(context); diff --git a/packages/stream_chat_flutter_core/lib/stream_chat_flutter_core.dart b/packages/stream_chat_flutter_core/lib/stream_chat_flutter_core.dart index 01463de0..8bd41c76 100644 --- a/packages/stream_chat_flutter_core/lib/stream_chat_flutter_core.dart +++ b/packages/stream_chat_flutter_core/lib/stream_chat_flutter_core.dart @@ -1,7 +1,9 @@ library stream_chat_flutter_core; +export 'package:connectivity_plus/connectivity_plus.dart'; export 'package:stream_chat/stream_chat.dart'; +export 'src/better_stream_builder.dart'; export 'src/channel_list_core.dart' hide ChannelListCoreState; export 'src/channels_bloc.dart'; export 'src/lazy_load_scroll_view.dart'; diff --git a/packages/stream_chat_flutter_core/pubspec.yaml b/packages/stream_chat_flutter_core/pubspec.yaml index 011e1cb2..cc59f9fe 100644 --- a/packages/stream_chat_flutter_core/pubspec.yaml +++ b/packages/stream_chat_flutter_core/pubspec.yaml @@ -1,22 +1,26 @@ name: stream_chat_flutter_core homepage: https://github.com/GetStream/stream-chat-flutter description: Stream Chat official Flutter SDK Core. Build your own chat experience using Dart and Flutter. -version: 1.5.3 +version: 2.0.0 repository: https://github.com/GetStream/stream-chat-flutter issue_tracker: https://github.com/GetStream/stream-chat-flutter/issues environment: - sdk: ">=2.7.0 <3.0.0" + sdk: '>=2.12.0 <3.0.0' flutter: ">=1.17.0" dependencies: + collection: ^1.15.0 + connectivity_plus: ^1.0.1 flutter: sdk: flutter - meta: ^1.2.4 - rxdart: ^0.25.0 - stream_chat: ^1.5.3 + meta: ^1.3.0 + rxdart: ^0.27.0 + stream_chat: ^2.0.0 dev_dependencies: + fake_async: ^1.2.0 flutter_test: sdk: flutter - mockito: ^4.1.3 \ No newline at end of file + mocktail: ^0.1.3 + diff --git a/packages/stream_chat_flutter_core/test/channel_list_core_test.dart b/packages/stream_chat_flutter_core/test/channel_list_core_test.dart index b0fb9ded..47aa2bf9 100644 --- a/packages/stream_chat_flutter_core/test/channel_list_core_test.dart +++ b/packages/stream_chat_flutter_core/test/channel_list_core_test.dart @@ -1,86 +1,33 @@ import 'dart:async'; -import 'package:flutter_test/flutter_test.dart'; import 'package:flutter/widgets.dart'; -import 'package:mockito/mockito.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; import 'package:stream_chat_flutter_core/src/channel_list_core.dart'; import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart'; import 'mocks.dart'; void main() { - const pagination = PaginationParams(offset: 0, limit: 3); + const pagination = PaginationParams(limit: 3); List _generateChannels( StreamChatClient client, { int count = 3, int offset = 0, - }) { - return List.generate( - count, - (index) { - index = index + offset; - return Channel( - client, - 'testType$index', - 'testId$index', - {'extra_data_key': 'extra_data_value_$index'}, - ); - }, - ); - } - - test( - 'should throw assertion error in case listBuilder is null', - () { - final channelListCore = () => ChannelListCore( - listBuilder: null, - loadingBuilder: (BuildContext context) => Offstage(), - emptyBuilder: (BuildContext context) => Offstage(), - errorBuilder: (BuildContext context, Object error) => Offstage(), + }) => + List.generate( + count, + (index) { + index = index + offset; + return Channel( + client, + 'testType$index', + 'testId$index', + extraData: {'extra_data_key': 'extra_data_value_$index'}, ); - expect(channelListCore, throwsA(isA())); - }, - ); - - test( - 'should throw assertion error in case loadingBuilder is null', - () { - final channelListCore = () => ChannelListCore( - listBuilder: (_, __) => Offstage(), - loadingBuilder: null, - emptyBuilder: (BuildContext context) => Offstage(), - errorBuilder: (BuildContext context, Object error) => Offstage(), - ); - expect(channelListCore, throwsA(isA())); - }, - ); - - test( - 'should throw assertion error in case emptyBuilder is null', - () { - final channelListCore = () => ChannelListCore( - listBuilder: (_, __) => Offstage(), - loadingBuilder: (BuildContext context) => Offstage(), - emptyBuilder: null, - errorBuilder: (BuildContext context, Object error) => Offstage(), - ); - expect(channelListCore, throwsA(isA())); - }, - ); - - test( - 'should throw assertion error in case errorBuilder is null', - () { - final channelListCore = () => ChannelListCore( - listBuilder: (_, __) => Offstage(), - loadingBuilder: (BuildContext context) => Offstage(), - emptyBuilder: (BuildContext context) => Offstage(), - errorBuilder: null, - ); - expect(channelListCore, throwsA(isA())); - }, - ); + }, + ); testWidgets( 'should throw if ChannelListCore is used where ChannelsBloc is not present ' @@ -89,16 +36,16 @@ void main() { const channelListCoreKey = Key('channelListCore'); final channelListCore = ChannelListCore( key: channelListCoreKey, - listBuilder: (_, __) => Offstage(), - loadingBuilder: (BuildContext context) => Offstage(), - emptyBuilder: (BuildContext context) => Offstage(), - errorBuilder: (BuildContext context, Object error) => Offstage(), + listBuilder: (_, __) => const Offstage(), + loadingBuilder: (BuildContext context) => const Offstage(), + emptyBuilder: (BuildContext context) => const Offstage(), + errorBuilder: (BuildContext context, Object error) => const Offstage(), ); await tester.pumpWidget(channelListCore); expect(find.byKey(channelListCoreKey), findsNothing); - expect(tester.takeException(), isInstanceOf()); + expect(tester.takeException(), isInstanceOf()); }, ); @@ -108,15 +55,16 @@ void main() { const channelListCoreKey = Key('channelListCore'); final channelListCore = ChannelListCore( key: channelListCoreKey, - listBuilder: (_, __) => Offstage(), - loadingBuilder: (BuildContext context) => Offstage(), - emptyBuilder: (BuildContext context) => Offstage(), - errorBuilder: (BuildContext context, Object error) => Offstage(), + listBuilder: (_, __) => const Offstage(), + loadingBuilder: (BuildContext context) => const Offstage(), + emptyBuilder: (BuildContext context) => const Offstage(), + errorBuilder: (BuildContext context, Object error) => const Offstage(), ); final mockClient = MockClient(); - when(mockClient.on(any, any, any, any)).thenAnswer((_) => Stream.empty()); + when(() => mockClient.on(any(), any(), any(), any())) + .thenAnswer((_) => const Stream.empty()); await tester.pumpWidget( StreamChatCore( @@ -139,10 +87,10 @@ void main() { final controller = ChannelListController(); final channelListCore = ChannelListCore( key: channelListCoreKey, - listBuilder: (_, __) => Offstage(), - loadingBuilder: (BuildContext context) => Offstage(), - emptyBuilder: (BuildContext context) => Offstage(), - errorBuilder: (BuildContext context, Object error) => Offstage(), + listBuilder: (_, __) => const Offstage(), + loadingBuilder: (BuildContext context) => const Offstage(), + emptyBuilder: (BuildContext context) => const Offstage(), + errorBuilder: (BuildContext context, Object error) => const Offstage(), channelListController: controller, ); @@ -151,7 +99,8 @@ void main() { final mockClient = MockClient(); - when(mockClient.on(any, any, any, any)).thenAnswer((_) => Stream.empty()); + when(() => mockClient.on(any(), any(), any(), any())) + .thenAnswer((_) => const Stream.empty()); await tester.pumpWidget( StreamChatCore( @@ -175,9 +124,9 @@ void main() { const errorWidgetKey = Key('errorWidget'); final channelListCore = ChannelListCore( key: channelListCoreKey, - listBuilder: (_, __) => Offstage(), - loadingBuilder: (BuildContext context) => Offstage(), - emptyBuilder: (BuildContext context) => Offstage(), + listBuilder: (_, __) => const Offstage(), + loadingBuilder: (BuildContext context) => const Offstage(), + emptyBuilder: (BuildContext context) => const Offstage(), errorBuilder: (BuildContext context, Object error) => Container(key: errorWidgetKey), pagination: pagination, @@ -185,15 +134,20 @@ void main() { final mockClient = MockClient(); - when(mockClient.on(any, any, any, any)).thenAnswer((_) => Stream.empty()); + when(() => mockClient.on(any(), any(), any(), any())) + .thenAnswer((_) => const Stream.empty()); const error = 'Error! Error! Error!'; - when(mockClient.queryChannels( - filter: anyNamed('filter'), - sort: anyNamed('sort'), - options: anyNamed('options'), - paginationParams: pagination, - )).thenThrow(error); + when(() => mockClient.queryChannels( + filter: any(named: 'filter'), + sort: any(named: 'sort'), + state: any(named: 'state'), + watch: any(named: 'watch'), + presence: any(named: 'presence'), + memberLimit: any(named: 'memberLimit'), + messageLimit: any(named: 'messageLimit'), + paginationParams: pagination, + )).thenThrow(error); await tester.pumpWidget( StreamChatCore( @@ -208,40 +162,49 @@ void main() { expect(find.byKey(errorWidgetKey), findsOneWidget); - verify(mockClient.queryChannels( - filter: anyNamed('filter'), - sort: anyNamed('sort'), - options: anyNamed('options'), - paginationParams: pagination, - )).called(1); + verify(() => mockClient.queryChannels( + filter: any(named: 'filter'), + sort: any(named: 'sort'), + state: any(named: 'state'), + watch: any(named: 'watch'), + presence: any(named: 'presence'), + memberLimit: any(named: 'memberLimit'), + messageLimit: any(named: 'messageLimit'), + paginationParams: pagination, + )).called(1); }, ); testWidgets( - 'should build empty widget if channelsBlocState.channelsStream emits empty data', + '''should build empty widget if channelsBlocState.channelsStream emits empty data''', (tester) async { const channelListCoreKey = Key('channelListCore'); const emptyWidgetKey = Key('emptyWidget'); final channelListCore = ChannelListCore( key: channelListCoreKey, - listBuilder: (_, __) => Offstage(), - loadingBuilder: (BuildContext context) => Offstage(), + listBuilder: (_, __) => const Offstage(), + loadingBuilder: (BuildContext context) => const Offstage(), emptyBuilder: (BuildContext context) => Container(key: emptyWidgetKey), - errorBuilder: (BuildContext context, Object error) => Offstage(), + errorBuilder: (BuildContext context, Object error) => const Offstage(), pagination: pagination, ); final mockClient = MockClient(); - when(mockClient.on(any, any, any, any)).thenAnswer((_) => Stream.empty()); + when(() => mockClient.on(any(), any(), any(), any())) + .thenAnswer((_) => const Stream.empty()); const channels = []; - when(mockClient.queryChannels( - filter: anyNamed('filter'), - sort: anyNamed('sort'), - options: anyNamed('options'), - paginationParams: pagination, - )).thenAnswer((_) => Stream.value(channels)); + when(() => mockClient.queryChannels( + filter: any(named: 'filter'), + sort: any(named: 'sort'), + state: any(named: 'state'), + watch: any(named: 'watch'), + presence: any(named: 'presence'), + memberLimit: any(named: 'memberLimit'), + messageLimit: any(named: 'messageLimit'), + paginationParams: pagination, + )).thenAnswer((_) => Stream.value(channels)); await tester.pumpWidget( StreamChatCore( @@ -256,40 +219,49 @@ void main() { expect(find.byKey(emptyWidgetKey), findsOneWidget); - verify(mockClient.queryChannels( - filter: anyNamed('filter'), - sort: anyNamed('sort'), - options: anyNamed('options'), - paginationParams: pagination, - )).called(1); + verify(() => mockClient.queryChannels( + filter: any(named: 'filter'), + sort: any(named: 'sort'), + state: any(named: 'state'), + watch: any(named: 'watch'), + presence: any(named: 'presence'), + memberLimit: any(named: 'memberLimit'), + messageLimit: any(named: 'messageLimit'), + paginationParams: pagination, + )).called(1); }, ); testWidgets( - 'should build list widget if channelsBlocState.channelsStream emits some data', + '''should build list widget if channelsBlocState.channelsStream emits some data''', (tester) async { const channelListCoreKey = Key('channelListCore'); const listWidgetKey = Key('listWidget'); final channelListCore = ChannelListCore( key: channelListCoreKey, listBuilder: (_, __) => Container(key: listWidgetKey), - loadingBuilder: (BuildContext context) => Offstage(), - emptyBuilder: (BuildContext context) => Offstage(), - errorBuilder: (BuildContext context, Object error) => Offstage(), + loadingBuilder: (BuildContext context) => const Offstage(), + emptyBuilder: (BuildContext context) => const Offstage(), + errorBuilder: (BuildContext context, Object error) => const Offstage(), pagination: pagination, ); final mockClient = MockClient(); - when(mockClient.on(any, any, any, any)).thenAnswer((_) => Stream.empty()); + when(() => mockClient.on(any(), any(), any(), any())) + .thenAnswer((_) => const Stream.empty()); final channels = _generateChannels(mockClient); - when(mockClient.queryChannels( - filter: anyNamed('filter'), - sort: anyNamed('sort'), - options: anyNamed('options'), - paginationParams: pagination, - )).thenAnswer((_) => Stream.value(channels)); + when(() => mockClient.queryChannels( + filter: any(named: 'filter'), + sort: any(named: 'sort'), + state: any(named: 'state'), + watch: any(named: 'watch'), + presence: any(named: 'presence'), + memberLimit: any(named: 'memberLimit'), + messageLimit: any(named: 'messageLimit'), + paginationParams: pagination, + )).thenAnswer((_) => Stream.value(channels)); await tester.pumpWidget( StreamChatCore( @@ -304,12 +276,16 @@ void main() { expect(find.byKey(listWidgetKey), findsOneWidget); - verify(mockClient.queryChannels( - filter: anyNamed('filter'), - sort: anyNamed('sort'), - options: anyNamed('options'), - paginationParams: pagination, - )).called(1); + verify(() => mockClient.queryChannels( + filter: any(named: 'filter'), + sort: any(named: 'sort'), + state: any(named: 'state'), + watch: any(named: 'watch'), + presence: any(named: 'presence'), + memberLimit: any(named: 'memberLimit'), + messageLimit: any(named: 'messageLimit'), + paginationParams: pagination, + )).called(1); }, ); @@ -321,31 +297,34 @@ void main() { const listWidgetKey = Key('listWidget'); final channelListCore = ChannelListCore( key: channelListCoreKey, - listBuilder: (_, channels) { - return Container( - key: listWidgetKey, - child: Text( - channels.map((e) => e.cid).join(','), - ), - ); - }, - loadingBuilder: (BuildContext context) => Offstage(), - emptyBuilder: (BuildContext context) => Offstage(), - errorBuilder: (BuildContext context, Object error) => Offstage(), + listBuilder: (_, channels) => Container( + key: listWidgetKey, + child: Text( + channels.map((e) => e.cid).join(','), + ), + ), + loadingBuilder: (BuildContext context) => const Offstage(), + emptyBuilder: (BuildContext context) => const Offstage(), + errorBuilder: (BuildContext context, Object error) => const Offstage(), pagination: pagination, ); final mockClient = MockClient(); - when(mockClient.on(any, any, any, any)).thenAnswer((_) => Stream.empty()); + when(() => mockClient.on(any(), any(), any(), any())) + .thenAnswer((_) => const Stream.empty()); final channels = _generateChannels(mockClient); - when(mockClient.queryChannels( - filter: anyNamed('filter'), - sort: anyNamed('sort'), - options: anyNamed('options'), - paginationParams: pagination, - )).thenAnswer((_) => Stream.value(channels)); + when(() => mockClient.queryChannels( + filter: any(named: 'filter'), + sort: any(named: 'sort'), + state: any(named: 'state'), + watch: any(named: 'watch'), + presence: any(named: 'presence'), + memberLimit: any(named: 'memberLimit'), + messageLimit: any(named: 'messageLimit'), + paginationParams: pagination, + )).thenAnswer((_) => Stream.value(channels)); await tester.pumpWidget( Directionality( @@ -364,12 +343,16 @@ void main() { expect(find.byKey(listWidgetKey), findsOneWidget); expect(find.text(channels.map((e) => e.cid).join(',')), findsOneWidget); - verify(mockClient.queryChannels( - filter: anyNamed('filter'), - sort: anyNamed('sort'), - options: anyNamed('options'), - paginationParams: pagination, - )).called(1); + verify(() => mockClient.queryChannels( + filter: any(named: 'filter'), + sort: any(named: 'sort'), + state: any(named: 'state'), + watch: any(named: 'watch'), + presence: any(named: 'presence'), + memberLimit: any(named: 'memberLimit'), + messageLimit: any(named: 'messageLimit'), + paginationParams: pagination, + )).called(1); final channelListCoreState = tester.state( find.byKey(channelListCoreKey), @@ -378,12 +361,16 @@ void main() { final offset = channels.length; final paginatedChannels = _generateChannels(mockClient, offset: offset); final updatedPagination = pagination.copyWith(offset: offset); - when(mockClient.queryChannels( - filter: anyNamed('filter'), - sort: anyNamed('sort'), - options: anyNamed('options'), - paginationParams: updatedPagination, - )).thenAnswer((_) => Stream.value(paginatedChannels)); + when(() => mockClient.queryChannels( + filter: any(named: 'filter'), + sort: any(named: 'sort'), + state: any(named: 'state'), + watch: any(named: 'watch'), + presence: any(named: 'presence'), + memberLimit: any(named: 'memberLimit'), + messageLimit: any(named: 'messageLimit'), + paginationParams: updatedPagination, + )).thenAnswer((_) => Stream.value(paginatedChannels)); await channelListCoreState.paginateData(); @@ -398,12 +385,16 @@ void main() { findsOneWidget, ); - verify(mockClient.queryChannels( - filter: anyNamed('filter'), - sort: anyNamed('sort'), - options: anyNamed('options'), - paginationParams: updatedPagination, - )).called(1); + verify(() => mockClient.queryChannels( + filter: any(named: 'filter'), + sort: any(named: 'sort'), + state: any(named: 'state'), + watch: any(named: 'watch'), + presence: any(named: 'presence'), + memberLimit: any(named: 'memberLimit'), + messageLimit: any(named: 'messageLimit'), + paginationParams: updatedPagination, + )).called(1); }, ); @@ -411,39 +402,43 @@ void main() { 'should rebuild ChannelListCore with updated widget data ' 'on calling setState()', (tester) async { - StateSetter _stateSetter; - int limit = pagination.limit; + StateSetter? _stateSetter; + var limit = pagination.limit; const channelListCoreKey = Key('channelListCore'); const listWidgetKey = Key('listWidget'); ChannelListCore channelListCoreBuilder(int limit) => ChannelListCore( key: channelListCoreKey, - listBuilder: (_, channels) { - return Container( - key: listWidgetKey, - child: Text( - channels.map((e) => e.cid).join(','), - ), - ); - }, - loadingBuilder: (BuildContext context) => Offstage(), - emptyBuilder: (BuildContext context) => Offstage(), - errorBuilder: (BuildContext context, Object error) => Offstage(), + listBuilder: (_, channels) => Container( + key: listWidgetKey, + child: Text( + channels.map((e) => e.cid).join(','), + ), + ), + loadingBuilder: (BuildContext context) => const Offstage(), + emptyBuilder: (BuildContext context) => const Offstage(), + errorBuilder: (BuildContext context, Object error) => + const Offstage(), pagination: pagination.copyWith(limit: limit), ); final mockClient = MockClient(); - when(mockClient.on(any, any, any, any)).thenAnswer((_) => Stream.empty()); + when(() => mockClient.on(any(), any(), any(), any())) + .thenAnswer((_) => const Stream.empty()); final channels = _generateChannels(mockClient); - when(mockClient.queryChannels( - filter: anyNamed('filter'), - sort: anyNamed('sort'), - options: anyNamed('options'), - paginationParams: pagination, - )).thenAnswer((_) => Stream.value(channels)); + when(() => mockClient.queryChannels( + filter: any(named: 'filter'), + sort: any(named: 'sort'), + state: any(named: 'state'), + watch: any(named: 'watch'), + presence: any(named: 'presence'), + memberLimit: any(named: 'memberLimit'), + messageLimit: any(named: 'messageLimit'), + paginationParams: pagination, + )).thenAnswer((_) => Stream.value(channels)); await tester.pumpWidget( Directionality( @@ -466,24 +461,32 @@ void main() { expect(find.byKey(listWidgetKey), findsOneWidget); expect(find.text(channels.map((e) => e.cid).join(',')), findsOneWidget); - verify(mockClient.queryChannels( - filter: anyNamed('filter'), - sort: anyNamed('sort'), - options: anyNamed('options'), - paginationParams: pagination, - )).called(1); + verify(() => mockClient.queryChannels( + filter: any(named: 'filter'), + sort: any(named: 'sort'), + state: any(named: 'state'), + watch: any(named: 'watch'), + presence: any(named: 'presence'), + memberLimit: any(named: 'memberLimit'), + messageLimit: any(named: 'messageLimit'), + paginationParams: pagination, + )).called(1); // Rebuilding ChannelListCore with new pagination limit - _stateSetter(() => limit = 6); + _stateSetter?.call(() => limit = 6); final updatedChannels = _generateChannels(mockClient, count: limit); final updatedPagination = pagination.copyWith(limit: limit); - when(mockClient.queryChannels( - filter: anyNamed('filter'), - sort: anyNamed('sort'), - options: anyNamed('options'), - paginationParams: updatedPagination, - )).thenAnswer((_) => Stream.value(updatedChannels)); + when(() => mockClient.queryChannels( + filter: any(named: 'filter'), + sort: any(named: 'sort'), + state: any(named: 'state'), + watch: any(named: 'watch'), + presence: any(named: 'presence'), + memberLimit: any(named: 'memberLimit'), + messageLimit: any(named: 'messageLimit'), + paginationParams: updatedPagination, + )).thenAnswer((_) => Stream.value(updatedChannels)); await tester.pumpAndSettle(); @@ -493,12 +496,16 @@ void main() { findsOneWidget, ); - verify(mockClient.queryChannels( - filter: anyNamed('filter'), - sort: anyNamed('sort'), - options: anyNamed('options'), - paginationParams: updatedPagination, - )).called(1); + verify(() => mockClient.queryChannels( + filter: any(named: 'filter'), + sort: any(named: 'sort'), + state: any(named: 'state'), + watch: any(named: 'watch'), + presence: any(named: 'presence'), + memberLimit: any(named: 'memberLimit'), + messageLimit: any(named: 'messageLimit'), + paginationParams: updatedPagination, + )).called(1); }, ); } diff --git a/packages/stream_chat_flutter_core/test/channels_bloc_test.dart b/packages/stream_chat_flutter_core/test/channels_bloc_test.dart index e021e9b7..a237a23c 100644 --- a/packages/stream_chat_flutter_core/test/channels_bloc_test.dart +++ b/packages/stream_chat_flutter_core/test/channels_bloc_test.dart @@ -2,50 +2,41 @@ import 'dart:async'; import 'package:flutter/widgets.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:mockito/mockito.dart'; +import 'package:mocktail/mocktail.dart'; import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart'; import 'matchers/channel_matcher.dart'; import 'mocks.dart'; void main() { + setUpAll(() { + registerFallbackValue(const PaginationParams()); + }); + List _generateChannels( StreamChatClient client, { int count = 3, int offset = 0, - }) { - return List.generate( - count, - (index) { - index = index + offset; - return Channel( - client, - 'testType$index', - 'testId$index', - {'extra_data_key': 'extra_data_value_$index'}, - ); - }, - ); - } - - test( - 'should throw assertion error if child is null', - () async { - const channelsBlocKey = Key('channelsBloc'); - final channelsBloc = () => ChannelsBloc( - key: channelsBlocKey, - child: null, + }) => + List.generate( + count, + (index) { + index = index + offset; + return Channel( + client, + 'testType$index', + 'testId$index', + extraData: {'extra_data_key': 'extra_data_value_$index'}, ); - expect(channelsBloc, throwsA(isA())); - }, - ); + }, + ); testWidgets( - 'should throw if ChannelsBloc is used where StreamChat is not present in the widget tree', + '''should throw if ChannelsBloc is used where StreamChat is not present in the widget tree''', (tester) async { const channelsBlocKey = Key('channelsBloc'); const childKey = Key('child'); - final channelsBloc = ChannelsBloc( + const channelsBloc = ChannelsBloc( key: channelsBlocKey, child: Offstage(key: childKey), ); @@ -54,7 +45,7 @@ void main() { expect(find.byKey(channelsBlocKey), findsNothing); expect(find.byKey(childKey), findsNothing); - expect(tester.takeException(), isInstanceOf()); + expect(tester.takeException(), isInstanceOf()); }, ); @@ -63,14 +54,15 @@ void main() { (tester) async { const channelsBlocKey = Key('channelsBloc'); const childKey = Key('child'); - final channelsBloc = ChannelsBloc( + const channelsBloc = ChannelsBloc( key: channelsBlocKey, child: Offstage(key: childKey), ); final mockClient = MockClient(); - when(mockClient.on(any, any, any, any)).thenAnswer((_) => Stream.empty()); + when(() => mockClient.on(any(), any(), any(), any())) + .thenAnswer((_) => const Stream.empty()); await tester.pumpWidget( StreamChatCore( @@ -93,15 +85,14 @@ void main() { key: channelsBlocKey, child: Builder( key: childKey, - builder: (context) { - return Offstage(); - }, + builder: (context) => const Offstage(), ), ); final mockClient = MockClient(); - when(mockClient.on(any, any, any, any)).thenAnswer((_) => Stream.empty()); + when(() => mockClient.on(any(), any(), any(), any())) + .thenAnswer((_) => const Stream.empty()); await tester.pumpWidget( StreamChatCore( @@ -117,12 +108,16 @@ void main() { final offlineChannels = _generateChannels(mockClient); final onlineChannels = _generateChannels(mockClient, offset: 3); - when(mockClient.queryChannels( - filter: anyNamed('filter'), - sort: anyNamed('sort'), - options: anyNamed('options'), - paginationParams: anyNamed('paginationParams'), - )).thenAnswer( + when(() => mockClient.queryChannels( + filter: any(named: 'filter'), + sort: any(named: 'sort'), + state: any(named: 'state'), + watch: any(named: 'watch'), + presence: any(named: 'presence'), + memberLimit: any(named: 'memberLimit'), + messageLimit: any(named: 'messageLimit'), + paginationParams: any(named: 'paginationParams'), + )).thenAnswer( (_) => Stream.fromIterable([offlineChannels, onlineChannels]), ); @@ -136,12 +131,16 @@ void main() { ]), ); - verify(mockClient.queryChannels( - filter: anyNamed('filter'), - sort: anyNamed('sort'), - options: anyNamed('options'), - paginationParams: anyNamed('paginationParams'), - )).called(1); + verify(() => mockClient.queryChannels( + filter: any(named: 'filter'), + sort: any(named: 'sort'), + state: any(named: 'state'), + watch: any(named: 'watch'), + presence: any(named: 'presence'), + memberLimit: any(named: 'memberLimit'), + messageLimit: any(named: 'messageLimit'), + paginationParams: any(named: 'paginationParams'), + )).called(1); }, ); @@ -155,15 +154,14 @@ void main() { key: channelsBlocKey, child: Builder( key: childKey, - builder: (context) { - return Offstage(); - }, + builder: (context) => const Offstage(), ), ); final mockClient = MockClient(); - when(mockClient.on(any, any, any, any)).thenAnswer((_) => Stream.empty()); + when(() => mockClient.on(any(), any(), any(), any())) + .thenAnswer((_) => const Stream.empty()); await tester.pumpWidget( StreamChatCore( @@ -176,14 +174,18 @@ void main() { find.byKey(channelsBlocKey), ); - final error = 'Error! Error! Error!'; + const error = 'Error! Error! Error!'; - when(mockClient.queryChannels( - filter: anyNamed('filter'), - sort: anyNamed('sort'), - options: anyNamed('options'), - paginationParams: anyNamed('paginationParams'), - )).thenThrow(error); + when(() => mockClient.queryChannels( + filter: any(named: 'filter'), + sort: any(named: 'sort'), + state: any(named: 'state'), + watch: any(named: 'watch'), + presence: any(named: 'presence'), + memberLimit: any(named: 'memberLimit'), + messageLimit: any(named: 'messageLimit'), + paginationParams: any(named: 'paginationParams'), + )).thenThrow(error); channelsBlocState.queryChannels(); @@ -192,12 +194,16 @@ void main() { emitsError(error), ); - verify(mockClient.queryChannels( - filter: anyNamed('filter'), - sort: anyNamed('sort'), - options: anyNamed('options'), - paginationParams: anyNamed('paginationParams'), - )).called(1); + verify(() => mockClient.queryChannels( + filter: any(named: 'filter'), + sort: any(named: 'sort'), + state: any(named: 'state'), + watch: any(named: 'watch'), + presence: any(named: 'presence'), + memberLimit: any(named: 'memberLimit'), + messageLimit: any(named: 'messageLimit'), + paginationParams: any(named: 'paginationParams'), + )).called(1); }, ); @@ -207,14 +213,15 @@ void main() { 'through queryChannelsLoading', (tester) async { const channelsBlocKey = Key('channelsBloc'); - final channelsBloc = ChannelsBloc( + const channelsBloc = ChannelsBloc( key: channelsBlocKey, child: Offstage(), ); final mockClient = MockClient(); - when(mockClient.on(any, any, any, any)).thenAnswer((_) => Stream.empty()); + when(() => mockClient.on(any(), any(), any(), any())) + .thenAnswer((_) => const Stream.empty()); await tester.pumpWidget( StreamChatCore( @@ -229,38 +236,53 @@ void main() { final channels = _generateChannels(mockClient); - when(mockClient.queryChannels( - filter: anyNamed('filter'), - sort: anyNamed('sort'), - options: anyNamed('options'), - paginationParams: anyNamed('paginationParams'), - )).thenAnswer((_) => Stream.value(channels)); + when(() => mockClient.queryChannels( + filter: any(named: 'filter'), + sort: any(named: 'sort'), + state: any(named: 'state'), + watch: any(named: 'watch'), + presence: any(named: 'presence'), + memberLimit: any(named: 'memberLimit'), + messageLimit: any(named: 'messageLimit'), + paginationParams: any(named: 'paginationParams'), + )).thenAnswer((_) => Stream.value(channels)); - channelsBlocState.queryChannels(); + const pagination = PaginationParams(limit: 3); + channelsBlocState.queryChannels( + paginationParams: pagination, + ); await expectLater( channelsBlocState.channelsStream, emits(isSameChannelListAs(channels)), ); - verify(mockClient.queryChannels( - filter: anyNamed('filter'), - sort: anyNamed('sort'), - options: anyNamed('options'), - paginationParams: anyNamed('paginationParams'), - )).called(1); + verify(() => mockClient.queryChannels( + filter: any(named: 'filter'), + sort: any(named: 'sort'), + state: any(named: 'state'), + watch: any(named: 'watch'), + presence: any(named: 'presence'), + memberLimit: any(named: 'memberLimit'), + messageLimit: any(named: 'messageLimit'), + paginationParams: any(named: 'paginationParams'), + )).called(1); final offset = channels.length; - final paginationParams = PaginationParams(offset: offset); + final paginationParams = pagination.copyWith(offset: offset); final newChannels = _generateChannels(mockClient, offset: offset); - when(mockClient.queryChannels( - filter: anyNamed('filter'), - sort: anyNamed('sort'), - options: anyNamed('options'), - paginationParams: paginationParams, - )).thenAnswer( + when(() => mockClient.queryChannels( + filter: any(named: 'filter'), + sort: any(named: 'sort'), + state: any(named: 'state'), + watch: any(named: 'watch'), + presence: any(named: 'presence'), + memberLimit: any(named: 'memberLimit'), + messageLimit: any(named: 'messageLimit'), + paginationParams: paginationParams, + )).thenAnswer( (_) => Stream.value(newChannels), ); @@ -277,12 +299,16 @@ void main() { ), ]); - verify(mockClient.queryChannels( - filter: anyNamed('filter'), - sort: anyNamed('sort'), - options: anyNamed('options'), - paginationParams: paginationParams, - )).called(1); + verify(() => mockClient.queryChannels( + filter: any(named: 'filter'), + sort: any(named: 'sort'), + state: any(named: 'state'), + watch: any(named: 'watch'), + presence: any(named: 'presence'), + memberLimit: any(named: 'memberLimit'), + messageLimit: any(named: 'messageLimit'), + paginationParams: paginationParams, + )).called(1); }, ); @@ -292,14 +318,15 @@ void main() { 'client.queryChannels() throws', (tester) async { const channelsBlocKey = Key('channelsBloc'); - final channelsBloc = ChannelsBloc( + const channelsBloc = ChannelsBloc( key: channelsBlocKey, child: Offstage(), ); final mockClient = MockClient(); - when(mockClient.on(any, any, any, any)).thenAnswer((_) => Stream.empty()); + when(() => mockClient.on(any(), any(), any(), any())) + .thenAnswer((_) => const Stream.empty()); await tester.pumpWidget( StreamChatCore( @@ -313,39 +340,53 @@ void main() { ); final channels = _generateChannels(mockClient); + const paginationParams = PaginationParams( + limit: 3, + ); - when(mockClient.queryChannels( - filter: anyNamed('filter'), - sort: anyNamed('sort'), - options: anyNamed('options'), - paginationParams: anyNamed('paginationParams'), - )).thenAnswer((_) => Stream.value(channels)); + when(() => mockClient.queryChannels( + filter: any(named: 'filter'), + sort: any(named: 'sort'), + state: any(named: 'state'), + watch: any(named: 'watch'), + presence: any(named: 'presence'), + memberLimit: any(named: 'memberLimit'), + messageLimit: any(named: 'messageLimit'), + paginationParams: paginationParams, + )).thenAnswer((_) => Stream.value(channels)); - channelsBlocState.queryChannels(); + channelsBlocState.queryChannels( + paginationParams: paginationParams, + ); await expectLater( channelsBlocState.channelsStream, emits(isSameChannelListAs(channels)), ); - verify(mockClient.queryChannels( - filter: anyNamed('filter'), - sort: anyNamed('sort'), - options: anyNamed('options'), - paginationParams: anyNamed('paginationParams'), - )).called(1); + verify(() => mockClient.queryChannels( + filter: any(named: 'filter'), + sort: any(named: 'sort'), + state: any(named: 'state'), + watch: any(named: 'watch'), + presence: any(named: 'presence'), + memberLimit: any(named: 'memberLimit'), + messageLimit: any(named: 'messageLimit'), + paginationParams: paginationParams, + )).called(1); - final offset = channels.length; - final paginationParams = PaginationParams(offset: offset); + const error = 'Error! Error! Error!'; - final error = 'Error! Error! Error!'; - - when(mockClient.queryChannels( - filter: anyNamed('filter'), - sort: anyNamed('sort'), - options: anyNamed('options'), - paginationParams: paginationParams, - )).thenThrow(error); + when(() => mockClient.queryChannels( + filter: any(named: 'filter'), + sort: any(named: 'sort'), + state: any(named: 'state'), + watch: any(named: 'watch'), + presence: any(named: 'presence'), + memberLimit: any(named: 'memberLimit'), + messageLimit: any(named: 'messageLimit'), + paginationParams: paginationParams, + )).thenThrow(error); channelsBlocState.queryChannels(paginationParams: paginationParams); @@ -354,17 +395,21 @@ void main() { emitsError(error), ); - verify(mockClient.queryChannels( - filter: anyNamed('filter'), - sort: anyNamed('sort'), - options: anyNamed('options'), - paginationParams: paginationParams, - )).called(1); + verify(() => mockClient.queryChannels( + filter: any(named: 'filter'), + sort: any(named: 'sort'), + state: any(named: 'state'), + watch: any(named: 'watch'), + presence: any(named: 'presence'), + memberLimit: any(named: 'memberLimit'), + messageLimit: any(named: 'messageLimit'), + paginationParams: paginationParams, + )).called(1); }, ); group('event controller test', () { - StreamController eventController; + late StreamController eventController; setUp(() { eventController = StreamController.broadcast(); }); @@ -374,17 +419,17 @@ void main() { (tester) async { final mockClient = MockClient(); const channelsBlocKey = Key('channelsBloc'); - final channelsBloc = ChannelsBloc( + const channelsBloc = ChannelsBloc( key: channelsBlocKey, child: Offstage(), ); - when(mockClient.on(any, any, any, any)) - .thenAnswer((_) => Stream.empty()); + when(() => mockClient.on(any(), any(), any(), any())) + .thenAnswer((_) => const Stream.empty()); - when(mockClient.on( - EventType.channelHidden, - )).thenAnswer((_) => eventController.stream); + when(() => mockClient.on( + EventType.channelHidden, + )).thenAnswer((_) => eventController.stream); await tester.pumpWidget( StreamChatCore( @@ -399,23 +444,31 @@ void main() { final channels = _generateChannels(mockClient); - when(mockClient.queryChannels( - filter: anyNamed('filter'), - sort: anyNamed('sort'), - options: anyNamed('options'), - paginationParams: anyNamed('paginationParams'), - )).thenAnswer( + when(() => mockClient.queryChannels( + filter: any(named: 'filter'), + sort: any(named: 'sort'), + state: any(named: 'state'), + watch: any(named: 'watch'), + presence: any(named: 'presence'), + memberLimit: any(named: 'memberLimit'), + messageLimit: any(named: 'messageLimit'), + paginationParams: any(named: 'paginationParams'), + )).thenAnswer( (_) => Stream.value(channels), ); await channelsBlocState.queryChannels(); - verify(mockClient.queryChannels( - filter: anyNamed('filter'), - sort: anyNamed('sort'), - options: anyNamed('options'), - paginationParams: anyNamed('paginationParams'), - )).called(1); + verify(() => mockClient.queryChannels( + filter: any(named: 'filter'), + sort: any(named: 'sort'), + state: any(named: 'state'), + watch: any(named: 'watch'), + presence: any(named: 'presence'), + memberLimit: any(named: 'memberLimit'), + messageLimit: any(named: 'messageLimit'), + paginationParams: any(named: 'paginationParams'), + )).called(1); final channelHiddenEvent = Event( type: EventType.channelHidden, @@ -435,7 +488,7 @@ void main() { ]), ); - verify(mockClient.on(EventType.channelHidden)).called(1); + verify(() => mockClient.on(EventType.channelHidden)).called(1); }, ); @@ -445,18 +498,18 @@ void main() { (tester) async { final mockClient = MockClient(); const channelsBlocKey = Key('channelsBloc'); - final channelsBloc = ChannelsBloc( + const channelsBloc = ChannelsBloc( key: channelsBlocKey, child: Offstage(), ); - when(mockClient.on(any, any, any, any)) - .thenAnswer((_) => Stream.empty()); + when(() => mockClient.on(any(), any(), any(), any())) + .thenAnswer((_) => const Stream.empty()); - when(mockClient.on( - EventType.channelDeleted, - EventType.notificationRemovedFromChannel, - )).thenAnswer((_) => eventController.stream); + when(() => mockClient.on( + EventType.channelDeleted, + EventType.notificationRemovedFromChannel, + )).thenAnswer((_) => eventController.stream); await tester.pumpWidget( StreamChatCore( @@ -471,31 +524,47 @@ void main() { final channels = _generateChannels(mockClient); - when(mockClient.queryChannels( - filter: anyNamed('filter'), - sort: anyNamed('sort'), - options: anyNamed('options'), - paginationParams: anyNamed('paginationParams'), - )).thenAnswer( + when(() => mockClient.queryChannels( + filter: any(named: 'filter'), + sort: any(named: 'sort'), + state: any(named: 'state'), + watch: any(named: 'watch'), + presence: any(named: 'presence'), + memberLimit: any(named: 'memberLimit'), + messageLimit: any(named: 'messageLimit'), + paginationParams: any(named: 'paginationParams'), + )).thenAnswer( (_) => Stream.value(channels), ); await channelsBlocState.queryChannels(); - verify(mockClient.queryChannels( - filter: anyNamed('filter'), - sort: anyNamed('sort'), - options: anyNamed('options'), - paginationParams: anyNamed('paginationParams'), - )).called(1); + verify(() => mockClient.queryChannels( + filter: any(named: 'filter'), + sort: any(named: 'sort'), + state: any(named: 'state'), + watch: any(named: 'watch'), + presence: any(named: 'presence'), + memberLimit: any(named: 'memberLimit'), + messageLimit: any(named: 'messageLimit'), + paginationParams: any(named: 'paginationParams'), + )).called(1); final channelDeletedOrNotificationRemovedEvent = Event( - channel: EventChannel(cid: channels.first.cid), + type: EventType.channelDeleted, + channel: EventChannel( + cid: channels.first.cid!, + updatedAt: DateTime.now(), + config: ChannelConfig(), + createdAt: DateTime.now(), + memberCount: 1, + ), ); eventController.add(channelDeletedOrNotificationRemovedEvent); - final channelCid = channelDeletedOrNotificationRemovedEvent.channel.cid; + final channelCid = + channelDeletedOrNotificationRemovedEvent.channel?.cid; final newChannels = [...channels] ..removeWhere((it) => it.cid == channelCid); @@ -507,30 +576,30 @@ void main() { ]), ); - verify(mockClient.on( - EventType.channelDeleted, - EventType.notificationRemovedFromChannel, - )).called(1); + verify(() => mockClient.on( + EventType.channelDeleted, + EventType.notificationRemovedFromChannel, + )).called(1); }, ); testWidgets( - 'event channel should be moved to top of the list if present when' + 'event channel should be moved to top of the list if present when ' 'EventType.messageNew event is received', (tester) async { final mockClient = MockClient(); const channelsBlocKey = Key('channelsBloc'); - final channelsBloc = ChannelsBloc( + const channelsBloc = ChannelsBloc( key: channelsBlocKey, child: Offstage(), ); - when(mockClient.on(any, any, any, any)) - .thenAnswer((_) => Stream.empty()); + when(() => mockClient.on(any(), any(), any(), any())) + .thenAnswer((_) => const Stream.empty()); - when(mockClient.on( - EventType.messageNew, - )).thenAnswer((_) => eventController.stream); + when(() => mockClient.on( + EventType.messageNew, + )).thenAnswer((_) => eventController.stream); await tester.pumpWidget( StreamChatCore( @@ -545,23 +614,31 @@ void main() { final channels = _generateChannels(mockClient); - when(mockClient.queryChannels( - filter: anyNamed('filter'), - sort: anyNamed('sort'), - options: anyNamed('options'), - paginationParams: anyNamed('paginationParams'), - )).thenAnswer( + when(() => mockClient.queryChannels( + filter: any(named: 'filter'), + sort: any(named: 'sort'), + state: any(named: 'state'), + watch: any(named: 'watch'), + presence: any(named: 'presence'), + memberLimit: any(named: 'memberLimit'), + messageLimit: any(named: 'messageLimit'), + paginationParams: any(named: 'paginationParams'), + )).thenAnswer( (_) => Stream.value(channels), ); await channelsBlocState.queryChannels(); - verify(mockClient.queryChannels( - filter: anyNamed('filter'), - sort: anyNamed('sort'), - options: anyNamed('options'), - paginationParams: anyNamed('paginationParams'), - )).called(1); + verify(() => mockClient.queryChannels( + filter: any(named: 'filter'), + sort: any(named: 'sort'), + state: any(named: 'state'), + watch: any(named: 'watch'), + presence: any(named: 'presence'), + memberLimit: any(named: 'memberLimit'), + messageLimit: any(named: 'messageLimit'), + paginationParams: any(named: 'paginationParams'), + )).called(1); final messageNewEvent = Event( type: EventType.messageNew, @@ -585,7 +662,7 @@ void main() { ]), ); - verify(mockClient.on(EventType.messageNew)).called(1); + verify(() => mockClient.on(EventType.messageNew)).called(1); }, ); @@ -596,29 +673,27 @@ void main() { (tester) async { final hiddenChannelEventController = StreamController(); - addTearDown(() { - hiddenChannelEventController.close(); - }); + addTearDown(hiddenChannelEventController.close); final mockClient = MockClient(); final channels = _generateChannels(mockClient); const channelsBlocKey = Key('channelsBloc'); final channelsBloc = ChannelsBloc( key: channelsBlocKey, - child: Offstage(), shouldAddChannel: (e) => channels.map((it) => it.cid).contains(e.cid), + child: const Offstage(), ); - when(mockClient.on(any, any, any, any)) - .thenAnswer((_) => Stream.empty()); + when(() => mockClient.on(any(), any(), any(), any())) + .thenAnswer((_) => const Stream.empty()); - when(mockClient.on( - EventType.channelHidden, - )).thenAnswer((_) => hiddenChannelEventController.stream); + when(() => mockClient.on( + EventType.channelHidden, + )).thenAnswer((_) => hiddenChannelEventController.stream); - when(mockClient.on( - EventType.messageNew, - )).thenAnswer((_) => eventController.stream); + when(() => mockClient.on( + EventType.messageNew, + )).thenAnswer((_) => eventController.stream); final messageNewEvent = Event( type: EventType.messageNew, @@ -636,23 +711,31 @@ void main() { find.byKey(channelsBlocKey), ); - when(mockClient.queryChannels( - filter: anyNamed('filter'), - sort: anyNamed('sort'), - options: anyNamed('options'), - paginationParams: anyNamed('paginationParams'), - )).thenAnswer( + when(() => mockClient.queryChannels( + filter: any(named: 'filter'), + sort: any(named: 'sort'), + state: any(named: 'state'), + watch: any(named: 'watch'), + presence: any(named: 'presence'), + memberLimit: any(named: 'memberLimit'), + messageLimit: any(named: 'messageLimit'), + paginationParams: any(named: 'paginationParams'), + )).thenAnswer( (_) => Stream.value(channels), ); await channelsBlocState.queryChannels(); - verify(mockClient.queryChannels( - filter: anyNamed('filter'), - sort: anyNamed('sort'), - options: anyNamed('options'), - paginationParams: anyNamed('paginationParams'), - )).called(1); + verify(() => mockClient.queryChannels( + filter: any(named: 'filter'), + sort: any(named: 'sort'), + state: any(named: 'state'), + watch: any(named: 'watch'), + presence: any(named: 'presence'), + memberLimit: any(named: 'memberLimit'), + messageLimit: any(named: 'messageLimit'), + paginationParams: any(named: 'paginationParams'), + )).called(1); final channelHiddenEvent = Event( type: EventType.channelHidden, @@ -681,8 +764,8 @@ void main() { ]), ); - verify(mockClient.on(EventType.channelHidden)).called(1); - verify(mockClient.on(EventType.messageNew)).called(1); + verify(() => mockClient.on(EventType.channelHidden)).called(1); + verify(() => mockClient.on(EventType.messageNew)).called(1); }, ); @@ -694,23 +777,23 @@ void main() { final mockClient = MockClient(); final channels = _generateChannels(mockClient); final stateChannels = { - for (var c in _generateChannels(mockClient, offset: 5)) c.cid: c + for (var c in _generateChannels(mockClient, offset: 5)) c.cid!: c }; const channelsBlocKey = Key('channelsBloc'); final channelsBloc = ChannelsBloc( key: channelsBlocKey, - child: Offstage(), shouldAddChannel: (_) => true, + child: const Offstage(), ); - when(mockClient.state.channels).thenReturn(stateChannels); + when(() => mockClient.state.channels).thenReturn(stateChannels); - when(mockClient.on(any, any, any, any)) - .thenAnswer((_) => Stream.empty()); + when(() => mockClient.on(any(), any(), any(), any())) + .thenAnswer((_) => const Stream.empty()); - when(mockClient.on( - EventType.messageNew, - )).thenAnswer((_) => eventController.stream); + when(() => mockClient.on( + EventType.messageNew, + )).thenAnswer((_) => eventController.stream); await tester.pumpWidget( StreamChatCore( @@ -723,23 +806,31 @@ void main() { find.byKey(channelsBlocKey), ); - when(mockClient.queryChannels( - filter: anyNamed('filter'), - sort: anyNamed('sort'), - options: anyNamed('options'), - paginationParams: anyNamed('paginationParams'), - )).thenAnswer( + when(() => mockClient.queryChannels( + filter: any(named: 'filter'), + sort: any(named: 'sort'), + state: any(named: 'state'), + watch: any(named: 'watch'), + presence: any(named: 'presence'), + memberLimit: any(named: 'memberLimit'), + messageLimit: any(named: 'messageLimit'), + paginationParams: any(named: 'paginationParams'), + )).thenAnswer( (_) => Stream.value(channels), ); await channelsBlocState.queryChannels(); - verify(mockClient.queryChannels( - filter: anyNamed('filter'), - sort: anyNamed('sort'), - options: anyNamed('options'), - paginationParams: anyNamed('paginationParams'), - )).called(1); + verify(() => mockClient.queryChannels( + filter: any(named: 'filter'), + sort: any(named: 'sort'), + state: any(named: 'state'), + watch: any(named: 'watch'), + presence: any(named: 'presence'), + memberLimit: any(named: 'memberLimit'), + messageLimit: any(named: 'messageLimit'), + paginationParams: any(named: 'paginationParams'), + )).called(1); final messageNewEvent = Event( type: EventType.messageNew, @@ -749,7 +840,7 @@ void main() { eventController.add(messageNewEvent); final newChannels = [...channels] - ..insert(0, stateChannels[stateChannels.keys.first]); + ..insert(0, stateChannels[stateChannels.keys.first]!); await expectLater( channelsBlocState.channelsStream, @@ -759,7 +850,7 @@ void main() { ]), ); - verify(mockClient.on(EventType.messageNew)).called(1); + verify(() => mockClient.on(EventType.messageNew)).called(1); }, ); @@ -770,25 +861,25 @@ void main() { final mockClient = MockClient(); final channels = _generateChannels(mockClient); int channelComparator(Channel a, Channel b) { - final aData = a.extraData['extra_data_key'] as String; - final bData = b.extraData['extra_data_key'] as String; + final aData = a.extraData['extra_data_key'].toString(); + final bData = b.extraData['extra_data_key'].toString(); return bData.compareTo(aData); } const channelsBlocKey = Key('channelsBloc'); final channelsBloc = ChannelsBloc( key: channelsBlocKey, - child: Offstage(), shouldAddChannel: (_) => true, channelsComparator: channelComparator, + child: const Offstage(), ); - when(mockClient.on(any, any, any, any)) - .thenAnswer((_) => Stream.empty()); + when(() => mockClient.on(any(), any(), any(), any())) + .thenAnswer((_) => const Stream.empty()); - when(mockClient.on( - EventType.messageNew, - )).thenAnswer((_) => eventController.stream); + when(() => mockClient.on( + EventType.messageNew, + )).thenAnswer((_) => eventController.stream); await tester.pumpWidget( StreamChatCore( @@ -801,23 +892,31 @@ void main() { find.byKey(channelsBlocKey), ); - when(mockClient.queryChannels( - filter: anyNamed('filter'), - sort: anyNamed('sort'), - options: anyNamed('options'), - paginationParams: anyNamed('paginationParams'), - )).thenAnswer( + when(() => mockClient.queryChannels( + filter: any(named: 'filter'), + sort: any(named: 'sort'), + state: any(named: 'state'), + watch: any(named: 'watch'), + presence: any(named: 'presence'), + memberLimit: any(named: 'memberLimit'), + messageLimit: any(named: 'messageLimit'), + paginationParams: any(named: 'paginationParams'), + )).thenAnswer( (_) => Stream.value(channels), ); await channelsBlocState.queryChannels(); - verify(mockClient.queryChannels( - filter: anyNamed('filter'), - sort: anyNamed('sort'), - options: anyNamed('options'), - paginationParams: anyNamed('paginationParams'), - )).called(1); + verify(() => mockClient.queryChannels( + filter: any(named: 'filter'), + sort: any(named: 'sort'), + state: any(named: 'state'), + watch: any(named: 'watch'), + presence: any(named: 'presence'), + memberLimit: any(named: 'memberLimit'), + messageLimit: any(named: 'messageLimit'), + paginationParams: any(named: 'paginationParams'), + )).called(1); final messageNewEvent = Event( type: EventType.messageNew, @@ -836,7 +935,7 @@ void main() { ]), ); - verify(mockClient.on(EventType.messageNew)).called(1); + verify(() => mockClient.on(EventType.messageNew)).called(1); }, ); diff --git a/packages/stream_chat_flutter_core/test/lazy_load_scroll_view_test.dart b/packages/stream_chat_flutter_core/test/lazy_load_scroll_view_test.dart index 19c1f2a2..86b39e23 100644 --- a/packages/stream_chat_flutter_core/test/lazy_load_scroll_view_test.dart +++ b/packages/stream_chat_flutter_core/test/lazy_load_scroll_view_test.dart @@ -3,23 +3,11 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:stream_chat_flutter_core/src/lazy_load_scroll_view.dart'; void main() { - test( - 'should throw assertion error if child is null', - () async { - const lazyLoadScrollViewKey = Key('lazyLoadScrollView'); - final lazyLoadScrollView = () => LazyLoadScrollView( - key: lazyLoadScrollViewKey, - child: null, - ); - expect(lazyLoadScrollView, throwsA(isA())); - }, - ); - testWidgets( 'should render LazyLoadScrollView if child is provided', (tester) async { const lazyLoadScrollViewKey = Key('lazyLoadScrollView'); - final lazyLoadScrollView = LazyLoadScrollView( + const lazyLoadScrollView = LazyLoadScrollView( key: lazyLoadScrollViewKey, child: Offstage(), ); @@ -35,7 +23,7 @@ void main() { (tester) async { const lazyLoadScrollViewKey = Key('lazyLoadScrollView'); const childKey = Key('childKey'); - final lazyLoadScrollView = LazyLoadScrollView( + const lazyLoadScrollView = LazyLoadScrollView( key: lazyLoadScrollViewKey, child: Offstage(key: childKey), ); @@ -53,7 +41,7 @@ void main() { const lazyLoadScrollViewKey = Key('lazyLoadScrollView'); const childListViewKey = Key('childListView'); - bool onPageScrollStartCalled = false; + var onPageScrollStartCalled = false; final lazyLoadScrollView = LazyLoadScrollView( key: lazyLoadScrollViewKey, @@ -64,7 +52,7 @@ void main() { key: childListViewKey, children: List.generate( 12, - (index) => Container( + (index) => SizedBox( height: 100, child: Text('Item #$index'), ), @@ -83,7 +71,7 @@ void main() { expect(find.byKey(childListViewKey), findsOneWidget); expect(onPageScrollStartCalled, isFalse); - await tester.startGesture(const Offset(100.0, 100.0)); + await tester.startGesture(const Offset(100, 100)); await tester.pump(const Duration(seconds: 1)); expect(onPageScrollStartCalled, isTrue); @@ -97,8 +85,8 @@ void main() { const lazyLoadScrollViewKey = Key('lazyLoadScrollView'); const childListViewKey = Key('childListView'); - bool onPageScrollStartCalled = false; - bool onPageScrollEndCalled = false; + var onPageScrollStartCalled = false; + var onPageScrollEndCalled = false; final lazyLoadScrollView = LazyLoadScrollView( key: lazyLoadScrollViewKey, @@ -112,7 +100,7 @@ void main() { key: childListViewKey, children: List.generate( 12, - (index) => Container( + (index) => SizedBox( height: 100, child: Text('Item #$index'), ), @@ -134,7 +122,7 @@ void main() { final gesture = await tester.createGesture(); - await gesture.down(const Offset(100.0, 100.0)); + await gesture.down(const Offset(100, 100)); await tester.pump(const Duration(seconds: 1)); expect(onPageScrollStartCalled, isTrue); @@ -153,7 +141,7 @@ void main() { const lazyLoadScrollViewKey = Key('lazyLoadScrollView'); const childListViewKey = Key('childListView'); - bool onInBetweenOfPageCalled = false; + var onInBetweenOfPageCalled = false; final lazyLoadScrollView = LazyLoadScrollView( key: lazyLoadScrollViewKey, @@ -164,7 +152,7 @@ void main() { key: childListViewKey, children: List.generate( 12, - (index) => Container( + (index) => SizedBox( height: 100, child: Text('Item #$index'), ), @@ -185,9 +173,9 @@ void main() { final gesture = await tester.createGesture(); - await gesture.down(const Offset(100.0, 100.0)); + await gesture.down(const Offset(100, 100)); await tester.pump(const Duration(seconds: 1)); - await gesture.moveBy(const Offset(-200.0, -200.0)); + await gesture.moveBy(const Offset(-200, -200)); await tester.pump(const Duration(seconds: 1)); expect(onInBetweenOfPageCalled, isTrue); @@ -201,7 +189,7 @@ void main() { const lazyLoadScrollViewKey = Key('lazyLoadScrollView'); const childListViewKey = Key('childListView'); - bool onStartOfPageCalled = false; + var onStartOfPageCalled = false; final lazyLoadScrollView = LazyLoadScrollView( key: lazyLoadScrollViewKey, @@ -212,7 +200,7 @@ void main() { key: childListViewKey, children: List.generate( 12, - (index) => Container( + (index) => SizedBox( height: 100, child: Text('Item #$index'), ), @@ -233,11 +221,11 @@ void main() { final gesture = await tester.createGesture(); - await gesture.down(const Offset(100.0, 100.0)); + await gesture.down(const Offset(100, 100)); await tester.pump(const Duration(seconds: 1)); - await gesture.moveBy(const Offset(-200.0, -200.0)); + await gesture.moveBy(const Offset(-200, -200)); await tester.pump(const Duration(seconds: 1)); - await gesture.moveBy(const Offset(201.0, 201.0)); + await gesture.moveBy(const Offset(201, 201)); await tester.pump(const Duration(seconds: 1)); expect(onStartOfPageCalled, isTrue); @@ -251,7 +239,7 @@ void main() { const lazyLoadScrollViewKey = Key('lazyLoadScrollView'); const childListViewKey = Key('childListView'); - bool onEndOfPageCalled = false; + var onEndOfPageCalled = false; final lazyLoadScrollView = LazyLoadScrollView( key: lazyLoadScrollViewKey, @@ -262,7 +250,7 @@ void main() { key: childListViewKey, children: List.generate( 12, - (index) => Container( + (index) => SizedBox( height: 100, child: Text('Item #$index'), ), @@ -283,9 +271,9 @@ void main() { final gesture = await tester.createGesture(); - await gesture.down(const Offset(100.0, 100.0)); + await gesture.down(const Offset(100, 100)); await tester.pump(const Duration(seconds: 1)); - await gesture.moveBy(const Offset(-601.0, -601.0)); + await gesture.moveBy(const Offset(-601, -601)); await tester.pump(const Duration(seconds: 1)); expect(onEndOfPageCalled, isTrue); diff --git a/packages/stream_chat_flutter_core/test/matchers/channel_matcher.dart b/packages/stream_chat_flutter_core/test/matchers/channel_matcher.dart index f371acff..514462e0 100644 --- a/packages/stream_chat_flutter_core/test/matchers/channel_matcher.dart +++ b/packages/stream_chat_flutter_core/test/matchers/channel_matcher.dart @@ -1,4 +1,3 @@ -import 'package:meta/meta.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart'; @@ -7,8 +6,8 @@ Matcher isSameChannelAs(Channel targetChannel) => class _IsSameChannelAs extends Matcher { const _IsSameChannelAs({ - @required this.targetChannel, - }) : assert(targetChannel != null, ''); + required this.targetChannel, + }); final Channel targetChannel; @@ -26,14 +25,14 @@ Matcher isSameChannelListAs(List targetChannelList) => class _IsSameChannelListAs extends Matcher { const _IsSameChannelListAs({ - @required this.targetChannelList, - }) : assert(targetChannelList != null, ''); + required this.targetChannelList, + }); final List targetChannelList; @override bool matches(covariant List channelList, Map matchState) { - bool matches = true; + var matches = true; for (var i = 0; i < channelList.length; i++) { final channel = channelList[i]; final targetChannel = targetChannelList[i]; diff --git a/packages/stream_chat_flutter_core/test/matchers/get_message_response_matcher.dart b/packages/stream_chat_flutter_core/test/matchers/get_message_response_matcher.dart index bd89420b..c3a6629d 100644 --- a/packages/stream_chat_flutter_core/test/matchers/get_message_response_matcher.dart +++ b/packages/stream_chat_flutter_core/test/matchers/get_message_response_matcher.dart @@ -1,4 +1,3 @@ -import 'package:meta/meta.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart'; @@ -7,15 +6,15 @@ Matcher isSameMessageResponseAs(GetMessageResponse targetResponse) => class _IsSameMessageResponseAs extends Matcher { const _IsSameMessageResponseAs({ - @required this.targetResponse, - }) : assert(targetResponse != null, ''); + required this.targetResponse, + }); final GetMessageResponse targetResponse; @override bool matches(covariant GetMessageResponse response, Map matchState) => response.message.id == targetResponse.message.id && - response.channel.cid == targetResponse.channel.cid; + response.channel?.cid == targetResponse.channel?.cid; @override Description describe(Description description) => @@ -28,15 +27,15 @@ Matcher isSameMessageResponseListAs( class _IsSameMessageResponseListAs extends Matcher { const _IsSameMessageResponseListAs({ - @required this.targetResponseList, - }) : assert(targetResponseList != null, ''); + required this.targetResponseList, + }); final List targetResponseList; @override bool matches( covariant List responseList, Map matchState) { - bool matches = true; + var matches = true; for (var i = 0; i < responseList.length; i++) { final response = responseList[i]; final targetResponse = targetResponseList[i]; diff --git a/packages/stream_chat_flutter_core/test/matchers/message_matcher.dart b/packages/stream_chat_flutter_core/test/matchers/message_matcher.dart index 12a3e19d..776665ff 100644 --- a/packages/stream_chat_flutter_core/test/matchers/message_matcher.dart +++ b/packages/stream_chat_flutter_core/test/matchers/message_matcher.dart @@ -1,4 +1,3 @@ -import 'package:meta/meta.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart'; @@ -7,8 +6,8 @@ Matcher isSameMessageAs(Message targetMessage) => class _IsSameMessageAs extends Matcher { const _IsSameMessageAs({ - @required this.targetMessage, - }) : assert(targetMessage != null, ''); + required this.targetMessage, + }); final Message targetMessage; @@ -26,14 +25,14 @@ Matcher isSameMessageListAs(List targetMessageList) => class _IsSameMessageListAs extends Matcher { const _IsSameMessageListAs({ - @required this.targetMessageList, - }) : assert(targetMessageList != null, ''); + required this.targetMessageList, + }); final List targetMessageList; @override bool matches(covariant List messageList, Map matchState) { - bool matches = true; + var matches = true; for (var i = 0; i < messageList.length; i++) { final message = messageList[i]; final targetMessage = targetMessageList[i]; diff --git a/packages/stream_chat_flutter_core/test/matchers/users_matcher.dart b/packages/stream_chat_flutter_core/test/matchers/users_matcher.dart index 3816fa24..fa0f0c08 100644 --- a/packages/stream_chat_flutter_core/test/matchers/users_matcher.dart +++ b/packages/stream_chat_flutter_core/test/matchers/users_matcher.dart @@ -1,4 +1,3 @@ -import 'package:meta/meta.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart'; @@ -6,8 +5,8 @@ Matcher isSameUserAs(User targetUser) => _IsSameUserAs(targetUser: targetUser); class _IsSameUserAs extends Matcher { const _IsSameUserAs({ - @required this.targetUser, - }) : assert(targetUser != null, ''); + required this.targetUser, + }); final User targetUser; @@ -24,14 +23,14 @@ Matcher isSameUserListAs(List targetUserList) => class _IsSameUserListAs extends Matcher { const _IsSameUserListAs({ - @required this.targetUserList, - }) : assert(targetUserList != null, ''); + required this.targetUserList, + }); final List targetUserList; @override bool matches(covariant List userList, Map matchState) { - bool matches = true; + var matches = true; for (var i = 0; i < userList.length; i++) { final user = userList[i]; final targetUser = targetUserList[i]; diff --git a/packages/stream_chat_flutter_core/test/message_list_core_test.dart b/packages/stream_chat_flutter_core/test/message_list_core_test.dart index 95446f95..b038411b 100644 --- a/packages/stream_chat_flutter_core/test/message_list_core_test.dart +++ b/packages/stream_chat_flutter_core/test/message_list_core_test.dart @@ -2,7 +2,7 @@ import 'dart:async'; import 'package:flutter/widgets.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:mockito/mockito.dart'; +import 'package:mocktail/mocktail.dart'; import 'package:stream_chat_flutter_core/src/message_list_core.dart'; import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart'; @@ -27,10 +27,9 @@ void main() { type: 'testType', user: users[index], createdAt: DateTime.now(), - shadowed: false, replyCount: index, updatedAt: DateTime.now(), - extraData: {'extra_test_field': 'extraTestData'}, + extraData: const {'extra_test_field': 'extraTestData'}, text: 'Dummy text #$index', pinned: true, pinnedAt: DateTime.now(), @@ -48,10 +47,9 @@ void main() { user: users[index], parentId: messages[0].id, createdAt: DateTime.now(), - shadowed: false, replyCount: index, updatedAt: DateTime.now(), - extraData: {'extra_test_field': 'extraTestData'}, + extraData: const {'extra_test_field': 'extraTestData'}, text: 'Dummy text #$index', pinned: true, pinnedAt: DateTime.now(), @@ -62,78 +60,22 @@ void main() { return threads ? threadMessages : messages; } - test( - 'should throw assertion error in case messageListBuilder is null', - () { - final messageListCore = () => MessageListCore( - messageListBuilder: null, - loadingBuilder: (BuildContext context) => Offstage(), - emptyBuilder: (BuildContext context) => Offstage(), - errorWidgetBuilder: (BuildContext context, Object error) => - Offstage(), - ); - expect(messageListCore, throwsA(isA())); - }, - ); - - test( - 'should throw assertion error in case loadingBuilder is null', - () { - final messageListCore = () => MessageListCore( - messageListBuilder: (_, __) => Offstage(), - loadingBuilder: null, - emptyBuilder: (BuildContext context) => Offstage(), - errorWidgetBuilder: (BuildContext context, Object error) => - Offstage(), - ); - expect(messageListCore, throwsA(isA())); - }, - ); - - test( - 'should throw assertion error in case emptyBuilder is null', - () { - final messageListCore = () => MessageListCore( - messageListBuilder: (_, __) => Offstage(), - loadingBuilder: (BuildContext context) => Offstage(), - emptyBuilder: null, - errorWidgetBuilder: (BuildContext context, Object error) => - Offstage(), - ); - expect(messageListCore, throwsA(isA())); - }, - ); - - test( - 'should throw assertion error in case errorWidgetBuilder is null', - () { - final messageListCore = () => MessageListCore( - messageListBuilder: (_, __) => Offstage(), - loadingBuilder: (BuildContext context) => Offstage(), - emptyBuilder: (BuildContext context) => Offstage(), - errorWidgetBuilder: null, - ); - expect(messageListCore, throwsA(isA())); - }, - ); - testWidgets( - 'should throw if MessageListCore is used where StreamChannel is not present ' - 'in the widget tree', + '''should throw if MessageListCore is used where StreamChannel is not present in the widget tree''', (tester) async { const messageListCoreKey = Key('messageListCore'); final messageListCore = MessageListCore( key: messageListCoreKey, - messageListBuilder: (_, __) => Offstage(), - loadingBuilder: (BuildContext context) => Offstage(), - emptyBuilder: (BuildContext context) => Offstage(), - errorWidgetBuilder: (BuildContext context, Object error) => Offstage(), + messageListBuilder: (_, __) => const Offstage(), + loadingBuilder: (BuildContext context) => const Offstage(), + emptyBuilder: (BuildContext context) => const Offstage(), + errorBuilder: (BuildContext context, Object error) => const Offstage(), ); await tester.pumpWidget(messageListCore); expect(find.byKey(messageListCoreKey), findsNothing); - expect(tester.takeException(), isInstanceOf()); + expect(tester.takeException(), isInstanceOf()); }, ); @@ -143,15 +85,19 @@ void main() { const messageListCoreKey = Key('messageListCore'); final messageListCore = MessageListCore( key: messageListCoreKey, - messageListBuilder: (_, __) => Offstage(), - loadingBuilder: (BuildContext context) => Offstage(), - emptyBuilder: (BuildContext context) => Offstage(), - errorWidgetBuilder: (BuildContext context, Object error) => Offstage(), + messageListBuilder: (_, __) => const Offstage(), + loadingBuilder: (BuildContext context) => const Offstage(), + emptyBuilder: (BuildContext context) => const Offstage(), + errorBuilder: (BuildContext context, Object error) => const Offstage(), ); final mockChannel = MockChannel(); + when(() => mockChannel.initialized).thenAnswer((_) => Future.value(true)); - when(mockChannel.state.isUpToDate).thenReturn(true); + when(() => mockChannel.state.isUpToDate).thenReturn(true); + when(() => mockChannel.state.messagesStream) + .thenAnswer((_) => Stream.value([])); + when(() => mockChannel.state.messages).thenReturn([]); await tester.pumpWidget( StreamChannel( @@ -171,10 +117,10 @@ void main() { final controller = MessageListController(); final messageListCore = MessageListCore( key: messageListCoreKey, - messageListBuilder: (_, __) => Offstage(), - loadingBuilder: (BuildContext context) => Offstage(), - emptyBuilder: (BuildContext context) => Offstage(), - errorWidgetBuilder: (BuildContext context, Object error) => Offstage(), + messageListBuilder: (_, __) => const Offstage(), + loadingBuilder: (BuildContext context) => const Offstage(), + emptyBuilder: (BuildContext context) => const Offstage(), + errorBuilder: (BuildContext context, Object error) => const Offstage(), messageListController: controller, ); @@ -182,7 +128,11 @@ void main() { final mockChannel = MockChannel(); - when(mockChannel.state.isUpToDate).thenReturn(true); + when(() => mockChannel.state.isUpToDate).thenReturn(true); + when(() => mockChannel.state.messagesStream) + .thenAnswer((_) => Stream.value([])); + when(() => mockChannel.state.messages).thenReturn([]); + when(() => mockChannel.initialized).thenAnswer((_) => Future.value(true)); await tester.pumpWidget( StreamChannel( @@ -196,6 +146,57 @@ void main() { }, ); + testWidgets( + '''should assign paginateData callback and paginate data correctly if a MessageListController is passed''', + (tester) async { + const messageListCoreKey = Key('messageListCore'); + final controller = MessageListController(); + final messageListCore = MessageListCore( + key: messageListCoreKey, + messageListBuilder: (_, __) => const Offstage(), + loadingBuilder: (BuildContext context) => const Offstage(), + emptyBuilder: (BuildContext context) => const Offstage(), + errorBuilder: (BuildContext context, Object error) => const Offstage(), + messageListController: controller, + ); + + expect(controller.paginateData, isNull); + + final mockChannel = MockChannel(); + + when(() => mockChannel.state.isUpToDate).thenReturn(true); + // when(() => mockChannel.query( + // messagesPagination: any(named: 'messagesPagination'), + // preferOffline: any(named: 'preferOffline'), + // )).thenAnswer((_) => mockChannel.state); + final messages = _generateMessages(); + when(() => mockChannel.state.messages).thenReturn(messages); + when(() => mockChannel.state.messagesStream) + .thenAnswer((_) => Stream.value(messages)); + when(() => mockChannel.state.messages).thenReturn(messages); + when(() => mockChannel.initialized).thenAnswer((_) => Future.value(true)); + + await tester.pumpWidget( + StreamChannel( + channel: mockChannel, + child: messageListCore, + ), + ); + + final finder = find.byKey(messageListCoreKey); + final coreState = tester.firstState(finder); + expect(finder, findsOneWidget); + expect(controller.paginateData, isNotNull); + + await coreState.paginateData(); + + verify(() => mockChannel.query( + messagesPagination: any(named: 'messagesPagination'), + preferOffline: any(named: 'preferOffline'), + )).called(1); + }, + ); + testWidgets( 'should build error widget if messagesStream emits error', (tester) async { @@ -203,22 +204,23 @@ void main() { const errorWidgetKey = Key('errorWidget'); final messageListCore = MessageListCore( key: messageListCoreKey, - messageListBuilder: (_, __) => Offstage(), - loadingBuilder: (BuildContext context) => Offstage(), - emptyBuilder: (BuildContext context) => Offstage(), - errorWidgetBuilder: (BuildContext context, Object error) => Offstage( + messageListBuilder: (_, __) => const Offstage(), + loadingBuilder: (BuildContext context) => const Offstage(), + emptyBuilder: (BuildContext context) => const Offstage(), + errorBuilder: (BuildContext context, Object error) => const Offstage( key: errorWidgetKey, ), ); final mockChannel = MockChannel(); - when(mockChannel.state.isUpToDate).thenReturn(true); - when(mockChannel.initialized).thenAnswer((_) async => true); + when(() => mockChannel.state.isUpToDate).thenReturn(true); + when(() => mockChannel.initialized).thenAnswer((_) async => true); const error = 'Error! Error! Error!'; - when(mockChannel.state.messagesStream) + when(() => mockChannel.state.messagesStream) .thenAnswer((_) => Stream.error(error)); + when(() => mockChannel.state.messages).thenReturn([]); await tester.pumpWidget( Directionality( @@ -244,20 +246,22 @@ void main() { const emptyWidgetKey = Key('emptyWidget'); final messageListCore = MessageListCore( key: messageListCoreKey, - messageListBuilder: (_, __) => Offstage(), - loadingBuilder: (BuildContext context) => Offstage(), - emptyBuilder: (BuildContext context) => Offstage(key: emptyWidgetKey), - errorWidgetBuilder: (BuildContext context, Object error) => Offstage(), + messageListBuilder: (_, __) => const Offstage(), + loadingBuilder: (BuildContext context) => const Offstage(), + emptyBuilder: (BuildContext context) => + const Offstage(key: emptyWidgetKey), + errorBuilder: (BuildContext context, Object error) => const Offstage(), ); final mockChannel = MockChannel(); - when(mockChannel.state.isUpToDate).thenReturn(true); - when(mockChannel.initialized).thenAnswer((_) async => true); + when(() => mockChannel.state.isUpToDate).thenReturn(true); + when(() => mockChannel.initialized).thenAnswer((_) async => true); const messages = []; - when(mockChannel.state.messagesStream) + when(() => mockChannel.state.messagesStream) .thenAnswer((_) => Stream.value(messages)); + when(() => mockChannel.state.messages).thenReturn(messages); await tester.pumpWidget( Directionality( @@ -283,20 +287,30 @@ void main() { const listWidgetKey = Key('listWidget'); final messageListCore = MessageListCore( key: messageListCoreKey, - messageListBuilder: (_, __) => Offstage(key: listWidgetKey), - loadingBuilder: (BuildContext context) => Offstage(), - emptyBuilder: (BuildContext context) => Offstage(), - errorWidgetBuilder: (BuildContext context, Object error) => Offstage(), + messageListBuilder: (_, __) => const Offstage(key: listWidgetKey), + loadingBuilder: (BuildContext context) => const Offstage(), + emptyBuilder: (BuildContext context) => const Offstage(), + errorBuilder: (BuildContext context, Object error) => const Offstage(), ); final mockChannel = MockChannel(); - when(mockChannel.state.isUpToDate).thenReturn(false); - when(mockChannel.initialized).thenAnswer((_) async => true); + when(() => mockChannel.state.isUpToDate).thenReturn(false); + when(() => mockChannel.initialized).thenAnswer((_) async => true); + when(() => mockChannel.query( + state: any(named: 'state'), + watch: any(named: 'watch'), + presence: any(named: 'presence'), + membersPagination: any(named: 'membersPagination'), + messagesPagination: any(named: 'messagesPagination'), + preferOffline: any(named: 'preferOffline'), + watchersPagination: any(named: 'watchersPagination'), + )).thenAnswer((_) async => ChannelState()); const messages = []; - when(mockChannel.state.messagesStream) + when(() => mockChannel.state.messagesStream) .thenAnswer((_) => Stream.value(messages)); + when(() => mockChannel.state.messages).thenReturn(messages); await tester.pumpWidget( Directionality( @@ -328,19 +342,20 @@ void main() { messages.reversed.map((it) => it.id).join(','), ), ), - loadingBuilder: (BuildContext context) => Offstage(), - emptyBuilder: (BuildContext context) => Offstage(), - errorWidgetBuilder: (BuildContext context, Object error) => Offstage(), + loadingBuilder: (BuildContext context) => const Offstage(), + emptyBuilder: (BuildContext context) => const Offstage(), + errorBuilder: (BuildContext context, Object error) => const Offstage(), ); final mockChannel = MockChannel(); - when(mockChannel.state.isUpToDate).thenReturn(true); - when(mockChannel.initialized).thenAnswer((_) async => true); + when(() => mockChannel.state.isUpToDate).thenReturn(true); + when(() => mockChannel.initialized).thenAnswer((_) async => true); final messages = _generateMessages(); - when(mockChannel.state.messagesStream) + when(() => mockChannel.state.messagesStream) .thenAnswer((_) => Stream.value(messages)); + when(() => mockChannel.state.messages).thenReturn(messages); await tester.pumpWidget( Directionality( @@ -374,21 +389,21 @@ void main() { messages.reversed.map((it) => '${it.parentId}-${it.id}').join(','), ), ), - loadingBuilder: (BuildContext context) => Offstage(), - emptyBuilder: (BuildContext context) => Offstage(), - errorWidgetBuilder: (BuildContext context, Object error) => Offstage(), + loadingBuilder: (BuildContext context) => const Offstage(), + emptyBuilder: (BuildContext context) => const Offstage(), + errorBuilder: (BuildContext context, Object error) => const Offstage(), parentMessage: parentMessage, ); final mockChannel = MockChannel(); - when(mockChannel.state.isUpToDate).thenReturn(true); - when(mockChannel.initialized).thenAnswer((_) async => true); + when(() => mockChannel.state.isUpToDate).thenReturn(true); + when(() => mockChannel.initialized).thenAnswer((_) async => true); final threads = {parentMessage.id: messages}; - when(mockChannel.state.threads).thenReturn(threads); - when(mockChannel.state.threadsStream) + when(() => mockChannel.state.threads).thenReturn(threads); + when(() => mockChannel.state.threadsStream) .thenAnswer((_) => Stream.value(threads)); await tester.pumpWidget( diff --git a/packages/stream_chat_flutter_core/test/message_search_bloc_test.dart b/packages/stream_chat_flutter_core/test/message_search_bloc_test.dart index e639c03b..979ca5b2 100644 --- a/packages/stream_chat_flutter_core/test/message_search_bloc_test.dart +++ b/packages/stream_chat_flutter_core/test/message_search_bloc_test.dart @@ -1,6 +1,6 @@ import 'package:flutter/widgets.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:mockito/mockito.dart'; +import 'package:mocktail/mocktail.dart'; import 'package:stream_chat/stream_chat.dart'; import 'package:stream_chat_flutter_core/src/message_search_bloc.dart'; import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart'; @@ -8,64 +8,38 @@ import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart'; import 'matchers/get_message_response_matcher.dart'; import 'mocks.dart'; +const testFilter = Filter.custom(operator: '\$test', value: 'testValue'); + void main() { List _generateMessages({ int count = 3, int offset = 0, - }) { - return List.generate( - count, - (index) { - index = index + offset; - return GetMessageResponse() - ..message = Message( - id: 'testId$index', - text: 'testTextData$index', - ) - ..channel = ChannelModel( - cid: 'testCid', - ); - }, - ); - } - - test( - 'should throw assertion error if child is null', - () async { - const messageSearchBlocKey = Key('messageSearchBloc'); - final messageSearchBloc = () => MessageSearchBloc( - key: messageSearchBlocKey, - child: null, - ); - expect(messageSearchBloc, throwsA(isA())); - }, - ); + }) => + List.generate( + count, + (index) { + index = index + offset; + return GetMessageResponse() + ..message = Message( + id: 'testId$index', + text: 'testTextData$index', + ) + ..channel = ChannelModel( + cid: 'testCid:id', + ); + }, + ); testWidgets( 'messageSearchBlocState.search() should throw if used where ' 'StreamChat is not present in the widget tree', (tester) async { - const messageSearchBlocKey = Key('messageSearchBloc'); - const childKey = Key('child'); - final messageSearchBloc = MessageSearchBloc( - key: messageSearchBlocKey, - child: Offstage(key: childKey), + const messageSearchBloc = MessageSearchBloc( + child: Offstage(), ); await tester.pumpWidget(messageSearchBloc); - - expect(find.byKey(messageSearchBlocKey), findsOneWidget); - expect(find.byKey(childKey), findsOneWidget); - - final usersBlocState = tester.state( - find.byKey(messageSearchBlocKey), - ); - - try { - await usersBlocState.search(); - } catch (e) { - expect(e, isInstanceOf()); - } + expect(tester.takeException(), isInstanceOf()); }, ); @@ -74,7 +48,7 @@ void main() { (tester) async { const messageSearchBlocKey = Key('messageSearchBloc'); const childKey = Key('child'); - final messageSearchBloc = MessageSearchBloc( + const messageSearchBloc = MessageSearchBloc( key: messageSearchBlocKey, child: Offstage(key: childKey), ); @@ -93,30 +67,30 @@ void main() { final messageResponseList = _generateMessages(); - when(mockClient.search( - any, - query: anyNamed('query'), - sort: anyNamed('sort'), - messageFilters: anyNamed('messageFilters'), - paginationParams: anyNamed('paginationParams'), - )).thenAnswer( + when(() => mockClient.search( + testFilter, + query: any(named: 'query'), + sort: any(named: 'sort'), + messageFilters: any(named: 'messageFilters'), + paginationParams: any(named: 'paginationParams'), + )).thenAnswer( (_) async => SearchMessagesResponse()..results = messageResponseList, ); - messageSearchBlocState.search(); + messageSearchBlocState.search(filter: testFilter); await expectLater( messageSearchBlocState.messagesStream, emits(isSameMessageResponseListAs(messageResponseList)), ); - verify(mockClient.search( - any, - query: anyNamed('query'), - sort: anyNamed('sort'), - messageFilters: anyNamed('messageFilters'), - paginationParams: anyNamed('paginationParams'), - )).called(1); + verify(() => mockClient.search( + testFilter, + query: any(named: 'query'), + sort: any(named: 'sort'), + messageFilters: any(named: 'messageFilters'), + paginationParams: any(named: 'paginationParams'), + )).called(1); }, ); @@ -126,7 +100,7 @@ void main() { (tester) async { const messageSearchBlocKey = Key('messageSearchBloc'); const childKey = Key('child'); - final messageSearchBloc = MessageSearchBloc( + const messageSearchBloc = MessageSearchBloc( key: messageSearchBlocKey, child: Offstage(key: childKey), ); @@ -144,28 +118,28 @@ void main() { ); const error = 'Error! Error! Error!'; - when(mockClient.search( - any, - query: anyNamed('query'), - sort: anyNamed('sort'), - messageFilters: anyNamed('messageFilters'), - paginationParams: anyNamed('paginationParams'), - )).thenThrow(error); + when(() => mockClient.search( + testFilter, + query: any(named: 'query'), + sort: any(named: 'sort'), + messageFilters: any(named: 'messageFilters'), + paginationParams: any(named: 'paginationParams'), + )).thenThrow(error); - messageSearchBlocState.search(); + messageSearchBlocState.search(filter: testFilter); await expectLater( messageSearchBlocState.messagesStream, emitsError(error), ); - verify(mockClient.search( - any, - query: anyNamed('query'), - sort: anyNamed('sort'), - messageFilters: anyNamed('messageFilters'), - paginationParams: anyNamed('paginationParams'), - )).called(1); + verify(() => mockClient.search( + testFilter, + query: any(named: 'query'), + sort: any(named: 'sort'), + messageFilters: any(named: 'messageFilters'), + paginationParams: any(named: 'paginationParams'), + )).called(1); }, ); @@ -176,7 +150,7 @@ void main() { (tester) async { const messageSearchBlocKey = Key('messageSearchBloc'); const childKey = Key('child'); - final messageSearchBloc = MessageSearchBloc( + const messageSearchBloc = MessageSearchBloc( key: messageSearchBlocKey, child: Offstage(key: childKey), ); @@ -196,47 +170,47 @@ void main() { final messageResponseList = _generateMessages(); - when(mockClient.search( - any, - query: anyNamed('query'), - sort: anyNamed('sort'), - messageFilters: anyNamed('messageFilters'), - paginationParams: anyNamed('paginationParams'), - )).thenAnswer( + when(() => mockClient.search( + testFilter, + query: any(named: 'query'), + sort: any(named: 'sort'), + messageFilters: any(named: 'messageFilters'), + paginationParams: any(named: 'paginationParams'), + )).thenAnswer( (_) async => SearchMessagesResponse()..results = messageResponseList, ); - messageSearchBlocState.search(); + messageSearchBlocState.search(filter: testFilter); await expectLater( messageSearchBlocState.messagesStream, emits(isSameMessageResponseListAs(messageResponseList)), ); - verify(mockClient.search( - any, - query: anyNamed('query'), - sort: anyNamed('sort'), - messageFilters: anyNamed('messageFilters'), - paginationParams: anyNamed('paginationParams'), - )).called(1); + verify(() => mockClient.search( + testFilter, + query: any(named: 'query'), + sort: any(named: 'sort'), + messageFilters: any(named: 'messageFilters'), + paginationParams: any(named: 'paginationParams'), + )).called(1); final offset = messageResponseList.length; final paginatedMessageResponseList = _generateMessages(offset: offset); final pagination = PaginationParams(offset: offset); - when(mockClient.search( - any, - query: anyNamed('query'), - sort: anyNamed('sort'), - messageFilters: anyNamed('messageFilters'), - paginationParams: pagination, - )).thenAnswer( + when(() => mockClient.search( + testFilter, + query: any(named: 'query'), + sort: any(named: 'sort'), + messageFilters: any(named: 'messageFilters'), + paginationParams: pagination, + )).thenAnswer( (_) async => SearchMessagesResponse()..results = paginatedMessageResponseList, ); - messageSearchBlocState.search(pagination: pagination); + messageSearchBlocState.search(pagination: pagination, filter: testFilter); await Future.wait([ expectLater( @@ -251,13 +225,13 @@ void main() { ), ]); - verify(mockClient.search( - any, - query: anyNamed('query'), - sort: anyNamed('sort'), - messageFilters: anyNamed('messageFilters'), - paginationParams: pagination, - )).called(1); + verify(() => mockClient.search( + testFilter, + query: any(named: 'query'), + sort: any(named: 'sort'), + messageFilters: any(named: 'messageFilters'), + paginationParams: pagination, + )).called(1); }, ); @@ -268,7 +242,7 @@ void main() { (tester) async { const messageSearchBlocKey = Key('messageSearchBloc'); const childKey = Key('child'); - final messageSearchBloc = MessageSearchBloc( + const messageSearchBloc = MessageSearchBloc( key: messageSearchBlocKey, child: Offstage(key: childKey), ); @@ -288,57 +262,57 @@ void main() { final messageResponseList = _generateMessages(); - when(mockClient.search( - any, - query: anyNamed('query'), - sort: anyNamed('sort'), - messageFilters: anyNamed('messageFilters'), - paginationParams: anyNamed('paginationParams'), - )).thenAnswer( + when(() => mockClient.search( + testFilter, + query: any(named: 'query'), + sort: any(named: 'sort'), + messageFilters: any(named: 'messageFilters'), + paginationParams: any(named: 'paginationParams'), + )).thenAnswer( (_) async => SearchMessagesResponse()..results = messageResponseList, ); - messageSearchBlocState.search(); + messageSearchBlocState.search(filter: testFilter); await expectLater( messageSearchBlocState.messagesStream, emits(isSameMessageResponseListAs(messageResponseList)), ); - verify(mockClient.search( - any, - query: anyNamed('query'), - sort: anyNamed('sort'), - messageFilters: anyNamed('messageFilters'), - paginationParams: anyNamed('paginationParams'), - )).called(1); + verify(() => mockClient.search( + testFilter, + query: any(named: 'query'), + sort: any(named: 'sort'), + messageFilters: any(named: 'messageFilters'), + paginationParams: any(named: 'paginationParams'), + )).called(1); final offset = messageResponseList.length; final pagination = PaginationParams(offset: offset); const error = 'Error! Error! Error!'; - when(mockClient.search( - any, - query: anyNamed('query'), - sort: anyNamed('sort'), - messageFilters: anyNamed('messageFilters'), - paginationParams: pagination, - )).thenThrow(error); + when(() => mockClient.search( + testFilter, + query: any(named: 'query'), + sort: any(named: 'sort'), + messageFilters: any(named: 'messageFilters'), + paginationParams: pagination, + )).thenThrow(error); - messageSearchBlocState.search(pagination: pagination); + messageSearchBlocState.search(pagination: pagination, filter: testFilter); await expectLater( messageSearchBlocState.queryMessagesLoading, emitsError(error), ); - verify(mockClient.search( - any, - query: anyNamed('query'), - sort: anyNamed('sort'), - messageFilters: anyNamed('messageFilters'), - paginationParams: pagination, - )).called(1); + verify(() => mockClient.search( + testFilter, + query: any(named: 'query'), + sort: any(named: 'sort'), + messageFilters: any(named: 'messageFilters'), + paginationParams: pagination, + )).called(1); }, ); } diff --git a/packages/stream_chat_flutter_core/test/message_search_list_core_test.dart b/packages/stream_chat_flutter_core/test/message_search_list_core_test.dart index aa4a47f9..6b700c76 100644 --- a/packages/stream_chat_flutter_core/test/message_search_list_core_test.dart +++ b/packages/stream_chat_flutter_core/test/message_search_list_core_test.dart @@ -1,83 +1,32 @@ import 'package:flutter/widgets.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:mockito/mockito.dart'; -import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart'; +import 'package:mocktail/mocktail.dart'; import 'package:stream_chat_flutter_core/src/message_search_list_core.dart'; +import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart'; import 'mocks.dart'; +const testFilter = Filter.custom(operator: '\$test', value: 'testValue'); + void main() { List _generateMessages({ int count = 3, int offset = 0, - }) { - return List.generate( - count, - (index) { - index = index + offset; - return GetMessageResponse() - ..message = Message( - id: 'testId$index', - text: 'testTextData$index', - ) - ..channel = ChannelModel( - cid: 'testCid', - ); - }, - ); - } - - test( - 'should throw assertion error in case childBuilder is null', - () { - final messageSearchListCore = () => MessageSearchListCore( - childBuilder: null, - loadingBuilder: (BuildContext context) => Offstage(), - emptyBuilder: (BuildContext context) => Offstage(), - errorBuilder: (BuildContext context, Object error) => Offstage(), - ); - expect(messageSearchListCore, throwsA(isA())); - }, - ); - - test( - 'should throw assertion error in case loadingBuilder is null', - () { - final messageSearchListCore = () => MessageSearchListCore( - childBuilder: (List messages) => Offstage(), - loadingBuilder: null, - emptyBuilder: (BuildContext context) => Offstage(), - errorBuilder: (BuildContext context, Object error) => Offstage(), - ); - expect(messageSearchListCore, throwsA(isA())); - }, - ); - - test( - 'should throw assertion error in case emptyBuilder is null', - () { - final messageSearchListCore = () => MessageSearchListCore( - childBuilder: (List messages) => Offstage(), - loadingBuilder: (BuildContext context) => Offstage(), - emptyBuilder: null, - errorBuilder: (BuildContext context, Object error) => Offstage(), - ); - expect(messageSearchListCore, throwsA(isA())); - }, - ); - - test( - 'should throw assertion error in case errorBuilder is null', - () { - final messageSearchListCore = () => MessageSearchListCore( - childBuilder: (List messages) => Offstage(), - loadingBuilder: (BuildContext context) => Offstage(), - emptyBuilder: (BuildContext context) => Offstage(), - errorBuilder: null, - ); - expect(messageSearchListCore, throwsA(isA())); - }, - ); + }) => + List.generate( + count, + (index) { + index = index + offset; + return GetMessageResponse() + ..message = Message( + id: 'testId$index', + text: 'testTextData$index', + ) + ..channel = ChannelModel( + cid: 'test:Cid', + ); + }, + ); testWidgets( 'should throw if MessageSearchListCore is used where MessageSearchBloc ' @@ -86,16 +35,17 @@ void main() { const messageSearchListCoreKey = Key('messageSearchListCore'); final messageSearchListCore = MessageSearchListCore( key: messageSearchListCoreKey, - childBuilder: (List messages) => Offstage(), - loadingBuilder: (BuildContext context) => Offstage(), - emptyBuilder: (BuildContext context) => Offstage(), - errorBuilder: (BuildContext context, Object error) => Offstage(), + childBuilder: (List? messages) => const Offstage(), + loadingBuilder: (BuildContext context) => const Offstage(), + emptyBuilder: (BuildContext context) => const Offstage(), + errorBuilder: (BuildContext context, Object? error) => const Offstage(), + filters: testFilter, ); await tester.pumpWidget(messageSearchListCore); expect(find.byKey(messageSearchListCoreKey), findsNothing); - expect(tester.takeException(), isInstanceOf()); + expect(tester.takeException(), isInstanceOf()); }, ); @@ -106,10 +56,11 @@ void main() { const messageSearchListCoreKey = Key('messageSearchListCore'); final messageSearchListCore = MessageSearchListCore( key: messageSearchListCoreKey, - childBuilder: (List messages) => Offstage(), - loadingBuilder: (BuildContext context) => Offstage(), - emptyBuilder: (BuildContext context) => Offstage(), - errorBuilder: (BuildContext context, Object error) => Offstage(), + childBuilder: (List messages) => const Offstage(), + loadingBuilder: (BuildContext context) => const Offstage(), + emptyBuilder: (BuildContext context) => const Offstage(), + errorBuilder: (BuildContext context, Object? error) => const Offstage(), + filters: testFilter, ); final mockClient = MockClient(); @@ -135,11 +86,12 @@ void main() { final controller = MessageSearchListController(); final messageSearchListCore = MessageSearchListCore( key: messageSearchListCoreKey, - childBuilder: (List messages) => Offstage(), - loadingBuilder: (BuildContext context) => Offstage(), - emptyBuilder: (BuildContext context) => Offstage(), - errorBuilder: (BuildContext context, Object error) => Offstage(), + childBuilder: (List messages) => const Offstage(), + loadingBuilder: (BuildContext context) => const Offstage(), + emptyBuilder: (BuildContext context) => const Offstage(), + errorBuilder: (BuildContext context, Object error) => const Offstage(), messageSearchListController: controller, + filters: testFilter, ); expect(controller.loadData, isNull); @@ -155,6 +107,7 @@ void main() { ), ), ); + await tester.pumpAndSettle(); expect(find.byKey(messageSearchListCoreKey), findsOneWidget); expect(controller.loadData, isNotNull); @@ -169,24 +122,25 @@ void main() { const errorWidgetKey = Key('errorWidget'); final messageSearchListCore = MessageSearchListCore( key: messageSearchListCoreKey, - childBuilder: (List messages) => Offstage(), - loadingBuilder: (BuildContext context) => Offstage(), - emptyBuilder: (BuildContext context) => Offstage(), - errorBuilder: (BuildContext context, Object error) => Offstage( + childBuilder: (List messages) => const Offstage(), + loadingBuilder: (BuildContext context) => const Offstage(), + emptyBuilder: (BuildContext context) => const Offstage(), + errorBuilder: (BuildContext context, Object error) => const Offstage( key: errorWidgetKey, ), + filters: testFilter, ); final mockClient = MockClient(); const error = 'Error! Error! Error!'; - when(mockClient.search( - any, - query: anyNamed('query'), - sort: anyNamed('sort'), - messageFilters: anyNamed('messageFilters'), - paginationParams: anyNamed('paginationParams'), - )).thenThrow(error); + when(() => mockClient.search( + testFilter, + query: any(named: 'query'), + sort: any(named: 'sort'), + messageFilters: any(named: 'messageFilters'), + paginationParams: any(named: 'paginationParams'), + )).thenThrow(error); await tester.pumpWidget( StreamChatCore( @@ -201,13 +155,13 @@ void main() { expect(find.byKey(errorWidgetKey), findsOneWidget); - verify(mockClient.search( - any, - query: anyNamed('query'), - sort: anyNamed('sort'), - messageFilters: anyNamed('messageFilters'), - paginationParams: anyNamed('paginationParams'), - )).called(1); + verify(() => mockClient.search( + testFilter, + query: any(named: 'query'), + sort: any(named: 'sort'), + messageFilters: any(named: 'messageFilters'), + paginationParams: any(named: 'paginationParams'), + )).called(1); }, ); @@ -219,22 +173,24 @@ void main() { const emptyWidgetKey = Key('emptyWidget'); final messageSearchListCore = MessageSearchListCore( key: messageSearchListCoreKey, - childBuilder: (List messages) => Offstage(), - loadingBuilder: (BuildContext context) => Offstage(), - emptyBuilder: (BuildContext context) => Offstage(key: emptyWidgetKey), - errorBuilder: (BuildContext context, Object error) => Offstage(), + childBuilder: (List messages) => const Offstage(), + loadingBuilder: (BuildContext context) => const Offstage(), + emptyBuilder: (BuildContext context) => + const Offstage(key: emptyWidgetKey), + errorBuilder: (BuildContext context, Object error) => const Offstage(), + filters: testFilter, ); final mockClient = MockClient(); final messageResponseList = []; - when(mockClient.search( - any, - query: anyNamed('query'), - sort: anyNamed('sort'), - messageFilters: anyNamed('messageFilters'), - paginationParams: anyNamed('paginationParams'), - )).thenAnswer( + when(() => mockClient.search( + testFilter, + query: any(named: 'query'), + sort: any(named: 'sort'), + messageFilters: any(named: 'messageFilters'), + paginationParams: any(named: 'paginationParams'), + )).thenAnswer( (_) async => SearchMessagesResponse()..results = messageResponseList, ); @@ -251,13 +207,13 @@ void main() { expect(find.byKey(emptyWidgetKey), findsOneWidget); - verify(mockClient.search( - any, - query: anyNamed('query'), - sort: anyNamed('sort'), - messageFilters: anyNamed('messageFilters'), - paginationParams: anyNamed('paginationParams'), - )).called(1); + verify(() => mockClient.search( + testFilter, + query: any(named: 'query'), + sort: any(named: 'sort'), + messageFilters: any(named: 'messageFilters'), + paginationParams: any(named: 'paginationParams'), + )).called(1); }, ); @@ -268,24 +224,25 @@ void main() { const childWidgetKey = Key('childWidget'); final messageSearchListCore = MessageSearchListCore( key: messageSearchListCoreKey, - childBuilder: (List messages) => Offstage( + childBuilder: (List messages) => const Offstage( key: childWidgetKey, ), - loadingBuilder: (BuildContext context) => Offstage(), - emptyBuilder: (BuildContext context) => Offstage(), - errorBuilder: (BuildContext context, Object error) => Offstage(), + loadingBuilder: (BuildContext context) => const Offstage(), + emptyBuilder: (BuildContext context) => const Offstage(), + errorBuilder: (BuildContext context, Object error) => const Offstage(), + filters: testFilter, ); final mockClient = MockClient(); final messageResponseList = _generateMessages(); - when(mockClient.search( - any, - query: anyNamed('query'), - sort: anyNamed('sort'), - messageFilters: anyNamed('messageFilters'), - paginationParams: anyNamed('paginationParams'), - )).thenAnswer( + when(() => mockClient.search( + testFilter, + query: any(named: 'query'), + sort: any(named: 'sort'), + messageFilters: any(named: 'messageFilters'), + paginationParams: any(named: 'paginationParams'), + )).thenAnswer( (_) async => SearchMessagesResponse()..results = messageResponseList, ); @@ -302,13 +259,13 @@ void main() { expect(find.byKey(childWidgetKey), findsOneWidget); - verify(mockClient.search( - any, - query: anyNamed('query'), - sort: anyNamed('sort'), - messageFilters: anyNamed('messageFilters'), - paginationParams: anyNamed('paginationParams'), - )).called(1); + verify(() => mockClient.search( + testFilter, + query: any(named: 'query'), + sort: any(named: 'sort'), + messageFilters: any(named: 'messageFilters'), + paginationParams: any(named: 'paginationParams'), + )).called(1); }, ); @@ -324,25 +281,26 @@ void main() { childBuilder: (List messages) => Container( key: childWidgetKey, child: Text( - messages.map((e) => '${e.channel.cid}-${e.message.id}').join(','), + messages.map((e) => '${e.channel?.cid}-${e.message.id}').join(','), ), ), - loadingBuilder: (BuildContext context) => Offstage(), - emptyBuilder: (BuildContext context) => Offstage(), - errorBuilder: (BuildContext context, Object error) => Offstage(), + loadingBuilder: (BuildContext context) => const Offstage(), + emptyBuilder: (BuildContext context) => const Offstage(), + errorBuilder: (BuildContext context, Object error) => const Offstage(), paginationParams: pagination, + filters: testFilter, ); final mockClient = MockClient(); final messageResponseList = _generateMessages(); - when(mockClient.search( - any, - query: anyNamed('query'), - sort: anyNamed('sort'), - messageFilters: anyNamed('messageFilters'), - paginationParams: pagination, - )).thenAnswer( + when(() => mockClient.search( + testFilter, + query: any(named: 'query'), + sort: any(named: 'sort'), + messageFilters: any(named: 'messageFilters'), + paginationParams: pagination, + )).thenAnswer( (_) async => SearchMessagesResponse()..results = messageResponseList, ); @@ -364,19 +322,19 @@ void main() { expect( find.text( messageResponseList - .map((e) => '${e.channel.cid}-${e.message.id}') + .map((e) => '${e.channel?.cid}-${e.message.id}') .join(','), ), findsOneWidget, ); - verify(mockClient.search( - any, - query: anyNamed('query'), - sort: anyNamed('sort'), - messageFilters: anyNamed('messageFilters'), - paginationParams: pagination, - )).called(1); + verify(() => mockClient.search( + testFilter, + query: any(named: 'query'), + sort: any(named: 'sort'), + messageFilters: any(named: 'messageFilters'), + paginationParams: pagination, + )).called(1); final messageSearchListCoreState = tester.state( @@ -386,13 +344,13 @@ void main() { final offset = messageResponseList.length; final paginatedMessageResponseList = _generateMessages(offset: offset); final updatedPagination = pagination.copyWith(offset: offset); - when(mockClient.search( - any, - query: anyNamed('query'), - sort: anyNamed('sort'), - messageFilters: anyNamed('messageFilters'), - paginationParams: updatedPagination, - )).thenAnswer( + when(() => mockClient.search( + testFilter, + query: any(named: 'query'), + sort: any(named: 'sort'), + messageFilters: any(named: 'messageFilters'), + paginationParams: updatedPagination, + )).thenAnswer( (_) async => SearchMessagesResponse()..results = paginatedMessageResponseList, ); @@ -406,17 +364,17 @@ void main() { find.text([ ...messageResponseList, ...paginatedMessageResponseList, - ].map((e) => '${e.channel.cid}-${e.message.id}').join(',')), + ].map((e) => '${e.channel?.cid}-${e.message.id}').join(',')), findsOneWidget, ); - verify(mockClient.search( - any, - query: anyNamed('query'), - sort: anyNamed('sort'), - messageFilters: anyNamed('messageFilters'), - paginationParams: updatedPagination, - )).called(1); + verify(() => mockClient.search( + testFilter, + query: any(named: 'query'), + sort: any(named: 'sort'), + messageFilters: any(named: 'messageFilters'), + paginationParams: updatedPagination, + )).called(1); }, ); @@ -426,8 +384,8 @@ void main() { (tester) async { const pagination = PaginationParams(); - StateSetter _stateSetter; - int limit = pagination.limit; + StateSetter? _stateSetter; + var limit = pagination.limit; const messageSearchListCoreKey = Key('messageSearchListCore'); const childWidgetKey = Key('childWidget'); @@ -438,26 +396,28 @@ void main() { key: childWidgetKey, child: Text( messages - .map((e) => '${e.channel.cid}-${e.message.id}') + .map((e) => '${e.channel?.cid}-${e.message.id}') .join(','), ), ), - loadingBuilder: (BuildContext context) => Offstage(), - emptyBuilder: (BuildContext context) => Offstage(), - errorBuilder: (BuildContext context, Object error) => Offstage(), + loadingBuilder: (BuildContext context) => const Offstage(), + emptyBuilder: (BuildContext context) => const Offstage(), + errorBuilder: (BuildContext context, Object error) => + const Offstage(), paginationParams: pagination.copyWith(limit: limit), + filters: testFilter, ); final mockClient = MockClient(); final messageResponseList = _generateMessages(); - when(mockClient.search( - any, - query: anyNamed('query'), - sort: anyNamed('sort'), - messageFilters: anyNamed('messageFilters'), - paginationParams: pagination, - )).thenAnswer( + when(() => mockClient.search( + testFilter, + query: any(named: 'query'), + sort: any(named: 'sort'), + messageFilters: any(named: 'messageFilters'), + paginationParams: pagination, + )).thenAnswer( (_) async => SearchMessagesResponse()..results = messageResponseList, ); @@ -483,32 +443,32 @@ void main() { expect( find.text( messageResponseList - .map((e) => '${e.channel.cid}-${e.message.id}') + .map((e) => '${e.channel?.cid}-${e.message.id}') .join(','), ), findsOneWidget, ); - verify(mockClient.search( - any, - query: anyNamed('query'), - sort: anyNamed('sort'), - messageFilters: anyNamed('messageFilters'), - paginationParams: pagination, - )).called(1); + verify(() => mockClient.search( + testFilter, + query: any(named: 'query'), + sort: any(named: 'sort'), + messageFilters: any(named: 'messageFilters'), + paginationParams: pagination, + )).called(1); // Rebuilding MessageSearchListCore with new pagination limit - _stateSetter(() => limit = 6); + _stateSetter?.call(() => limit = 6); final updatedMessageResponseList = _generateMessages(count: limit); final updatedPagination = pagination.copyWith(limit: limit); - when(mockClient.search( - any, - query: anyNamed('query'), - sort: anyNamed('sort'), - messageFilters: anyNamed('messageFilters'), - paginationParams: updatedPagination, - )).thenAnswer( + when(() => mockClient.search( + testFilter, + query: any(named: 'query'), + sort: any(named: 'sort'), + messageFilters: any(named: 'messageFilters'), + paginationParams: updatedPagination, + )).thenAnswer( (_) async => SearchMessagesResponse()..results = updatedMessageResponseList, ); @@ -518,18 +478,18 @@ void main() { expect(find.byKey(childWidgetKey), findsOneWidget); expect( find.text(updatedMessageResponseList - .map((e) => '${e.channel.cid}-${e.message.id}') + .map((e) => '${e.channel?.cid}-${e.message.id}') .join(',')), findsOneWidget, ); - verify(mockClient.search( - any, - query: anyNamed('query'), - sort: anyNamed('sort'), - messageFilters: anyNamed('messageFilters'), - paginationParams: updatedPagination, - )).called(1); + verify(() => mockClient.search( + testFilter, + query: any(named: 'query'), + sort: any(named: 'sort'), + messageFilters: any(named: 'messageFilters'), + paginationParams: updatedPagination, + )).called(1); }, ); } diff --git a/packages/stream_chat_flutter_core/test/mocks.dart b/packages/stream_chat_flutter_core/test/mocks.dart index a46b7b5c..73227e7c 100644 --- a/packages/stream_chat_flutter_core/test/mocks.dart +++ b/packages/stream_chat_flutter_core/test/mocks.dart @@ -1,19 +1,24 @@ -import 'package:mockito/mockito.dart'; +import 'package:mocktail/mocktail.dart'; import 'package:stream_chat/stream_chat.dart'; class MockLogger extends Mock implements Logger {} class MockClient extends Mock implements StreamChatClient { + MockClient() { + when(() => wsConnectionStatus).thenReturn(ConnectionStatus.connected); + } + + @override final Logger logger = MockLogger(); - ClientState _state; + ClientState? _state; @override ClientState get state => _state ??= MockClientState(); } class MockClientState extends Mock implements ClientState { - OwnUser _user; + OwnUser? _user; @override OwnUser get user => _user ??= OwnUser( @@ -25,12 +30,12 @@ class MockClientState extends Mock implements ClientState { } class MockChannel extends Mock implements Channel { - ChannelClientState _state; + ChannelClientState? _state; @override ChannelClientState get state => _state ??= MockChannelState(); - StreamChatClient _client; + StreamChatClient? _client; @override StreamChatClient get client => _client ??= MockClient(); diff --git a/packages/stream_chat_flutter_core/test/stream_channel_test.dart b/packages/stream_chat_flutter_core/test/stream_channel_test.dart index 4387a918..08763f9d 100644 --- a/packages/stream_chat_flutter_core/test/stream_channel_test.dart +++ b/packages/stream_chat_flutter_core/test/stream_channel_test.dart @@ -3,7 +3,7 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter/widgets.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:mockito/mockito.dart'; +import 'package:mocktail/mocktail.dart'; import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart'; import 'mocks.dart'; @@ -27,10 +27,9 @@ void main() { type: 'testType', user: users[index], createdAt: DateTime.now(), - shadowed: false, replyCount: index, updatedAt: DateTime.now(), - extraData: {'extra_test_field': 'extraTestData'}, + extraData: const {'extra_test_field': 'extraTestData'}, text: 'Dummy text #$index', pinned: true, pinnedAt: DateTime.now(), @@ -48,10 +47,9 @@ void main() { user: users[index], parentId: messages[0].id, createdAt: DateTime.now(), - shadowed: false, replyCount: index, updatedAt: DateTime.now(), - extraData: {'extra_test_field': 'extraTestData'}, + extraData: const {'extra_test_field': 'extraTestData'}, text: 'Dummy text #$index', pinned: true, pinnedAt: DateTime.now(), @@ -62,43 +60,17 @@ void main() { return threads ? threadMessages : messages; } - test( - 'should throw assertion error if child is null', - () async { - final mockChannel = MockChannel(); - const streamChannelKey = Key('streamChannel'); - final streamChannel = () => StreamChannel( - key: streamChannelKey, - channel: mockChannel, - child: null, - ); - expect(streamChannel, throwsA(isA())); - }, - ); - - test( - 'should throw assertion error if channel is null', - () async { - const streamChannelKey = Key('streamChannel'); - final streamChannel = () => StreamChannel( - key: streamChannelKey, - child: Offstage(), - channel: null, - ); - expect(streamChannel, throwsA(isA())); - }, - ); - testWidgets( 'should render StreamChannel if both channel and child is provided', (tester) async { final mockChannel = MockChannel(); const streamChannelKey = Key('streamChannel'); const childKey = Key('childKey'); + when(() => mockChannel.initialized).thenAnswer((_) => Future.value(true)); final streamChannel = StreamChannel( key: streamChannelKey, channel: mockChannel, - child: Offstage(key: childKey), + child: const Offstage(key: childKey), ); await tester.pumpWidget(streamChannel); @@ -117,12 +89,17 @@ void main() { final streamChannel = StreamChannel( key: streamChannelKey, channel: mockChannel, - child: Offstage(key: childKey), + child: const Offstage(key: childKey), ); - final errorMessage = 'Error! Error! Error!'; - final error = DioError(type: DioErrorType.RESPONSE, error: errorMessage); - when(mockChannel.initialized).thenAnswer((_) => Future.error(error)); + const errorMessage = 'Error! Error! Error!'; + final error = DioError( + type: DioErrorType.response, + error: errorMessage, + requestOptions: RequestOptions(path: ''), + ); + when(() => mockChannel.initialized) + .thenAnswer((_) => Future.error(error)); await tester.pumpWidget( Directionality( @@ -135,7 +112,7 @@ void main() { expect(find.text(errorMessage), findsOneWidget); - verify(mockChannel.initialized).called(1); + verify(() => mockChannel.initialized).called(1); }, ); @@ -149,11 +126,10 @@ void main() { final streamChannel = StreamChannel( key: streamChannelKey, channel: mockChannel, - child: Offstage(key: childKey), - showLoading: true, + child: const Offstage(key: childKey), ); - when(mockChannel.initialized).thenAnswer((_) async => false); + when(() => mockChannel.initialized).thenAnswer((_) async => false); await tester.pumpWidget( Directionality( @@ -166,7 +142,7 @@ void main() { expect(find.byType(CircularProgressIndicator), findsOneWidget); - verify(mockChannel.initialized).called(1); + verify(() => mockChannel.initialized).called(1); }, ); @@ -179,19 +155,21 @@ void main() { final streamChannel = StreamChannel( key: streamChannelKey, channel: mockChannel, - child: Offstage(key: childKey), initialMessageId: 'testInitialMessageId', + child: const Offstage(key: childKey), ); - when(mockChannel.initialized).thenAnswer((_) async => true); + when(() => mockChannel.initialized).thenAnswer((_) async => true); final messages = _generateMessages(); - when(mockChannel.query( - options: anyNamed('options'), - messagesPagination: anyNamed('messagesPagination'), - membersPagination: anyNamed('membersPagination'), - watchersPagination: anyNamed('watchersPagination'), - preferOffline: anyNamed('preferOffline'), - )).thenAnswer((_) async => ChannelState(messages: messages)); + when(() => mockChannel.query( + state: any(named: 'state'), + watch: any(named: 'watch'), + presence: any(named: 'presence'), + messagesPagination: any(named: 'messagesPagination'), + membersPagination: any(named: 'membersPagination'), + watchersPagination: any(named: 'watchersPagination'), + preferOffline: any(named: 'preferOffline'), + )).thenAnswer((_) async => ChannelState(messages: messages)); await tester.pumpWidget( Directionality( @@ -202,14 +180,16 @@ void main() { await tester.pumpAndSettle(); - verify(mockChannel.initialized).called(1); - verify(mockChannel.query( - options: anyNamed('options'), - messagesPagination: anyNamed('messagesPagination'), - membersPagination: anyNamed('membersPagination'), - watchersPagination: anyNamed('watchersPagination'), - preferOffline: anyNamed('preferOffline'), - )).called( + verify(() => mockChannel.initialized).called(1); + verify(() => mockChannel.query( + state: any(named: 'state'), + watch: any(named: 'watch'), + presence: any(named: 'presence'), + messagesPagination: any(named: 'messagesPagination'), + membersPagination: any(named: 'membersPagination'), + watchersPagination: any(named: 'watchersPagination'), + preferOffline: any(named: 'preferOffline'), + )).called( 2, // Fetching After messages + Fetching Before messages, ); }, @@ -219,7 +199,7 @@ void main() { 'should rebuild StreamChannel with updated widget data ' 'on calling setState()', (tester) async { - StateSetter _stateSetter; + StateSetter? _stateSetter; var initialMessageId = 'testInitialMessageId'; @@ -230,8 +210,8 @@ void main() { StreamChannel( key: streamChannelKey, channel: mockChannel, - child: Offstage(key: childKey), initialMessageId: initialMessageId, + child: const Offstage(key: childKey), ); final beforePagination = PaginationParams( @@ -244,25 +224,29 @@ void main() { limit: 20, ); - when(mockChannel.initialized).thenAnswer((_) async => true); + when(() => mockChannel.initialized).thenAnswer((_) async => true); final messages = _generateMessages(); - when(mockChannel.query( - options: anyNamed('options'), - messagesPagination: beforePagination, - membersPagination: anyNamed('membersPagination'), - watchersPagination: anyNamed('watchersPagination'), - preferOffline: anyNamed('preferOffline'), - )).thenAnswer((_) async => ChannelState(messages: messages)); + when(() => mockChannel.query( + state: any(named: 'state'), + watch: any(named: 'watch'), + presence: any(named: 'presence'), + messagesPagination: beforePagination, + membersPagination: any(named: 'membersPagination'), + watchersPagination: any(named: 'watchersPagination'), + preferOffline: any(named: 'preferOffline'), + )).thenAnswer((_) async => ChannelState(messages: messages)); - when(mockChannel.query( - options: anyNamed('options'), - messagesPagination: afterPagination, - membersPagination: anyNamed('membersPagination'), - watchersPagination: anyNamed('watchersPagination'), - preferOffline: anyNamed('preferOffline'), - )).thenAnswer((_) async => ChannelState(messages: messages)); + when(() => mockChannel.query( + state: any(named: 'state'), + watch: any(named: 'watch'), + presence: any(named: 'presence'), + messagesPagination: afterPagination, + membersPagination: any(named: 'membersPagination'), + watchersPagination: any(named: 'watchersPagination'), + preferOffline: any(named: 'preferOffline'), + )).thenAnswer((_) async => ChannelState(messages: messages)); await tester.pumpWidget( Directionality( @@ -279,23 +263,27 @@ void main() { await tester.pumpAndSettle(); - verify(mockChannel.query( - options: anyNamed('options'), - messagesPagination: beforePagination, - membersPagination: anyNamed('membersPagination'), - watchersPagination: anyNamed('watchersPagination'), - preferOffline: anyNamed('preferOffline'), - )).called(1); + verify(() => mockChannel.query( + state: any(named: 'state'), + watch: any(named: 'watch'), + presence: any(named: 'presence'), + messagesPagination: beforePagination, + membersPagination: any(named: 'membersPagination'), + watchersPagination: any(named: 'watchersPagination'), + preferOffline: any(named: 'preferOffline'), + )).called(1); - verify(mockChannel.query( - options: anyNamed('options'), - messagesPagination: afterPagination, - membersPagination: anyNamed('membersPagination'), - watchersPagination: anyNamed('watchersPagination'), - preferOffline: anyNamed('preferOffline'), - )).called(1); + verify(() => mockChannel.query( + state: any(named: 'state'), + watch: any(named: 'watch'), + presence: any(named: 'presence'), + messagesPagination: afterPagination, + membersPagination: any(named: 'membersPagination'), + watchersPagination: any(named: 'watchersPagination'), + preferOffline: any(named: 'preferOffline'), + )).called(1); - _stateSetter(() => initialMessageId = 'testInitialMessageId2'); + _stateSetter?.call(() => initialMessageId = 'testInitialMessageId2'); final updatedBeforePagination = beforePagination.copyWith( lessThan: initialMessageId, @@ -305,39 +293,47 @@ void main() { greaterThanOrEqual: initialMessageId, ); - when(mockChannel.query( - options: anyNamed('options'), - messagesPagination: updatedBeforePagination, - membersPagination: anyNamed('membersPagination'), - watchersPagination: anyNamed('watchersPagination'), - preferOffline: anyNamed('preferOffline'), - )).thenAnswer((_) async => ChannelState(messages: messages)); + when(() => mockChannel.query( + state: any(named: 'state'), + watch: any(named: 'watch'), + presence: any(named: 'presence'), + messagesPagination: updatedBeforePagination, + membersPagination: any(named: 'membersPagination'), + watchersPagination: any(named: 'watchersPagination'), + preferOffline: any(named: 'preferOffline'), + )).thenAnswer((_) async => ChannelState(messages: messages)); - when(mockChannel.query( - options: anyNamed('options'), - messagesPagination: updatedAfterPagination, - membersPagination: anyNamed('membersPagination'), - watchersPagination: anyNamed('watchersPagination'), - preferOffline: anyNamed('preferOffline'), - )).thenAnswer((_) async => ChannelState(messages: messages)); + when(() => mockChannel.query( + state: any(named: 'state'), + watch: any(named: 'watch'), + presence: any(named: 'presence'), + messagesPagination: updatedAfterPagination, + membersPagination: any(named: 'membersPagination'), + watchersPagination: any(named: 'watchersPagination'), + preferOffline: any(named: 'preferOffline'), + )).thenAnswer((_) async => ChannelState(messages: messages)); await tester.pumpAndSettle(); - verify(mockChannel.query( - options: anyNamed('options'), - messagesPagination: updatedBeforePagination, - membersPagination: anyNamed('membersPagination'), - watchersPagination: anyNamed('watchersPagination'), - preferOffline: anyNamed('preferOffline'), - )).called(1); + verify(() => mockChannel.query( + state: any(named: 'state'), + watch: any(named: 'watch'), + presence: any(named: 'presence'), + messagesPagination: updatedBeforePagination, + membersPagination: any(named: 'membersPagination'), + watchersPagination: any(named: 'watchersPagination'), + preferOffline: any(named: 'preferOffline'), + )).called(1); - verify(mockChannel.query( - options: anyNamed('options'), - messagesPagination: updatedAfterPagination, - membersPagination: anyNamed('membersPagination'), - watchersPagination: anyNamed('watchersPagination'), - preferOffline: anyNamed('preferOffline'), - )).called(1); + verify(() => mockChannel.query( + state: any(named: 'state'), + watch: any(named: 'watch'), + presence: any(named: 'presence'), + messagesPagination: updatedAfterPagination, + membersPagination: any(named: 'membersPagination'), + watchersPagination: any(named: 'watchersPagination'), + preferOffline: any(named: 'preferOffline'), + )).called(1); }, ); } diff --git a/packages/stream_chat_flutter_core/test/stream_chat_core_test.dart b/packages/stream_chat_flutter_core/test/stream_chat_core_test.dart index 4adc0062..ee9a86cb 100644 --- a/packages/stream_chat_flutter_core/test/stream_chat_core_test.dart +++ b/packages/stream_chat_flutter_core/test/stream_chat_core_test.dart @@ -1,8 +1,10 @@ import 'dart:async'; +import 'package:connectivity_plus/connectivity_plus.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:mockito/mockito.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:rxdart/rxdart.dart'; import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart'; import 'mocks.dart'; @@ -12,29 +14,6 @@ class MockOnBackgroundEventReceived extends Mock { } void main() { - test( - 'should throw assertion error in case client is null', - () { - final streamChatCore = () => StreamChatCore( - client: null, - child: Offstage(), - ); - expect(streamChatCore, throwsA(isA())); - }, - ); - - test( - 'should throw assertion error in case child is null', - () { - final mockClient = MockClient(); - final streamChatCore = () => StreamChatCore( - client: mockClient, - child: null, - ); - expect(streamChatCore, throwsA(isA())); - }, - ); - testWidgets( 'should render StreamChatCore if both client and child is provided', (tester) async { @@ -44,7 +23,7 @@ void main() { final streamChatCore = StreamChatCore( key: streamChatCoreKey, client: mockClient, - child: Offstage(key: childKey), + child: const Offstage(key: childKey), ); await tester.pumpWidget(streamChatCore); @@ -63,7 +42,7 @@ void main() { final streamChatCore = StreamChatCore( key: streamChatCoreKey, client: mockClient, - child: Offstage(key: childKey), + child: const Offstage(key: childKey), ); await tester.pumpWidget(streamChatCore); @@ -74,7 +53,7 @@ void main() { ); testWidgets( - 'didChangeAppLifecycleState should call client.disconnect() and return ' + 'didChangeAppLifecycleState should call client.closeConnection and return ' 'if onBackgroundEventReceived is null and the widget lifestyle changes to ' 'AppLifecycleState.paused', (tester) async { @@ -84,7 +63,7 @@ void main() { final streamChatCore = StreamChatCore( key: streamChatCoreKey, client: mockClient, - child: Offstage(key: childKey), + child: const Offstage(key: childKey), ); await tester.pumpWidget(streamChatCore); @@ -92,7 +71,8 @@ void main() { expect(find.byKey(streamChatCoreKey), findsOneWidget); expect(find.byKey(childKey), findsOneWidget); - when(mockClient.disconnect()).thenAnswer((_) async { + // ignore: prefer_expression_function_bodies + when(mockClient.closeConnection).thenAnswer((_) async { return; }); @@ -100,9 +80,10 @@ void main() { find.byKey(streamChatCoreKey), ); + // ignore: cascade_invocations streamChatCoreState.didChangeAppLifecycleState(AppLifecycleState.paused); - verify(mockClient.disconnect()).called(1); + verify(mockClient.closeConnection).called(1); }, ); @@ -114,15 +95,16 @@ void main() { await tester.runAsync(() async { final mockClient = MockClient(); final mockOnBackgroundEventReceived = MockOnBackgroundEventReceived(); - const backgroundKeepAlive = const Duration(seconds: 3); + const backgroundKeepAlive = Duration(seconds: 3); const streamChatCoreKey = Key('streamChatCore'); const childKey = Key('child'); final streamChatCore = StreamChatCore( key: streamChatCoreKey, client: mockClient, - child: Offstage(key: childKey), onBackgroundEventReceived: mockOnBackgroundEventReceived, backgroundKeepAlive: backgroundKeepAlive, + connectivityStream: Stream.value(ConnectivityResult.mobile), + child: const Offstage(key: childKey), ); await tester.pumpWidget(streamChatCore); @@ -130,9 +112,10 @@ void main() { expect(find.byKey(streamChatCoreKey), findsOneWidget); expect(find.byKey(childKey), findsOneWidget); - final event = Event(); - when(mockClient.on()).thenAnswer((_) => Stream.value(event)); - when(mockClient.disconnect()).thenAnswer((_) async { + final event = Event(type: EventType.any); + when(mockClient.on).thenAnswer((_) => Stream.value(event)); + // ignore: prefer_expression_function_bodies + when(mockClient.closeConnection).thenAnswer((_) async { return; }); @@ -140,17 +123,18 @@ void main() { find.byKey(streamChatCoreKey), ); + // ignore: cascade_invocations streamChatCoreState .didChangeAppLifecycleState(AppLifecycleState.paused); - await untilCalled(mockOnBackgroundEventReceived.call(event)); + await untilCalled(() => mockOnBackgroundEventReceived.call(event)); - verify(mockOnBackgroundEventReceived.call(event)).called(1); + verify(() => mockOnBackgroundEventReceived.call(event)).called(1); await Future.delayed(backgroundKeepAlive); - verify(mockClient.disconnect()).called(1); - verifyNever(mockOnBackgroundEventReceived.call(event)); + verify(mockClient.closeConnection).called(1); + verifyNever(() => mockOnBackgroundEventReceived.call(event)); }); }, ); @@ -163,15 +147,15 @@ void main() { await tester.runAsync(() async { final mockClient = MockClient(); final mockOnBackgroundEventReceived = MockOnBackgroundEventReceived(); - const backgroundKeepAlive = const Duration(seconds: 3); + const backgroundKeepAlive = Duration(seconds: 3); const streamChatCoreKey = Key('streamChatCore'); const childKey = Key('child'); final streamChatCore = StreamChatCore( key: streamChatCoreKey, client: mockClient, - child: Offstage(key: childKey), onBackgroundEventReceived: mockOnBackgroundEventReceived, backgroundKeepAlive: backgroundKeepAlive, + child: const Offstage(key: childKey), ); await tester.pumpWidget(streamChatCore); @@ -179,24 +163,25 @@ void main() { expect(find.byKey(streamChatCoreKey), findsOneWidget); expect(find.byKey(childKey), findsOneWidget); - final event = Event(); - when(mockClient.on()).thenAnswer((_) => Stream.value(event)); + final event = Event(type: EventType.any); + when(mockClient.on).thenAnswer((_) => Stream.value(event)); final streamChatCoreState = tester.state( find.byKey(streamChatCoreKey), ); + // ignore: cascade_invocations streamChatCoreState .didChangeAppLifecycleState(AppLifecycleState.paused); - await untilCalled(mockOnBackgroundEventReceived.call(event)); + await untilCalled(() => mockOnBackgroundEventReceived.call(event)); - verify(mockOnBackgroundEventReceived.call(event)).called(1); + verify(() => mockOnBackgroundEventReceived.call(event)).called(1); streamChatCoreState .didChangeAppLifecycleState(AppLifecycleState.resumed); - verifyNever(mockOnBackgroundEventReceived.call(event)); + verifyNever(() => mockOnBackgroundEventReceived.call(event)); }); }, ); @@ -213,7 +198,8 @@ void main() { final streamChatCore = StreamChatCore( key: streamChatCoreKey, client: mockClient, - child: Offstage(key: childKey), + connectivityStream: Stream.value(ConnectivityResult.mobile), + child: const Offstage(key: childKey), ); await tester.pumpWidget(streamChatCore); @@ -221,16 +207,23 @@ void main() { expect(find.byKey(streamChatCoreKey), findsOneWidget); expect(find.byKey(childKey), findsOneWidget); - final event = Event(); - when(mockClient.on()).thenAnswer((_) => Stream.value(event)); - when(mockClient.connect()).thenAnswer((_) async => event); - when(mockClient.wsConnectionStatus) + final event = Event(type: EventType.any); + when(mockClient.on).thenAnswer((_) => Stream.value(event)); + when(mockClient.openConnection) + .thenAnswer((_) async => OwnUser(id: 'test')); + + // ignore: prefer_expression_function_bodies + when(mockClient.closeConnection).thenAnswer((_) async { + return; + }); + when(() => mockClient.wsConnectionStatus) .thenReturn(ConnectionStatus.disconnected); final streamChatCoreState = tester.state( find.byKey(streamChatCoreKey), ); + // ignore: cascade_invocations streamChatCoreState .didChangeAppLifecycleState(AppLifecycleState.paused); @@ -239,7 +232,200 @@ void main() { streamChatCoreState .didChangeAppLifecycleState(AppLifecycleState.resumed); - verify(mockClient.connect()).called(1); + verify(mockClient.openConnection).called(1); + }); + }, + ); + + testWidgets( + 'didChangeAppLifecycleState should not call client.openConnection() ' + 'if connection is not available in case the ' + 'widget lifestyle changes to AppLifecycleState.resume', + (tester) async { + await tester.runAsync(() async { + final mockClient = MockClient(); + const streamChatCoreKey = Key('streamChatCore'); + const childKey = Key('child'); + + final event = Event(); + when(mockClient.on).thenAnswer((_) => Stream.value(event)); + when(mockClient.openConnection) + .thenAnswer((_) async => OwnUser(id: 'test')); + + // ignore: prefer_expression_function_bodies + when(mockClient.closeConnection).thenAnswer((_) async { + return; + }); + when(() => mockClient.wsConnectionStatus) + .thenReturn(ConnectionStatus.disconnected); + + final streamChatCore = StreamChatCore( + key: streamChatCoreKey, + client: mockClient, + connectivityStream: Stream.value(ConnectivityResult.none), + child: const Offstage(key: childKey), + ); + + await tester.pumpWidget(streamChatCore); + + expect(find.byKey(streamChatCoreKey), findsOneWidget); + expect(find.byKey(childKey), findsOneWidget); + + final streamChatCoreState = tester.state( + find.byKey(streamChatCoreKey), + ); + + // ignore: cascade_invocations + streamChatCoreState + .didChangeAppLifecycleState(AppLifecycleState.paused); + + await Future.delayed(const Duration(seconds: 1)); + + streamChatCoreState + .didChangeAppLifecycleState(AppLifecycleState.resumed); + + verifyNever(mockClient.openConnection); + }); + }, + ); + testWidgets( + 'didChangeAppLifecycleState should cancel the backgroundKeepAlive timer ' + 'if it is currently running in case the widget lifestyle changes to ' + 'AppLifecycleState.inactive', + (tester) async { + await tester.runAsync(() async { + final mockClient = MockClient(); + final mockOnBackgroundEventReceived = MockOnBackgroundEventReceived(); + const backgroundKeepAlive = Duration(seconds: 3); + const streamChatCoreKey = Key('streamChatCore'); + const childKey = Key('child'); + final streamChatCore = StreamChatCore( + key: streamChatCoreKey, + client: mockClient, + onBackgroundEventReceived: mockOnBackgroundEventReceived, + backgroundKeepAlive: backgroundKeepAlive, + child: const Offstage(key: childKey), + ); + + await tester.pumpWidget(streamChatCore); + + expect(find.byKey(streamChatCoreKey), findsOneWidget); + expect(find.byKey(childKey), findsOneWidget); + + final event = Event(type: EventType.any); + when(mockClient.on).thenAnswer((_) => Stream.value(event)); + + final streamChatCoreState = tester.state( + find.byKey(streamChatCoreKey), + ); + + // ignore: cascade_invocations + streamChatCoreState + .didChangeAppLifecycleState(AppLifecycleState.paused); + + await untilCalled(() => mockOnBackgroundEventReceived.call(event)); + + verify(() => mockOnBackgroundEventReceived.call(event)).called(1); + + streamChatCoreState + .didChangeAppLifecycleState(AppLifecycleState.inactive); + + verifyNever(() => mockOnBackgroundEventReceived.call(event)); + }); + }, + ); + + testWidgets( + 'didChangeAppLifecycleState should call client.connect() ' + 'if the connectionStatus is ConnectionStatus.disconnected in case the ' + 'widget lifestyle changes to AppLifecycleState.inactive', + (tester) async { + await tester.runAsync(() async { + final mockClient = MockClient(); + const streamChatCoreKey = Key('streamChatCore'); + const childKey = Key('child'); + final streamChatCore = StreamChatCore( + key: streamChatCoreKey, + client: mockClient, + connectivityStream: Stream.value(ConnectivityResult.mobile), + child: const Offstage(key: childKey), + ); + + await tester.pumpWidget(streamChatCore); + + expect(find.byKey(streamChatCoreKey), findsOneWidget); + expect(find.byKey(childKey), findsOneWidget); + + final event = Event(type: EventType.any); + when(mockClient.on).thenAnswer((_) => Stream.value(event)); + when(mockClient.openConnection) + .thenAnswer((_) async => OwnUser(id: 'test')); + when(mockClient.closeConnection).thenAnswer((_) async {}); + when(() => mockClient.wsConnectionStatus) + .thenReturn(ConnectionStatus.disconnected); + + final streamChatCoreState = tester.state( + find.byKey(streamChatCoreKey), + ); + + // ignore: cascade_invocations + streamChatCoreState + .didChangeAppLifecycleState(AppLifecycleState.paused); + + await Future.delayed(const Duration(seconds: 1)); + + streamChatCoreState + .didChangeAppLifecycleState(AppLifecycleState.inactive); + + verify(mockClient.openConnection).called(1); + }); + }, + ); + + testWidgets( + 'didChangeAppLifecycleState should not call client.openConnection() ' + 'if connection is not available in case the ' + 'widget lifestyle changes to AppLifecycleState.inactive', + (tester) async { + await tester.runAsync(() async { + final mockClient = MockClient(); + const streamChatCoreKey = Key('streamChatCore'); + const childKey = Key('child'); + + final event = Event(); + when(mockClient.on).thenAnswer((_) => Stream.value(event)); + when(mockClient.openConnection) + .thenAnswer((_) async => OwnUser(id: 'test')); + when(mockClient.closeConnection).thenAnswer((_) async {}); + when(() => mockClient.wsConnectionStatus) + .thenReturn(ConnectionStatus.disconnected); + + final streamChatCore = StreamChatCore( + key: streamChatCoreKey, + client: mockClient, + connectivityStream: Stream.value(ConnectivityResult.none), + child: const Offstage(key: childKey), + ); + + await tester.pumpWidget(streamChatCore); + + expect(find.byKey(streamChatCoreKey), findsOneWidget); + expect(find.byKey(childKey), findsOneWidget); + + final streamChatCoreState = tester.state( + find.byKey(streamChatCoreKey), + ); + + // ignore: cascade_invocations + streamChatCoreState + .didChangeAppLifecycleState(AppLifecycleState.paused); + + await Future.delayed(const Duration(seconds: 1)); + + streamChatCoreState + .didChangeAppLifecycleState(AppLifecycleState.inactive); + + verifyNever(mockClient.openConnection); }); }, ); @@ -257,7 +443,7 @@ void main() { final streamChatCore = StreamChatCore( key: streamChatCoreKey, client: mockClient, - child: Offstage(key: childKey), + child: const Offstage(key: childKey), ); await tester.pumpWidget(streamChatCore); @@ -265,7 +451,7 @@ void main() { expect(find.byKey(streamChatCoreKey), findsOneWidget); expect(find.byKey(childKey), findsOneWidget); - when(mockClient.state.userStream) + when(() => mockClient.state.userStream) .thenAnswer((_) => userController.stream); final streamChatCoreState = tester.state( @@ -280,9 +466,151 @@ void main() { emits(ownUser), ); - addTearDown(() { - userController.close(); + addTearDown(userController.close); + }); + }, + ); + + testWidgets( + 'should call connect if in foreground and connection is back', + (tester) async { + await tester.runAsync(() async { + final mockClient = MockClient(); + const streamChatCoreKey = Key('streamChatCore'); + const childKey = Key('child'); + final _connectivityController = + BehaviorSubject.seeded(ConnectivityResult.none); + + final event = Event(); + when(mockClient.on).thenAnswer((_) => Stream.value(event)); + when(mockClient.openConnection) + .thenAnswer((_) async => OwnUser(id: 'test')); + + // ignore: prefer_expression_function_bodies + when(mockClient.closeConnection).thenAnswer((_) async { + return; }); + when(() => mockClient.wsConnectionStatus) + .thenReturn(ConnectionStatus.disconnected); + + final streamChatCore = StreamChatCore( + key: streamChatCoreKey, + client: mockClient, + connectivityStream: _connectivityController.stream, + child: const Offstage(key: childKey), + ); + + await tester.pumpWidget(streamChatCore); + + expect(find.byKey(streamChatCoreKey), findsOneWidget); + expect(find.byKey(childKey), findsOneWidget); + + _connectivityController.add(ConnectivityResult.mobile); + + await Future.delayed(const Duration(seconds: 1)); + + verify(mockClient.openConnection).called(1); + + addTearDown(_connectivityController.close); + }); + }, + ); + + testWidgets( + 'should call disconnect if in foreground and connection goes away', + (tester) async { + await tester.runAsync(() async { + final mockClient = MockClient(); + const streamChatCoreKey = Key('streamChatCore'); + const childKey = Key('child'); + final _connectivityController = + BehaviorSubject.seeded(ConnectivityResult.mobile); + final streamChatCore = StreamChatCore( + key: streamChatCoreKey, + client: mockClient, + connectivityStream: _connectivityController.stream, + child: const Offstage(key: childKey), + ); + + await tester.pumpWidget(streamChatCore); + + expect(find.byKey(streamChatCoreKey), findsOneWidget); + expect(find.byKey(childKey), findsOneWidget); + + final event = Event(); + when(mockClient.on).thenAnswer((_) => Stream.value(event)); + when(mockClient.openConnection) + .thenAnswer((_) async => OwnUser(id: 'test')); + + // ignore: prefer_expression_function_bodies + when(mockClient.closeConnection).thenAnswer((_) async { + return; + }); + when(() => mockClient.wsConnectionStatus) + .thenReturn(ConnectionStatus.connected); + + _connectivityController.add(ConnectivityResult.none); + + await Future.delayed(const Duration(seconds: 1)); + + verify(mockClient.closeConnection).called(1); + + addTearDown(_connectivityController.close); + }); + }, + ); + + testWidgets( + 'should ignore connectivity in background', + (tester) async { + await tester.runAsync(() async { + final mockClient = MockClient(); + const streamChatCoreKey = Key('streamChatCore'); + const childKey = Key('child'); + final _connectivityController = + BehaviorSubject.seeded(ConnectivityResult.none); + + final event = Event(); + when(mockClient.on).thenAnswer((_) => Stream.value(event)); + when(mockClient.openConnection) + .thenAnswer((_) async => OwnUser(id: 'test')); + + // ignore: prefer_expression_function_bodies + when(mockClient.closeConnection).thenAnswer((_) async { + return; + }); + when(() => mockClient.wsConnectionStatus) + .thenReturn(ConnectionStatus.disconnected); + + final streamChatCore = StreamChatCore( + key: streamChatCoreKey, + client: mockClient, + connectivityStream: _connectivityController.stream, + child: const Offstage(key: childKey), + ); + + await tester.pumpWidget(streamChatCore); + + expect(find.byKey(streamChatCoreKey), findsOneWidget); + expect(find.byKey(childKey), findsOneWidget); + + final streamChatCoreState = tester.state( + find.byKey(streamChatCoreKey), + ); + + // ignore: cascade_invocations + streamChatCoreState + .didChangeAppLifecycleState(AppLifecycleState.paused); + + await Future.delayed(const Duration(seconds: 1)); + + _connectivityController.add(ConnectivityResult.mobile); + + await Future.delayed(const Duration(seconds: 1)); + + verifyNever(mockClient.closeConnection); + + addTearDown(_connectivityController.close); }); }, ); diff --git a/packages/stream_chat_flutter_core/test/user_list_core_test.dart b/packages/stream_chat_flutter_core/test/user_list_core_test.dart index d1c7e83a..eac8dc71 100644 --- a/packages/stream_chat_flutter_core/test/user_list_core_test.dart +++ b/packages/stream_chat_flutter_core/test/user_list_core_test.dart @@ -1,6 +1,6 @@ import 'package:flutter/widgets.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:mockito/mockito.dart'; +import 'package:mocktail/mocktail.dart'; import 'package:stream_chat_flutter_core/src/user_list_core.dart'; import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart'; @@ -12,78 +12,24 @@ void main() { List _generateUsers({ int count = 3, int offset = 0, - }) { - return List.generate( - count, - (index) { - index = index + offset; - return User( - id: 'testId$index', - role: 'testRole$index', - createdAt: DateTime.now(), - updatedAt: DateTime.now(), - lastActive: DateTime.now(), - online: true, - banned: false, - extraData: { - 'name': '${alphabets[index]}-testName', - }, - ); - }, - ); - } - - test( - 'should throw assertion error in case listBuilder is null', - () { - final userListCore = () => UserListCore( - listBuilder: null, - loadingBuilder: (BuildContext context) => Offstage(), - emptyBuilder: (BuildContext context) => Offstage(), - errorBuilder: (Object error) => Offstage(), + }) => + List.generate( + count, + (index) { + index = index + offset; + return User( + id: 'testId$index', + role: 'testRole$index', + createdAt: DateTime.now(), + updatedAt: DateTime.now(), + lastActive: DateTime.now(), + online: true, + extraData: { + 'name': '${alphabets[index]}-testName', + }, ); - expect(userListCore, throwsA(isA())); - }, - ); - - test( - 'should throw assertion error in case loadingBuilder is null', - () { - final userListCore = () => UserListCore( - listBuilder: (_, __) => Offstage(), - loadingBuilder: null, - emptyBuilder: (BuildContext context) => Offstage(), - errorBuilder: (Object error) => Offstage(), - ); - expect(userListCore, throwsA(isA())); - }, - ); - - test( - 'should throw assertion error in case emptyBuilder is null', - () { - final userListCore = () => UserListCore( - listBuilder: (_, __) => Offstage(), - loadingBuilder: (BuildContext context) => Offstage(), - emptyBuilder: null, - errorBuilder: (Object error) => Offstage(), - ); - expect(userListCore, throwsA(isA())); - }, - ); - - test( - 'should throw assertion error in case errorBuilder is null', - () { - final userListCore = () => UserListCore( - listBuilder: (_, __) => Offstage(), - loadingBuilder: (BuildContext context) => Offstage(), - emptyBuilder: (BuildContext context) => Offstage(), - errorBuilder: null, - ); - expect(userListCore, throwsA(isA())); - }, - ); + }, + ); testWidgets( 'should throw if UserListCore is used where UsersBloc is not present ' @@ -92,16 +38,16 @@ void main() { const userListCoreKey = Key('userListCore'); final userListCore = UserListCore( key: userListCoreKey, - listBuilder: (_, __) => Offstage(), - loadingBuilder: (BuildContext context) => Offstage(), - emptyBuilder: (BuildContext context) => Offstage(), - errorBuilder: (Object error) => Offstage(), + listBuilder: (_, __) => const Offstage(), + loadingBuilder: (BuildContext context) => const Offstage(), + emptyBuilder: (BuildContext context) => const Offstage(), + errorBuilder: (BuildContext context, Object error) => const Offstage(), ); await tester.pumpWidget(userListCore); expect(find.byKey(userListCoreKey), findsNothing); - expect(tester.takeException(), isInstanceOf()); + expect(tester.takeException(), isInstanceOf()); }, ); @@ -111,10 +57,10 @@ void main() { const userListCoreKey = Key('userListCore'); final userListCore = UserListCore( key: userListCoreKey, - listBuilder: (_, __) => Offstage(), - loadingBuilder: (BuildContext context) => Offstage(), - emptyBuilder: (BuildContext context) => Offstage(), - errorBuilder: (Object error) => Offstage(), + listBuilder: (_, __) => const Offstage(), + loadingBuilder: (BuildContext context) => const Offstage(), + emptyBuilder: (BuildContext context) => const Offstage(), + errorBuilder: (BuildContext context, Object error) => const Offstage(), ); final mockClient = MockClient(); @@ -140,10 +86,10 @@ void main() { final controller = UserListController(); final userListCore = UserListCore( key: userListCoreKey, - listBuilder: (_, __) => Offstage(), - loadingBuilder: (BuildContext context) => Offstage(), - emptyBuilder: (BuildContext context) => Offstage(), - errorBuilder: (Object error) => Offstage(), + listBuilder: (_, __) => const Offstage(), + loadingBuilder: (BuildContext context) => const Offstage(), + emptyBuilder: (BuildContext context) => const Offstage(), + errorBuilder: (BuildContext context, Object error) => const Offstage(), userListController: controller, ); @@ -174,21 +120,22 @@ void main() { const errorWidgetKey = Key('errorWidget'); final userListCore = UserListCore( key: userListCoreKey, - listBuilder: (_, __) => Offstage(), - loadingBuilder: (BuildContext context) => Offstage(), - emptyBuilder: (BuildContext context) => Offstage(), - errorBuilder: (Object error) => Container(key: errorWidgetKey), + listBuilder: (_, __) => const Offstage(), + loadingBuilder: (BuildContext context) => const Offstage(), + emptyBuilder: (BuildContext context) => const Offstage(), + errorBuilder: (BuildContext context, Object error) => + Container(key: errorWidgetKey), ); final mockClient = MockClient(); const error = 'Error! Error! Error!'; - when(mockClient.queryUsers( - filter: anyNamed('filter'), - sort: anyNamed('sort'), - options: anyNamed('options'), - pagination: anyNamed('pagination'), - )).thenThrow(error); + when(() => mockClient.queryUsers( + filter: any(named: 'filter'), + sort: any(named: 'sort'), + presence: any(named: 'presence'), + pagination: any(named: 'pagination'), + )).thenThrow(error); await tester.pumpWidget( StreamChatCore( @@ -203,12 +150,12 @@ void main() { expect(find.byKey(errorWidgetKey), findsOneWidget); - verify(mockClient.queryUsers( - filter: anyNamed('filter'), - sort: anyNamed('sort'), - options: anyNamed('options'), - pagination: anyNamed('pagination'), - )).called(1); + verify(() => mockClient.queryUsers( + filter: any(named: 'filter'), + sort: any(named: 'sort'), + presence: any(named: 'presence'), + pagination: any(named: 'pagination'), + )).called(1); }, ); @@ -219,21 +166,21 @@ void main() { const emptyWidgetKey = Key('emptyWidget'); final userListCore = UserListCore( key: userListCoreKey, - listBuilder: (_, __) => Offstage(), - loadingBuilder: (BuildContext context) => Offstage(), + listBuilder: (_, __) => const Offstage(), + loadingBuilder: (BuildContext context) => const Offstage(), emptyBuilder: (BuildContext context) => Container(key: emptyWidgetKey), - errorBuilder: (Object error) => Offstage(), + errorBuilder: (BuildContext context, Object error) => const Offstage(), ); final mockClient = MockClient(); const users = []; - when(mockClient.queryUsers( - filter: anyNamed('filter'), - sort: anyNamed('sort'), - options: anyNamed('options'), - pagination: anyNamed('pagination'), - )).thenAnswer((_) async => QueryUsersResponse()..users = users); + when(() => mockClient.queryUsers( + filter: any(named: 'filter'), + sort: any(named: 'sort'), + presence: any(named: 'presence'), + pagination: any(named: 'pagination'), + )).thenAnswer((_) async => QueryUsersResponse()..users = users); await tester.pumpWidget( StreamChatCore( @@ -248,12 +195,12 @@ void main() { expect(find.byKey(emptyWidgetKey), findsOneWidget); - verify(mockClient.queryUsers( - filter: anyNamed('filter'), - sort: anyNamed('sort'), - options: anyNamed('options'), - pagination: anyNamed('pagination'), - )).called(1); + verify(() => mockClient.queryUsers( + filter: any(named: 'filter'), + sort: any(named: 'sort'), + presence: any(named: 'presence'), + pagination: any(named: 'pagination'), + )).called(1); }, ); @@ -265,20 +212,20 @@ void main() { final userListCore = UserListCore( key: userListCoreKey, listBuilder: (_, __) => Container(key: listWidgetKey), - loadingBuilder: (BuildContext context) => Offstage(), - emptyBuilder: (BuildContext context) => Offstage(), - errorBuilder: (Object error) => Offstage(), + loadingBuilder: (BuildContext context) => const Offstage(), + emptyBuilder: (BuildContext context) => const Offstage(), + errorBuilder: (BuildContext context, Object error) => const Offstage(), ); final mockClient = MockClient(); final users = _generateUsers(); - when(mockClient.queryUsers( - filter: anyNamed('filter'), - sort: anyNamed('sort'), - options: anyNamed('options'), - pagination: anyNamed('pagination'), - )).thenAnswer((_) async => QueryUsersResponse()..users = users); + when(() => mockClient.queryUsers( + filter: any(named: 'filter'), + sort: any(named: 'sort'), + presence: any(named: 'presence'), + pagination: any(named: 'pagination'), + )).thenAnswer((_) async => QueryUsersResponse()..users = users); await tester.pumpWidget( StreamChatCore( @@ -293,12 +240,12 @@ void main() { expect(find.byKey(listWidgetKey), findsOneWidget); - verify(mockClient.queryUsers( - filter: anyNamed('filter'), - sort: anyNamed('sort'), - options: anyNamed('options'), - pagination: anyNamed('pagination'), - )).called(1); + verify(() => mockClient.queryUsers( + filter: any(named: 'filter'), + sort: any(named: 'sort'), + presence: any(named: 'presence'), + pagination: any(named: 'pagination'), + )).called(1); }, ); @@ -312,32 +259,32 @@ void main() { listBuilder: (_, items) => Container( key: listWidgetKey, child: ListView( - children: items.map((e) { - return Container( - key: Key(e.key), - child: e.when( - headerItem: (heading) => Text(heading), - userItem: (user) => Text(user.id), - ), - ); - }).toList(growable: false), + children: items + .map((e) => Container( + key: Key(e.key ?? ''), + child: e.when( + headerItem: (heading) => Text(heading), + userItem: (user) => Text(user.id), + ), + )) + .toList(growable: false), ), ), - loadingBuilder: (BuildContext context) => Offstage(), - emptyBuilder: (BuildContext context) => Offstage(), - errorBuilder: (Object error) => Offstage(), + loadingBuilder: (BuildContext context) => const Offstage(), + emptyBuilder: (BuildContext context) => const Offstage(), + errorBuilder: (BuildContext context, Object error) => const Offstage(), groupAlphabetically: true, ); final mockClient = MockClient(); final users = _generateUsers(); - when(mockClient.queryUsers( - filter: anyNamed('filter'), - sort: anyNamed('sort'), - options: anyNamed('options'), - pagination: anyNamed('pagination'), - )).thenAnswer((_) async => QueryUsersResponse()..users = users); + when(() => mockClient.queryUsers( + filter: any(named: 'filter'), + sort: any(named: 'sort'), + presence: any(named: 'presence'), + pagination: any(named: 'pagination'), + )).thenAnswer((_) async => QueryUsersResponse()..users = users); await tester.pumpWidget( Directionality( @@ -359,12 +306,12 @@ void main() { expect(find.byKey(Key('USER-${user.id}')), findsOneWidget); } - verify(mockClient.queryUsers( - filter: anyNamed('filter'), - sort: anyNamed('sort'), - options: anyNamed('options'), - pagination: anyNamed('pagination'), - )).called(1); + verify(() => mockClient.queryUsers( + filter: any(named: 'filter'), + sort: any(named: 'sort'), + presence: any(named: 'presence'), + pagination: any(named: 'pagination'), + )).called(1); }, ); @@ -380,20 +327,20 @@ void main() { listBuilder: (_, items) => Container( key: listWidgetKey, child: ListView( - children: items.map((e) { - return Container( - key: Key(e.key), - child: e.when( - headerItem: (heading) => Text(heading), - userItem: (user) => Text(user.id), - ), - ); - }).toList(growable: false), + children: items + .map((e) => Container( + key: Key(e.key ?? ''), + child: e.when( + headerItem: (heading) => Text(heading), + userItem: (user) => Text(user.id), + ), + )) + .toList(growable: false), ), ), - loadingBuilder: (BuildContext context) => Offstage(), - emptyBuilder: (BuildContext context) => Offstage(), - errorBuilder: (Object error) => Offstage(), + loadingBuilder: (BuildContext context) => const Offstage(), + emptyBuilder: (BuildContext context) => const Offstage(), + errorBuilder: (BuildContext context, Object error) => const Offstage(), pagination: pagination, groupAlphabetically: true, ); @@ -401,12 +348,12 @@ void main() { final mockClient = MockClient(); final users = _generateUsers(); - when(mockClient.queryUsers( - filter: anyNamed('filter'), - sort: anyNamed('sort'), - options: anyNamed('options'), - pagination: anyNamed('pagination'), - )).thenAnswer((_) async => QueryUsersResponse()..users = users); + when(() => mockClient.queryUsers( + filter: any(named: 'filter'), + sort: any(named: 'sort'), + presence: any(named: 'presence'), + pagination: any(named: 'pagination'), + )).thenAnswer((_) async => QueryUsersResponse()..users = users); await tester.pumpWidget( Directionality( @@ -428,12 +375,12 @@ void main() { expect(find.byKey(Key('USER-${user.id}')), findsOneWidget); } - verify(mockClient.queryUsers( - filter: anyNamed('filter'), - sort: anyNamed('sort'), - options: anyNamed('options'), - pagination: anyNamed('pagination'), - )).called(1); + verify(() => mockClient.queryUsers( + filter: any(named: 'filter'), + sort: any(named: 'sort'), + presence: any(named: 'presence'), + pagination: any(named: 'pagination'), + )).called(1); final userListCoreState = tester.state( find.byKey(userListCoreKey), @@ -442,12 +389,14 @@ void main() { final offset = users.length; final paginatedUsers = _generateUsers(offset: offset); final updatedPagination = pagination.copyWith(offset: offset); - when(mockClient.queryUsers( - filter: anyNamed('filter'), - sort: anyNamed('sort'), - options: anyNamed('options'), - pagination: updatedPagination, - )).thenAnswer((_) async => QueryUsersResponse()..users = paginatedUsers); + when(() => mockClient.queryUsers( + filter: any(named: 'filter'), + sort: any(named: 'sort'), + presence: any(named: 'presence'), + pagination: updatedPagination, + )) + .thenAnswer( + (_) async => QueryUsersResponse()..users = paginatedUsers); await userListCoreState.paginateData(); @@ -458,12 +407,12 @@ void main() { expect(find.byKey(Key('USER-${user.id}')), findsOneWidget); } - verify(mockClient.queryUsers( - filter: anyNamed('filter'), - sort: anyNamed('sort'), - options: anyNamed('options'), - pagination: updatedPagination, - )).called(1); + verify(() => mockClient.queryUsers( + filter: any(named: 'filter'), + sort: any(named: 'sort'), + presence: any(named: 'presence'), + pagination: updatedPagination, + )).called(1); }, ); @@ -473,8 +422,8 @@ void main() { (tester) async { const pagination = PaginationParams(); - StateSetter _stateSetter; - int limit = pagination.limit; + StateSetter? _stateSetter; + var limit = pagination.limit; const userListCoreKey = Key('userListCore'); const listWidgetKey = Key('listWidget'); @@ -483,20 +432,21 @@ void main() { listBuilder: (_, items) => Container( key: listWidgetKey, child: ListView( - children: items.map((e) { - return Container( - key: Key(e.key), - child: e.when( - headerItem: (heading) => Text(heading), - userItem: (user) => Text(user.id), - ), - ); - }).toList(growable: false), + children: items + .map((e) => Container( + key: Key(e.key ?? ''), + child: e.when( + headerItem: (heading) => Text(heading), + userItem: (user) => Text(user.id), + ), + )) + .toList(growable: false), ), ), - loadingBuilder: (BuildContext context) => Offstage(), - emptyBuilder: (BuildContext context) => Offstage(), - errorBuilder: (Object error) => Offstage(), + loadingBuilder: (BuildContext context) => const Offstage(), + emptyBuilder: (BuildContext context) => const Offstage(), + errorBuilder: (BuildContext context, Object error) => + const Offstage(), pagination: pagination.copyWith(limit: limit), groupAlphabetically: true, ); @@ -504,12 +454,12 @@ void main() { final mockClient = MockClient(); final users = _generateUsers(); - when(mockClient.queryUsers( - filter: anyNamed('filter'), - sort: anyNamed('sort'), - options: anyNamed('options'), - pagination: anyNamed('pagination'), - )).thenAnswer((_) async => QueryUsersResponse()..users = users); + when(() => mockClient.queryUsers( + filter: any(named: 'filter'), + sort: any(named: 'sort'), + presence: any(named: 'presence'), + pagination: any(named: 'pagination'), + )).thenAnswer((_) async => QueryUsersResponse()..users = users); await tester.pumpWidget( Directionality( @@ -535,24 +485,25 @@ void main() { expect(find.byKey(Key('USER-${user.id}')), findsOneWidget); } - verify(mockClient.queryUsers( - filter: anyNamed('filter'), - sort: anyNamed('sort'), - options: anyNamed('options'), - pagination: anyNamed('pagination'), - )).called(1); + verify(() => mockClient.queryUsers( + filter: any(named: 'filter'), + sort: any(named: 'sort'), + presence: any(named: 'presence'), + pagination: any(named: 'pagination'), + )).called(1); // Rebuilding UserListCore with new pagination limit - _stateSetter(() => limit = 6); + _stateSetter?.call(() => limit = 6); final updatedUsers = _generateUsers(count: limit); final updatedPagination = pagination.copyWith(limit: limit); - when(mockClient.queryUsers( - filter: anyNamed('filter'), - sort: anyNamed('sort'), - options: anyNamed('options'), - pagination: updatedPagination, - )).thenAnswer((_) async => QueryUsersResponse()..users = updatedUsers); + when(() => mockClient.queryUsers( + filter: any(named: 'filter'), + sort: any(named: 'sort'), + presence: any(named: 'presence'), + pagination: updatedPagination, + )) + .thenAnswer((_) async => QueryUsersResponse()..users = updatedUsers); await tester.pumpAndSettle(); @@ -561,12 +512,12 @@ void main() { expect(find.byKey(Key('USER-${user.id}')), findsOneWidget); } - verify(mockClient.queryUsers( - filter: anyNamed('filter'), - sort: anyNamed('sort'), - options: anyNamed('options'), - pagination: updatedPagination, - )).called(1); + verify(() => mockClient.queryUsers( + filter: any(named: 'filter'), + sort: any(named: 'sort'), + presence: any(named: 'presence'), + pagination: updatedPagination, + )).called(1); }, ); } diff --git a/packages/stream_chat_flutter_core/test/users_bloc_test.dart b/packages/stream_chat_flutter_core/test/users_bloc_test.dart index e9ccd2e7..af088a76 100644 --- a/packages/stream_chat_flutter_core/test/users_bloc_test.dart +++ b/packages/stream_chat_flutter_core/test/users_bloc_test.dart @@ -1,9 +1,9 @@ import 'package:flutter/widgets.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; import 'package:stream_chat/stream_chat.dart'; import 'package:stream_chat_flutter_core/src/stream_chat_core.dart'; import 'package:stream_chat_flutter_core/src/users_bloc.dart'; -import 'package:mockito/mockito.dart'; import 'matchers/users_matcher.dart'; import 'mocks.dart'; @@ -12,62 +12,33 @@ void main() { List _generateUsers({ int count = 3, int offset = 0, - }) { - return List.generate( - count, - (index) { - index = index + offset; - return User( - id: 'testId$index', - role: 'testRole$index', - createdAt: DateTime.now(), - updatedAt: DateTime.now(), - lastActive: DateTime.now(), - online: true, - banned: false, - extraData: {'extra_data_key': 'extraDataValue'}, - ); - }, - ); - } - - test( - 'should throw assertion error if child is null', - () async { - const usersBlocKey = Key('usersBloc'); - final usersBloc = () => UsersBloc( - key: usersBlocKey, - child: null, + }) => + List.generate( + count, + (index) { + index = index + offset; + return User( + id: 'testId$index', + role: 'testRole$index', + createdAt: DateTime.now(), + updatedAt: DateTime.now(), + lastActive: DateTime.now(), + online: true, + extraData: const {'extra_data_key': 'extraDataValue'}, ); - expect(usersBloc, throwsA(isA())); - }, - ); + }, + ); testWidgets( 'usersBlocState.queryUsers() should throw if used where ' 'StreamChat is not present in the widget tree', (tester) async { - const usersBlocKey = Key('usersBloc'); - const childKey = Key('child'); - final usersBloc = UsersBloc( - key: usersBlocKey, - child: Offstage(key: childKey), + const usersBloc = UsersBloc( + child: Offstage(), ); await tester.pumpWidget(usersBloc); - - expect(find.byKey(usersBlocKey), findsOneWidget); - expect(find.byKey(childKey), findsOneWidget); - - final usersBlocState = tester.state( - find.byKey(usersBlocKey), - ); - - try { - await usersBlocState.queryUsers(); - } catch (e) { - expect(e, isInstanceOf()); - } + expect(tester.takeException(), isInstanceOf()); }, ); @@ -76,7 +47,7 @@ void main() { (tester) async { const usersBlocKey = Key('usersBloc'); const childKey = Key('child'); - final usersBloc = UsersBloc( + const usersBloc = UsersBloc( key: usersBlocKey, child: Offstage(key: childKey), ); @@ -96,12 +67,12 @@ void main() { final users = _generateUsers(); - when(mockClient.queryUsers( - filter: anyNamed('filter'), - sort: anyNamed('sort'), - options: anyNamed('options'), - pagination: anyNamed('pagination'), - )).thenAnswer((_) async => QueryUsersResponse()..users = users); + when(() => mockClient.queryUsers( + filter: any(named: 'filter'), + sort: any(named: 'sort'), + presence: any(named: 'presence'), + pagination: any(named: 'pagination'), + )).thenAnswer((_) async => QueryUsersResponse()..users = users); usersBlocState.queryUsers(); @@ -110,12 +81,12 @@ void main() { emits(isSameUserListAs(users)), ); - verify(mockClient.queryUsers( - filter: anyNamed('filter'), - sort: anyNamed('sort'), - options: anyNamed('options'), - pagination: anyNamed('pagination'), - )).called(1); + verify(() => mockClient.queryUsers( + filter: any(named: 'filter'), + sort: any(named: 'sort'), + presence: any(named: 'presence'), + pagination: any(named: 'pagination'), + )).called(1); }, ); @@ -125,7 +96,7 @@ void main() { (tester) async { const usersBlocKey = Key('usersBloc'); const childKey = Key('child'); - final usersBloc = UsersBloc( + const usersBloc = UsersBloc( key: usersBlocKey, child: Offstage(key: childKey), ); @@ -143,14 +114,14 @@ void main() { find.byKey(usersBlocKey), ); - final error = 'Error! Error! Error!'; + const error = 'Error! Error! Error!'; - when(mockClient.queryUsers( - filter: anyNamed('filter'), - sort: anyNamed('sort'), - options: anyNamed('options'), - pagination: anyNamed('pagination'), - )).thenThrow(error); + when(() => mockClient.queryUsers( + filter: any(named: 'filter'), + sort: any(named: 'sort'), + presence: any(named: 'presence'), + pagination: any(named: 'pagination'), + )).thenThrow(error); usersBlocState.queryUsers(); @@ -159,12 +130,12 @@ void main() { emitsError(error), ); - verify(mockClient.queryUsers( - filter: anyNamed('filter'), - sort: anyNamed('sort'), - options: anyNamed('options'), - pagination: anyNamed('pagination'), - )).called(1); + verify(() => mockClient.queryUsers( + filter: any(named: 'filter'), + sort: any(named: 'sort'), + presence: any(named: 'presence'), + pagination: any(named: 'pagination'), + )).called(1); }, ); @@ -175,7 +146,7 @@ void main() { (tester) async { const usersBlocKey = Key('usersBloc'); const childKey = Key('child'); - final usersBloc = UsersBloc( + const usersBloc = UsersBloc( key: usersBlocKey, child: Offstage(key: childKey), ); @@ -195,12 +166,12 @@ void main() { final users = _generateUsers(); - when(mockClient.queryUsers( - filter: anyNamed('filter'), - sort: anyNamed('sort'), - options: anyNamed('options'), - pagination: anyNamed('pagination'), - )).thenAnswer((_) async => QueryUsersResponse()..users = users); + when(() => mockClient.queryUsers( + filter: any(named: 'filter'), + sort: any(named: 'sort'), + presence: any(named: 'presence'), + pagination: any(named: 'pagination'), + )).thenAnswer((_) async => QueryUsersResponse()..users = users); usersBlocState.queryUsers(); @@ -209,23 +180,25 @@ void main() { emits(isSameUserListAs(users)), ); - verify(mockClient.queryUsers( - filter: anyNamed('filter'), - sort: anyNamed('sort'), - options: anyNamed('options'), - pagination: anyNamed('pagination'), - )).called(1); + verify(() => mockClient.queryUsers( + filter: any(named: 'filter'), + sort: any(named: 'sort'), + presence: any(named: 'presence'), + pagination: any(named: 'pagination'), + )).called(1); final offset = users.length; final paginatedUsers = _generateUsers(offset: offset); final pagination = PaginationParams(offset: offset); - when(mockClient.queryUsers( - filter: anyNamed('filter'), - sort: anyNamed('sort'), - options: anyNamed('options'), - pagination: pagination, - )).thenAnswer((_) async => QueryUsersResponse()..users = paginatedUsers); + when(() => mockClient.queryUsers( + filter: any(named: 'filter'), + sort: any(named: 'sort'), + presence: any(named: 'presence'), + pagination: pagination, + )) + .thenAnswer( + (_) async => QueryUsersResponse()..users = paginatedUsers); usersBlocState.queryUsers(pagination: pagination); @@ -240,12 +213,12 @@ void main() { ), ]); - verify(mockClient.queryUsers( - filter: anyNamed('filter'), - sort: anyNamed('sort'), - options: anyNamed('options'), - pagination: pagination, - )).called(1); + verify(() => mockClient.queryUsers( + filter: any(named: 'filter'), + sort: any(named: 'sort'), + presence: any(named: 'presence'), + pagination: pagination, + )).called(1); }, ); @@ -256,7 +229,7 @@ void main() { (tester) async { const usersBlocKey = Key('usersBloc'); const childKey = Key('child'); - final usersBloc = UsersBloc( + const usersBloc = UsersBloc( key: usersBlocKey, child: Offstage(key: childKey), ); @@ -276,12 +249,12 @@ void main() { final users = _generateUsers(); - when(mockClient.queryUsers( - filter: anyNamed('filter'), - sort: anyNamed('sort'), - options: anyNamed('options'), - pagination: anyNamed('pagination'), - )).thenAnswer((_) async => QueryUsersResponse()..users = users); + when(() => mockClient.queryUsers( + filter: any(named: 'filter'), + sort: any(named: 'sort'), + presence: any(named: 'presence'), + pagination: any(named: 'pagination'), + )).thenAnswer((_) async => QueryUsersResponse()..users = users); usersBlocState.queryUsers(); @@ -290,24 +263,24 @@ void main() { emits(isSameUserListAs(users)), ); - verify(mockClient.queryUsers( - filter: anyNamed('filter'), - sort: anyNamed('sort'), - options: anyNamed('options'), - pagination: anyNamed('pagination'), - )).called(1); + verify(() => mockClient.queryUsers( + filter: any(named: 'filter'), + sort: any(named: 'sort'), + presence: any(named: 'presence'), + pagination: any(named: 'pagination'), + )).called(1); final offset = users.length; final pagination = PaginationParams(offset: offset); - final error = 'Error! Error! Error!'; + const error = 'Error! Error! Error!'; - when(mockClient.queryUsers( - filter: anyNamed('filter'), - sort: anyNamed('sort'), - options: anyNamed('options'), - pagination: pagination, - )).thenThrow(error); + when(() => mockClient.queryUsers( + filter: any(named: 'filter'), + sort: any(named: 'sort'), + presence: any(named: 'presence'), + pagination: pagination, + )).thenThrow(error); usersBlocState.queryUsers(pagination: pagination); @@ -316,12 +289,12 @@ void main() { emitsError(error), ); - verify(mockClient.queryUsers( - filter: anyNamed('filter'), - sort: anyNamed('sort'), - options: anyNamed('options'), - pagination: pagination, - )).called(1); + verify(() => mockClient.queryUsers( + filter: any(named: 'filter'), + sort: any(named: 'sort'), + presence: any(named: 'presence'), + pagination: pagination, + )).called(1); }, ); } diff --git a/packages/stream_chat_persistence/CHANGELOG.md b/packages/stream_chat_persistence/CHANGELOG.md index e4971975..3a65db58 100644 --- a/packages/stream_chat_persistence/CHANGELOG.md +++ b/packages/stream_chat_persistence/CHANGELOG.md @@ -1,3 +1,32 @@ +## 2.0.0 +* Migrate this package to null safety +* Minor fixes and improvements + +## 2.0.0-nullsafety.8 +* Updated llc dependency +* Upgraded moor dependencies and generated files with the latest dependency + +## 2.0.0-nullsafety.7 + +* Update llc dependency +* Minor fixes and improvements + +## 2.0.0-nullsafety.5 + +* Update llc dependency +* Minor fixes and improvements + +## 2.0.0-nullsafety.2 + +* Update llc dependency +* Minor fixes and improvements +* Fixed bug not saving message.mentioned_users + +## 2.0.0-nullsafety.1 + +* Migrate this package to null safety +* Update llc dependency + ## 1.5.2 * Fix sorting by last_updated diff --git a/packages/stream_chat_persistence/analysis_options.yaml b/packages/stream_chat_persistence/analysis_options.yaml deleted file mode 100644 index 26001160..00000000 --- a/packages/stream_chat_persistence/analysis_options.yaml +++ /dev/null @@ -1,146 +0,0 @@ -analyzer: - exclude: - - lib/**/*.g.dart - - lib/**/*.freezed.dart - - example/* - - test/* -linter: - rules: - - always_use_package_imports - - avoid_empty_else - - avoid_relative_lib_imports - - avoid_slow_async_io - - avoid_types_as_parameter_names - - cancel_subscriptions - - close_sinks - - control_flow_in_finally - - diagnostic_describe_all_properties - - empty_statements - - hash_and_equals - - invariant_booleans - - iterable_contains_unrelated_type - - list_remove_unrelated_type - - literal_only_boolean_expressions - - no_adjacent_strings_in_list - - no_duplicate_case_values - - no_logic_in_create_state - - prefer_void_to_null - - test_types_in_equals - - throw_in_finally - - unnecessary_statements - - unrelated_type_equality_checks - - omit_local_variable_types - - use_key_in_widget_constructors - - valid_regexps - - always_declare_return_types - - always_put_required_named_parameters_first - - always_require_non_null_named_parameters - - annotate_overrides - - avoid_bool_literals_in_conditional_expressions - - avoid_catching_errors - - avoid_init_to_null - - avoid_null_checks_in_equality_operators - - avoid_positional_boolean_parameters - - avoid_private_typedef_functions - - avoid_redundant_argument_values - - avoid_return_types_on_setters - - avoid_returning_null_for_void - - avoid_shadowing_type_parameters - - avoid_single_cascade_in_expression_statements - - avoid_unnecessary_containers - - avoid_unused_constructor_parameters - - await_only_futures - - camel_case_extensions - - camel_case_types - - cascade_invocations - - - constant_identifier_names - - curly_braces_in_flow_control_structures - - directives_ordering - - empty_catches - - empty_constructor_bodies - - exhaustive_cases - - file_names - - implementation_imports - - join_return_with_assignment - - leading_newlines_in_multiline_strings - - library_names - - library_prefixes - - lines_longer_than_80_chars - - missing_whitespace_between_adjacent_strings - - non_constant_identifier_names - - null_closures - - one_member_abstracts - - only_throw_errors - - package_api_docs - - package_prefixed_library_names - - parameter_assignments - - prefer_adjacent_string_concatenation - - prefer_asserts_in_initializer_lists - - prefer_asserts_with_message - - prefer_collection_literals - - prefer_conditional_assignment - - prefer_const_constructors - - prefer_const_constructors_in_immutables - - prefer_const_declarations - - prefer_const_literals_to_create_immutables - - prefer_constructors_over_static_methods - - prefer_contains - - prefer_equal_for_default_values - - prefer_expression_function_bodies - - prefer_final_fields - - prefer_final_in_for_each - - prefer_final_locals - - prefer_function_declarations_over_variables - - prefer_generic_function_type_aliases - - prefer_if_elements_to_conditional_expressions - - prefer_if_null_operators - - prefer_initializing_formals - - prefer_inlined_adds - - prefer_int_literals - - prefer_interpolation_to_compose_strings - - prefer_is_empty - - prefer_is_not_empty - - prefer_is_not_operator - - prefer_null_aware_operators - - prefer_single_quotes - - prefer_spread_collections - - prefer_typing_uninitialized_variables - - provide_deprecation_message - - public_member_api_docs - - recursive_getters - - sized_box_for_whitespace - - slash_for_doc_comments - - sort_child_properties_last - - sort_constructors_first - - sort_unnamed_constructors_first - - - type_annotate_public_apis - - type_init_formals - - unnecessary_await_in_return - - unnecessary_brace_in_string_interps - - unnecessary_const - - unnecessary_getters_setters - - unnecessary_lambdas - - unnecessary_new - - unnecessary_null_aware_assignments - - unnecessary_null_in_if_null_operators - - unnecessary_nullable_for_final_variable_declarations - - unnecessary_parenthesis - - unnecessary_raw_strings - - unnecessary_string_escapes - - unnecessary_string_interpolations - - unnecessary_this - - use_is_even_rather_than_modulo - - use_late_for_private_fields_and_variables - - use_rethrow_when_possible - - use_setters_to_change_properties - - use_to_and_as_if_applicable - - package_names - - sort_pub_dependencies - - # To be added when null-safe: - # - cast_nullable_to_non_nullable - #- unnecessary_null_checks - # - tighten_type_of_initializing_formals - # - null_check_on_nullable_type_parameter \ No newline at end of file diff --git a/packages/stream_chat_persistence/example/lib/main.dart b/packages/stream_chat_persistence/example/lib/main.dart index b356d96b..ab6dcd1c 100644 --- a/packages/stream_chat_persistence/example/lib/main.dart +++ b/packages/stream_chat_persistence/example/lib/main.dart @@ -3,8 +3,8 @@ import 'package:stream_chat/stream_chat.dart'; import 'package:stream_chat_persistence/stream_chat_persistence.dart'; Future main() async { - /// Create a new instance of [StreamChatClient] passing the apikey obtained from your - /// project dashboard. + /// Create a new instance of [StreamChatClient] passing the apikey obtained + /// from your project dashboard. final client = StreamChatClient('b67pax5b2wdq'); WidgetsFlutterBinding.ensureInitialized(); @@ -22,12 +22,13 @@ Future main() async { await client.connectUser( User( id: 'cool-shadow-7', - extraData: { + extraData: const { 'image': 'https://getstream.io/random_png/?id=cool-shadow-7&name=Cool+shadow', }, ), - 'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VyX2lkIjoiY29vbC1zaGFkb3ctNyJ9.gkOlCRb1qgy4joHPaxFwPOdXcGvSPvp6QY0S4mpRkVo', + 'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VyX2lkIjoiY29vbC1zaGFkb3ctNyJ9.' + 'gkOlCRb1qgy4joHPaxFwPOdXcGvSPvp6QY0S4mpRkVo', ); /// Creates a channel using the type `messaging` and `godevs`. @@ -50,55 +51,57 @@ Future main() async { /// Example using Stream's Low Level Dart client. class StreamExample extends StatelessWidget { - /// To initialize this example, an instance of [client] and [channel] is required. + /// To initialize this example, an instance of + /// [client] and [channel] is required. const StreamExample({ - Key key, - @required this.client, - @required this.channel, + Key? key, + required this.client, + required this.channel, }) : super(key: key); - /// Instance of [StreamChatClient] we created earlier. This contains information about - /// our application and connection state. + /// Instance of [StreamChatClient] we created earlier. + /// This contains information about our application and connection state. final StreamChatClient client; /// The channel we'd like to observe and participate. final Channel channel; @override - Widget build(BuildContext context) { - return MaterialApp( - title: 'Stream Chat Dart Example', - home: HomeScreen(channel: channel), - ); - } + Widget build(BuildContext context) => MaterialApp( + title: 'Stream Chat Dart Example', + home: HomeScreen(channel: channel), + ); } /// Main screen of our application. The layout is comprised of an [AppBar] /// containing the channel name and a [MessageView] displaying recent messages. class HomeScreen extends StatelessWidget { /// [HomeScreen] is constructed using the [Channel] we defined earlier. - const HomeScreen({Key key, @required this.channel}) : super(key: key); + const HomeScreen({ + Key? key, + required this.channel, + }) : super(key: key); /// Channel object containing the [Channel.id] we'd like to observe. final Channel channel; @override Widget build(BuildContext context) { - final messages = channel.state.channelStateStream; + final messages = channel.state!.channelStateStream; return Scaffold( appBar: AppBar( title: Text('Channel: ${channel.id}'), ), body: SafeArea( - child: StreamBuilder( + child: StreamBuilder( stream: messages, builder: ( BuildContext context, - AsyncSnapshot snapshot, + AsyncSnapshot snapshot, ) { if (snapshot.hasData && snapshot.data != null) { return MessageView( - messages: snapshot.data.messages.reversed.toList(), + messages: snapshot.data!.messages.reversed.toList(), channel: channel, ); } else if (snapshot.hasError) { @@ -110,8 +113,8 @@ class HomeScreen extends StatelessWidget { } return const Center( child: SizedBox( - width: 100.0, - height: 100.0, + width: 100, + height: 100, child: CircularProgressIndicator(), ), ); @@ -127,9 +130,9 @@ class HomeScreen extends StatelessWidget { class MessageView extends StatefulWidget { /// Message takes the latest list of messages and the current channel. const MessageView({ - Key key, - @required this.messages, - @required this.channel, + Key? key, + required this.messages, + required this.channel, }) : super(key: key); /// List of messages sent in the given channel. @@ -143,8 +146,8 @@ class MessageView extends StatefulWidget { } class _MessageViewState extends State { - TextEditingController _controller; - ScrollController _scrollController; + late final TextEditingController _controller; + late final ScrollController _scrollController; List get _messages => widget.messages; @@ -172,86 +175,85 @@ class _MessageViewState extends State { } @override - Widget build(BuildContext context) { - return Column( - children: [ - Expanded( - child: ListView.builder( - controller: _scrollController, - itemCount: _messages.length, - reverse: true, - itemBuilder: (BuildContext context, int index) { - final item = _messages[index]; - if (item.user.id == widget.channel.client.uid) { - return Align( - alignment: Alignment.centerRight, - child: Padding( - padding: const EdgeInsets.all(8.0), - child: Text(item.text), - ), - ); - } else { - return Align( - alignment: Alignment.centerLeft, - child: Padding( - padding: const EdgeInsets.all(8.0), - child: Text(item.text), - ), - ); - } - }, + Widget build(BuildContext context) => Column( + children: [ + Expanded( + child: ListView.builder( + controller: _scrollController, + itemCount: _messages.length, + reverse: true, + itemBuilder: (BuildContext context, int index) { + final item = _messages[index]; + if (item.user?.id == widget.channel.client.uid) { + return Align( + alignment: Alignment.centerRight, + child: Padding( + padding: const EdgeInsets.all(8), + child: Text(item.text ?? ''), + ), + ); + } else { + return Align( + alignment: Alignment.centerLeft, + child: Padding( + padding: const EdgeInsets.all(8), + child: Text(item.text ?? ''), + ), + ); + } + }, + ), ), - ), - Padding( - padding: const EdgeInsets.all(8.0), - child: Row( - children: [ - Expanded( - child: TextField( - controller: _controller, - decoration: const InputDecoration( - hintText: 'Enter your message', - ), - ), - ), - Material( - type: MaterialType.circle, - color: Colors.blue, - clipBehavior: Clip.hardEdge, - child: InkWell( - onTap: () async { - // We can send a new message by calling `sendMessage` on - // the current channel. After sending a message, the - // TextField is cleared and the list view is scrolled - // to show the new item. - if (_controller.value.text.isNotEmpty) { - await widget.channel.sendMessage( - Message(text: _controller.value.text), - ); - _controller.clear(); - _updateList(); - } - }, - child: const Padding( - padding: EdgeInsets.all(8.0), - child: Center( - child: Icon( - Icons.send, - color: Colors.white, - ), + Padding( + padding: const EdgeInsets.all(8), + child: Row( + children: [ + Expanded( + child: TextField( + controller: _controller, + decoration: const InputDecoration( + hintText: 'Enter your message', ), ), ), - ) - ], - ), - ) - ], - ); - } + Material( + type: MaterialType.circle, + color: Colors.blue, + clipBehavior: Clip.hardEdge, + child: InkWell( + onTap: () async { + // We can send a new message by calling `sendMessage` on + // the current channel. After sending a message, the + // TextField is cleared and the list view is scrolled + // to show the new item. + if (_controller.value.text.isNotEmpty) { + await widget.channel.sendMessage( + Message(text: _controller.value.text), + ); + _controller.clear(); + _updateList(); + } + }, + child: const Padding( + padding: EdgeInsets.all(8), + child: Center( + child: Icon( + Icons.send, + color: Colors.white, + ), + ), + ), + ), + ) + ], + ), + ) + ], + ); } -/// Helper extension for quickly retrieving the current user id from a [StreamChatClient]. +/// Helper extension for quickly retrieving +/// the current user id from a [StreamChatClient]. extension on StreamChatClient { - String get uid => state.user.id; + String get uid => state.user!.id; } diff --git a/packages/stream_chat_persistence/example/pubspec.yaml b/packages/stream_chat_persistence/example/pubspec.yaml index faa4fcd4..8dd6a719 100644 --- a/packages/stream_chat_persistence/example/pubspec.yaml +++ b/packages/stream_chat_persistence/example/pubspec.yaml @@ -5,18 +5,22 @@ publish_to: 'none' version: 1.0.0+1 environment: - sdk: ">=2.7.0 <3.0.0" + sdk: ">=2.12.0 <3.0.0" dependencies: + cupertino_icons: ^1.0.3 flutter: sdk: flutter - cupertino_icons: ^1.0.0 - stream_chat: ^1.4.0 stream_chat_persistence: path: ../ +dependency_overrides: + stream_chat: + path: ../../stream_chat + dev_dependencies: flutter_test: sdk: flutter + flutter: uses-material-design: true \ No newline at end of file diff --git a/packages/stream_chat_persistence/lib/src/converter/list_converter.dart b/packages/stream_chat_persistence/lib/src/converter/list_converter.dart index cbb8ec05..d813afc3 100644 --- a/packages/stream_chat_persistence/lib/src/converter/list_converter.dart +++ b/packages/stream_chat_persistence/lib/src/converter/list_converter.dart @@ -6,7 +6,7 @@ import 'package:moor/moor.dart'; /// by the sqlite backend. class ListConverter extends TypeConverter, String> { @override - List mapToDart(String fromDb) { + List? mapToDart(String? fromDb) { if (fromDb == null) { return null; } @@ -14,7 +14,7 @@ class ListConverter extends TypeConverter, String> { } @override - String mapToSql(List value) { + String? mapToSql(List? value) { if (value == null) { return null; } diff --git a/packages/stream_chat_persistence/lib/src/converter/map_converter.dart b/packages/stream_chat_persistence/lib/src/converter/map_converter.dart index b11eb5b8..64284a9d 100644 --- a/packages/stream_chat_persistence/lib/src/converter/map_converter.dart +++ b/packages/stream_chat_persistence/lib/src/converter/map_converter.dart @@ -6,7 +6,7 @@ import 'package:moor/moor.dart'; /// by the sqlite backend. class MapConverter extends TypeConverter, String> { @override - Map mapToDart(String fromDb) { + Map? mapToDart(String? fromDb) { if (fromDb == null) { return null; } @@ -14,7 +14,7 @@ class MapConverter extends TypeConverter, String> { } @override - String mapToSql(Map value) { + String? mapToSql(Map? value) { if (value == null) { return null; } diff --git a/packages/stream_chat_persistence/lib/src/converter/message_sending_status_converter.dart b/packages/stream_chat_persistence/lib/src/converter/message_sending_status_converter.dart index 75b006bb..9d6ea11f 100644 --- a/packages/stream_chat_persistence/lib/src/converter/message_sending_status_converter.dart +++ b/packages/stream_chat_persistence/lib/src/converter/message_sending_status_converter.dart @@ -6,7 +6,7 @@ import 'package:stream_chat/stream_chat.dart'; class MessageSendingStatusConverter extends TypeConverter { @override - MessageSendingStatus mapToDart(int fromDb) { + MessageSendingStatus? mapToDart(int? fromDb) { switch (fromDb) { case 0: return MessageSendingStatus.sending; @@ -28,7 +28,7 @@ class MessageSendingStatusConverter } @override - int mapToSql(MessageSendingStatus value) { + int? mapToSql(MessageSendingStatus? value) { switch (value) { case MessageSendingStatus.sending: return 0; diff --git a/packages/stream_chat_persistence/lib/src/dao/channel_dao.dart b/packages/stream_chat_persistence/lib/src/dao/channel_dao.dart index e75cead6..fc488511 100644 --- a/packages/stream_chat_persistence/lib/src/dao/channel_dao.dart +++ b/packages/stream_chat_persistence/lib/src/dao/channel_dao.dart @@ -15,14 +15,14 @@ class ChannelDao extends DatabaseAccessor ChannelDao(MoorChatDatabase db) : super(db); /// Get channel by cid - Future getChannelByCid(String cid) async => + Future getChannelByCid(String cid) async => (select(channels)..where((c) => c.cid.equals(cid))).join([ leftOuterJoin(users, channels.createdById.equalsExp(users.id)), ]).map((rows) { final channel = rows.readTable(channels); - final createdBy = rows.readTable(users); + final createdBy = rows.readTableOrNull(users); return channel.toChannelModel(createdBy: createdBy?.toUser()); - }).getSingle(); + }).getSingleOrNull(); /// Delete all channels by matching cid in [cids] /// @@ -30,7 +30,7 @@ class ChannelDao extends DatabaseAccessor /// 1. Channel Reads /// 2. Channel Members /// 3. Channel Messages -> Messages Reactions - Future deleteChannelByCids(List cids) async => + Future deleteChannelByCids(List cids) async => (delete(channels)..where((tbl) => tbl.cid.isIn(cids))).go(); /// Get the channel cids saved in the storage diff --git a/packages/stream_chat_persistence/lib/src/dao/channel_query_dao.dart b/packages/stream_chat_persistence/lib/src/dao/channel_query_dao.dart index 2010ab89..9100e76d 100644 --- a/packages/stream_chat_persistence/lib/src/dao/channel_query_dao.dart +++ b/packages/stream_chat_persistence/lib/src/dao/channel_query_dao.dart @@ -6,7 +6,6 @@ import 'package:stream_chat_persistence/src/db/moor_chat_database.dart'; import 'package:stream_chat_persistence/src/entity/channel_queries.dart'; import 'package:stream_chat_persistence/src/entity/channels.dart'; import 'package:stream_chat_persistence/src/entity/users.dart'; - import 'package:stream_chat_persistence/src/mapper/mapper.dart'; part 'channel_query_dao.g.dart'; @@ -18,7 +17,7 @@ class ChannelQueryDao extends DatabaseAccessor /// Creates a new channel query dao instance ChannelQueryDao(MoorChatDatabase db) : super(db); - String _computeHash(Map filter) { + String _computeHash(Filter? filter) { if (filter == null) { return 'allchannels'; } @@ -30,7 +29,7 @@ class ChannelQueryDao extends DatabaseAccessor /// If [clearQueryCache] is true before the insert /// the list of matching rows will be deleted Future updateChannelQueries( - Map filter, + Filter? filter, List cids, { bool clearQueryCache = false, }) async => @@ -58,7 +57,7 @@ class ChannelQueryDao extends DatabaseAccessor }); /// - Future> getCachedChannelCids(Map filter) { + Future> getCachedChannelCids(Filter? filter) { final hash = _computeHash(filter); return (select(channelQueries)..where((c) => c.queryHash.equals(hash))) .map((c) => c.channelCid) @@ -67,9 +66,9 @@ class ChannelQueryDao extends DatabaseAccessor /// Get list of channels by filter, sort and paginationParams Future> getChannels({ - Map filter, - List> sort = const [], - PaginationParams paginationParams, + Filter? filter, + List>? sort, + PaginationParams? paginationParams, }) async { assert(() { if (sort != null && sort.any((it) => it.comparator == null)) { @@ -86,7 +85,7 @@ class ChannelQueryDao extends DatabaseAccessor final cachedChannels = await (query.join([ leftOuterJoin(users, channels.createdById.equalsExp(users.id)), ]).map((row) { - final createdByEntity = row.readTable(users); + final createdByEntity = row.readTableOrNull(users); final channelEntity = row.readTable(channels); return channelEntity.toChannelModel(createdBy: createdByEntity?.toUser()); })).get(); @@ -102,7 +101,7 @@ class ChannelQueryDao extends DatabaseAccessor int result; for (final comparator in sort.map((it) => it.comparator)) { try { - result = comparator(a, b); + result = comparator!(a, b); } catch (e) { result = 0; } @@ -115,11 +114,11 @@ class ChannelQueryDao extends DatabaseAccessor cachedChannels.sort(chainedComparator); if (paginationParams?.offset != null && cachedChannels.isNotEmpty) { - cachedChannels.removeRange(0, paginationParams.offset); + cachedChannels.removeRange(0, paginationParams!.offset); } if (paginationParams?.limit != null) { - return cachedChannels.take(paginationParams.limit).toList(); + return cachedChannels.take(paginationParams!.limit).toList(); } return cachedChannels; diff --git a/packages/stream_chat_persistence/lib/src/dao/connection_event_dao.dart b/packages/stream_chat_persistence/lib/src/dao/connection_event_dao.dart index 50528bd2..0cef23b0 100644 --- a/packages/stream_chat_persistence/lib/src/dao/connection_event_dao.dart +++ b/packages/stream_chat_persistence/lib/src/dao/connection_event_dao.dart @@ -15,23 +15,23 @@ class ConnectionEventDao extends DatabaseAccessor ConnectionEventDao(MoorChatDatabase db) : super(db); /// Get the latest stored connection event - Future get connectionEvent => select(connectionEvents) + Future get connectionEvent => select(connectionEvents) .map((eventEntity) => eventEntity.toEvent()) - .getSingle(); + .getSingleOrNull(); /// Get the latest stored lastSyncAt - Future get lastSyncAt => - select(connectionEvents).getSingle().then((r) => r?.lastSyncAt); + Future get lastSyncAt => + select(connectionEvents).getSingleOrNull().then((r) => r?.lastSyncAt); /// Update stored connection event with latest data - Future updateConnectionEvent(Event event) async => - transaction(() async { - final connectionInfo = await select(connectionEvents).getSingle(); - await into(connectionEvents).insert( + Future updateConnectionEvent(Event event) => transaction(() async { + final connectionInfo = await select(connectionEvents).getSingleOrNull(); + return into(connectionEvents).insert( ConnectionEventEntity( id: 1, + type: event.type, lastSyncAt: connectionInfo?.lastSyncAt, - lastEventAt: event.createdAt ?? connectionInfo?.lastEventAt, + lastEventAt: event.createdAt, totalUnreadCount: event.totalUnreadCount ?? connectionInfo?.totalUnreadCount, ownUser: event.me?.toJson() ?? connectionInfo?.ownUser, diff --git a/packages/stream_chat_persistence/lib/src/dao/member_dao.dart b/packages/stream_chat_persistence/lib/src/dao/member_dao.dart index e6c03996..157b0a9a 100644 --- a/packages/stream_chat_persistence/lib/src/dao/member_dao.dart +++ b/packages/stream_chat_persistence/lib/src/dao/member_dao.dart @@ -26,7 +26,7 @@ class MemberDao extends DatabaseAccessor .map((row) { final userEntity = row.readTable(users); final memberEntity = row.readTable(members); - return memberEntity.toMember(user: userEntity?.toUser()); + return memberEntity.toMember(user: userEntity.toUser()); }).get(); /// Updates all the members using the new [memberList] data diff --git a/packages/stream_chat_persistence/lib/src/dao/message_dao.dart b/packages/stream_chat_persistence/lib/src/dao/message_dao.dart index 8d36b758..c2e1cde2 100644 --- a/packages/stream_chat_persistence/lib/src/dao/message_dao.dart +++ b/packages/stream_chat_persistence/lib/src/dao/message_dao.dart @@ -25,7 +25,7 @@ class MessageDao extends DatabaseAccessor /// /// This will automatically delete the following linked records /// 1. Message Reactions - Future deleteMessageByIds(List messageIds) => + Future deleteMessageByIds(List messageIds) => (delete(messages)..where((tbl) => tbl.id.isIn(messageIds))).go(); /// Removes all the messages by matching [Messages.channelCid] in [cids] @@ -36,17 +36,18 @@ class MessageDao extends DatabaseAccessor (delete(messages)..where((tbl) => tbl.channelCid.isIn(cids))).go(); Future _messageFromJoinRow(TypedResult rows) async { - final userEntity = rows.readTable(_users); - final pinnedByEntity = rows.readTable(_pinnedByUsers); + final userEntity = rows.readTableOrNull(_users); + final pinnedByEntity = rows.readTableOrNull(_pinnedByUsers); final msgEntity = rows.readTable(messages); final latestReactions = await _db.reactionDao.getReactions(msgEntity.id); final ownReactions = await _db.reactionDao.getReactionsByUserId( msgEntity.id, _db.userId, ); - Message quotedMessage; - if (msgEntity.quotedMessageId != null) { - quotedMessage = await getMessageById(msgEntity.quotedMessageId); + Message? quotedMessage; + final quotedMessageId = msgEntity.quotedMessageId; + if (quotedMessageId != null) { + quotedMessage = await getMessageById(quotedMessageId); } return msgEntity.toMessage( user: userEntity?.toUser(), @@ -58,7 +59,7 @@ class MessageDao extends DatabaseAccessor } /// Returns a single message by matching the [Messages.id] with [id] - Future getMessageById(String id) async => + Future getMessageById(String id) async => await (select(messages).join([ leftOuterJoin(_users, messages.userId.equalsExp(_users.id)), leftOuterJoin(_pinnedByUsers, @@ -66,7 +67,7 @@ class MessageDao extends DatabaseAccessor ]) ..where(messages.id.equals(id))) .map(_messageFromJoinRow) - .getSingle(); + .getSingleOrNull(); /// Returns all the messages of a particular thread by matching /// [Messages.channelCid] with [cid] @@ -77,7 +78,7 @@ class MessageDao extends DatabaseAccessor messages.pinnedByUserId.equalsExp(_pinnedByUsers.id)), ]) ..where(messages.channelCid.equals(cid)) - ..where(isNotNull(messages.parentId)) + ..where(messages.parentId.isNotNull()) ..orderBy([OrderingTerm.asc(messages.createdAt)])) .map(_messageFromJoinRow) .get()); @@ -86,14 +87,14 @@ class MessageDao extends DatabaseAccessor /// [Messages.parentId] with [parentId] Future> getThreadMessagesByParentId( String parentId, { - PaginationParams options, + PaginationParams? options, }) async { final msgList = await Future.wait(await (select(messages).join([ leftOuterJoin(_users, messages.userId.equalsExp(_users.id)), leftOuterJoin( _pinnedByUsers, messages.pinnedByUserId.equalsExp(_pinnedByUsers.id)), ]) - ..where(isNotNull(messages.parentId)) + ..where(messages.parentId.isNotNull()) ..where(messages.parentId.equals(parentId)) ..orderBy([OrderingTerm.asc(messages.createdAt)])) .map(_messageFromJoinRow) @@ -102,7 +103,7 @@ class MessageDao extends DatabaseAccessor if (msgList.isNotEmpty) { if (options?.lessThan != null) { final lessThanIndex = msgList.indexWhere( - (m) => m.id == options.lessThan, + (m) => m.id == options!.lessThan, ); if (lessThanIndex != -1) { msgList.removeRange(lessThanIndex, msgList.length); @@ -110,14 +111,14 @@ class MessageDao extends DatabaseAccessor } if (options?.greaterThanOrEqual != null) { final greaterThanIndex = msgList.indexWhere( - (m) => m.id == options.greaterThanOrEqual, + (m) => m.id == options!.greaterThanOrEqual, ); if (greaterThanIndex != -1) { msgList.removeRange(0, greaterThanIndex); } } if (options?.limit != null) { - return msgList.take(options.limit).toList(); + return msgList.take(options!.limit).toList(); } } return msgList; @@ -127,7 +128,7 @@ class MessageDao extends DatabaseAccessor /// [Messages.channelCid] with [parentId] Future> getMessagesByCid( String cid, { - PaginationParams messagePagination, + PaginationParams? messagePagination, }) async { final msgList = await Future.wait(await (select(messages).join([ leftOuterJoin(_users, messages.userId.equalsExp(_users.id)), @@ -136,7 +137,8 @@ class MessageDao extends DatabaseAccessor ]) ..where(messages.channelCid.equals(cid)) ..where( - isNull(messages.parentId) | messages.showInChannel.equals(true)) + messages.parentId.isNull() | messages.showInChannel.equals(true), + ) ..orderBy([OrderingTerm.asc(messages.createdAt)])) .map(_messageFromJoinRow) .get()); @@ -144,7 +146,7 @@ class MessageDao extends DatabaseAccessor if (msgList.isNotEmpty) { if (messagePagination?.lessThan != null) { final lessThanIndex = msgList.indexWhere( - (m) => m.id == messagePagination.lessThan, + (m) => m.id == messagePagination!.lessThan, ); if (lessThanIndex != -1) { msgList.removeRange(lessThanIndex, msgList.length); @@ -152,14 +154,14 @@ class MessageDao extends DatabaseAccessor } if (messagePagination?.greaterThanOrEqual != null) { final greaterThanIndex = msgList.indexWhere( - (m) => m.id == messagePagination.greaterThanOrEqual, + (m) => m.id == messagePagination!.greaterThanOrEqual, ); if (greaterThanIndex != -1) { msgList.removeRange(0, greaterThanIndex); } } if (messagePagination?.limit != null) { - return msgList.take(messagePagination.limit).toList(); + return msgList.take(messagePagination!.limit).toList(); } } return msgList; @@ -167,17 +169,13 @@ class MessageDao extends DatabaseAccessor /// Updates the message data of a particular channel with /// the new [messageList] data - Future updateMessages(String cid, List messageList) async { - if (messageList == null) { - return; - } - - return batch((batch) { - batch.insertAll( - messages, - messageList.map((it) => it.toEntity(cid: cid)).toList(), - mode: InsertMode.insertOrReplace, + Future updateMessages(String cid, List messageList) => batch( + (batch) { + batch.insertAll( + messages, + messageList.map((it) => it.toEntity(cid: cid)).toList(), + mode: InsertMode.insertOrReplace, + ); + }, ); - }); - } } diff --git a/packages/stream_chat_persistence/lib/src/dao/pinned_message_dao.dart b/packages/stream_chat_persistence/lib/src/dao/pinned_message_dao.dart index 34ee4c28..af6497f9 100644 --- a/packages/stream_chat_persistence/lib/src/dao/pinned_message_dao.dart +++ b/packages/stream_chat_persistence/lib/src/dao/pinned_message_dao.dart @@ -36,17 +36,18 @@ class PinnedMessageDao extends DatabaseAccessor (delete(pinnedMessages)..where((tbl) => tbl.channelCid.isIn(cids))).go(); Future _messageFromJoinRow(TypedResult rows) async { - final userEntity = rows.readTable(users); - final pinnedByEntity = rows.readTable(_pinnedByUsers); + final userEntity = rows.readTableOrNull(users); + final pinnedByEntity = rows.readTableOrNull(_pinnedByUsers); final msgEntity = rows.readTable(pinnedMessages); final latestReactions = await _db.reactionDao.getReactions(msgEntity.id); final ownReactions = await _db.reactionDao.getReactionsByUserId( msgEntity.id, _db.userId, ); - Message quotedMessage; - if (msgEntity.quotedMessageId != null) { - quotedMessage = await getMessageById(msgEntity.quotedMessageId); + Message? quotedMessage; + final quotedMessageId = msgEntity.quotedMessageId; + if (quotedMessageId != null) { + quotedMessage = await getMessageById(quotedMessageId); } return msgEntity.toMessage( user: userEntity?.toUser(), @@ -58,7 +59,7 @@ class PinnedMessageDao extends DatabaseAccessor } /// Returns a single message by matching the [PinnedMessages.id] with [id] - Future getMessageById(String id) async => + Future getMessageById(String id) async => await (select(pinnedMessages).join([ leftOuterJoin(_users, pinnedMessages.userId.equalsExp(_users.id)), leftOuterJoin(_pinnedByUsers, @@ -66,7 +67,7 @@ class PinnedMessageDao extends DatabaseAccessor ]) ..where(pinnedMessages.id.equals(id))) .map(_messageFromJoinRow) - .getSingle(); + .getSingleOrNull(); /// Returns all the messages of a particular thread by matching /// [PinnedMessages.channelCid] with [cid] @@ -77,7 +78,7 @@ class PinnedMessageDao extends DatabaseAccessor pinnedMessages.pinnedByUserId.equalsExp(_pinnedByUsers.id)), ]) ..where(pinnedMessages.channelCid.equals(cid)) - ..where(isNotNull(pinnedMessages.parentId)) + ..where(pinnedMessages.parentId.isNotNull()) ..orderBy([OrderingTerm.asc(pinnedMessages.createdAt)])) .map(_messageFromJoinRow) .get()); @@ -86,14 +87,14 @@ class PinnedMessageDao extends DatabaseAccessor /// [PinnedMessages.parentId] with [parentId] Future> getThreadMessagesByParentId( String parentId, { - PaginationParams options, + PaginationParams? options, }) async { final msgList = await Future.wait(await (select(pinnedMessages).join([ leftOuterJoin(_users, pinnedMessages.userId.equalsExp(_users.id)), leftOuterJoin(_pinnedByUsers, pinnedMessages.pinnedByUserId.equalsExp(_pinnedByUsers.id)), ]) - ..where(isNotNull(pinnedMessages.parentId)) + ..where(pinnedMessages.parentId.isNotNull()) ..where(pinnedMessages.parentId.equals(parentId)) ..orderBy([OrderingTerm.asc(pinnedMessages.createdAt)])) .map(_messageFromJoinRow) @@ -102,7 +103,7 @@ class PinnedMessageDao extends DatabaseAccessor if (msgList.isNotEmpty) { if (options?.lessThan != null) { final lessThanIndex = msgList.indexWhere( - (m) => m.id == options.lessThan, + (m) => m.id == options!.lessThan, ); if (lessThanIndex != -1) { msgList.removeRange(lessThanIndex, msgList.length); @@ -110,14 +111,14 @@ class PinnedMessageDao extends DatabaseAccessor } if (options?.greaterThanOrEqual != null) { final greaterThanIndex = msgList.indexWhere( - (m) => m.id == options.greaterThanOrEqual, + (m) => m.id == options!.greaterThanOrEqual, ); if (greaterThanIndex != -1) { msgList.removeRange(0, greaterThanIndex); } } if (options?.limit != null) { - return msgList.take(options.limit).toList(); + return msgList.take(options!.limit).toList(); } } return msgList; @@ -127,7 +128,7 @@ class PinnedMessageDao extends DatabaseAccessor /// [PinnedMessages.channelCid] with [parentId] Future> getMessagesByCid( String cid, { - PaginationParams messagePagination, + PaginationParams? messagePagination, }) async { final msgList = await Future.wait(await (select(pinnedMessages).join([ leftOuterJoin(_users, pinnedMessages.userId.equalsExp(_users.id)), @@ -135,7 +136,7 @@ class PinnedMessageDao extends DatabaseAccessor pinnedMessages.pinnedByUserId.equalsExp(_pinnedByUsers.id)), ]) ..where(pinnedMessages.channelCid.equals(cid)) - ..where(isNull(pinnedMessages.parentId) | + ..where(pinnedMessages.parentId.isNull() | pinnedMessages.showInChannel.equals(true)) ..orderBy([OrderingTerm.asc(pinnedMessages.createdAt)])) .map(_messageFromJoinRow) @@ -144,7 +145,7 @@ class PinnedMessageDao extends DatabaseAccessor if (msgList.isNotEmpty) { if (messagePagination?.lessThan != null) { final lessThanIndex = msgList.indexWhere( - (m) => m.id == messagePagination.lessThan, + (m) => m.id == messagePagination!.lessThan, ); if (lessThanIndex != -1) { msgList.removeRange(lessThanIndex, msgList.length); @@ -152,14 +153,14 @@ class PinnedMessageDao extends DatabaseAccessor } if (messagePagination?.greaterThanOrEqual != null) { final greaterThanIndex = msgList.indexWhere( - (m) => m.id == messagePagination.greaterThanOrEqual, + (m) => m.id == messagePagination!.greaterThanOrEqual, ); if (greaterThanIndex != -1) { msgList.removeRange(0, greaterThanIndex); } } if (messagePagination?.limit != null) { - return msgList.take(messagePagination.limit).toList(); + return msgList.take(messagePagination!.limit).toList(); } } return msgList; @@ -167,17 +168,13 @@ class PinnedMessageDao extends DatabaseAccessor /// Updates the message data of a particular channel with /// the new [messageList] data - Future updateMessages(String cid, List messageList) async { - if (messageList == null) { - return; - } - - return batch((batch) { - batch.insertAll( - pinnedMessages, - messageList.map((it) => it.toPinnedEntity(cid: cid)).toList(), - mode: InsertMode.insertOrReplace, + Future updateMessages(String cid, List messageList) => batch( + (batch) { + batch.insertAll( + pinnedMessages, + messageList.map((it) => it.toPinnedEntity(cid: cid)).toList(), + mode: InsertMode.insertOrReplace, + ); + }, ); - }); - } } diff --git a/packages/stream_chat_persistence/lib/src/dao/reaction_dao.dart b/packages/stream_chat_persistence/lib/src/dao/reaction_dao.dart index ef06326a..89da6081 100644 --- a/packages/stream_chat_persistence/lib/src/dao/reaction_dao.dart +++ b/packages/stream_chat_persistence/lib/src/dao/reaction_dao.dart @@ -23,7 +23,7 @@ class ReactionDao extends DatabaseAccessor ..where(reactions.messageId.equals(messageId)) ..orderBy([OrderingTerm.asc(reactions.createdAt)])) .map((rows) { - final userEntity = rows.readTable(users); + final userEntity = rows.readTableOrNull(users); final reactionEntity = rows.readTable(reactions); return reactionEntity.toReaction(user: userEntity?.toUser()); }).get(); diff --git a/packages/stream_chat_persistence/lib/src/dao/read_dao.dart b/packages/stream_chat_persistence/lib/src/dao/read_dao.dart index 95e23d8b..05d807fd 100644 --- a/packages/stream_chat_persistence/lib/src/dao/read_dao.dart +++ b/packages/stream_chat_persistence/lib/src/dao/read_dao.dart @@ -24,7 +24,7 @@ class ReadDao extends DatabaseAccessor with _$ReadDaoMixin { .map((row) { final userEntity = row.readTable(users); final readEntity = row.readTable(reads); - return readEntity.toRead(user: userEntity?.toUser()); + return readEntity.toRead(user: userEntity.toUser()); }).get(); /// Updates the read data of a particular channel with diff --git a/packages/stream_chat_persistence/lib/src/db/moor_chat_database.dart b/packages/stream_chat_persistence/lib/src/db/moor_chat_database.dart index 73c7b0fa..447533e0 100644 --- a/packages/stream_chat_persistence/lib/src/db/moor_chat_database.dart +++ b/packages/stream_chat_persistence/lib/src/db/moor_chat_database.dart @@ -1,5 +1,3 @@ -import 'package:meta/meta.dart'; -import 'package:moor/ffi.dart'; import 'package:moor/moor.dart'; import 'package:stream_chat/stream_chat.dart'; @@ -46,10 +44,6 @@ class MoorChatDatabase extends _$MoorChatDatabase { DatabaseConnection connection, ) : super.connect(connection); - /// Custom constructor used only for testing - @visibleForTesting - MoorChatDatabase.testable(this._userId) : super(VmDatabase.memory()); - final String _userId; /// User id to which the database is connected @@ -57,7 +51,7 @@ class MoorChatDatabase extends _$MoorChatDatabase { // you should bump this number whenever you change or add a table definition. @override - int get schemaVersion => 2; + int get schemaVersion => 4; @override MigrationStrategy get migration => MigrationStrategy( @@ -72,6 +66,13 @@ class MoorChatDatabase extends _$MoorChatDatabase { }, ); + /// Deletes all the tables + Future flush() => batch((batch) { + allTables.forEach((table) { + delete(table).go(); + }); + }); + /// Closes the database instance Future disconnect() => close(); } diff --git a/packages/stream_chat_persistence/lib/src/db/moor_chat_database.g.dart b/packages/stream_chat_persistence/lib/src/db/moor_chat_database.g.dart index b83b16da..6a5bc38a 100644 --- a/packages/stream_chat_persistence/lib/src/db/moor_chat_database.g.dart +++ b/packages/stream_chat_persistence/lib/src/db/moor_chat_database.g.dart @@ -8,157 +8,165 @@ part of 'moor_chat_database.dart'; // ignore_for_file: unnecessary_brace_in_string_interps, unnecessary_this class ChannelEntity extends DataClass implements Insertable { + /// The id of this channel final String id; + + /// The type of this channel final String type; + + /// The cid of this channel final String cid; - final Map config; + + /// The channel configuration data + final Map config; + + /// True if this channel entity is frozen final bool frozen; - final DateTime lastMessageAt; + + /// The date of the last message + final DateTime? lastMessageAt; + + /// The date of channel creation final DateTime createdAt; + + /// The date of the last channel update final DateTime updatedAt; - final DateTime deletedAt; + + /// The date of channel deletion + final DateTime? deletedAt; + + /// The count of this channel members final int memberCount; - final String createdById; - final Map extraData; + + /// The id of the user that created this channel + final String? createdById; + + /// Map of custom channel extraData + final Map? extraData; ChannelEntity( - {@required this.id, - @required this.type, - @required this.cid, - @required this.config, - @required this.frozen, + {required this.id, + required this.type, + required this.cid, + required this.config, + required this.frozen, this.lastMessageAt, - this.createdAt, - this.updatedAt, + required this.createdAt, + required this.updatedAt, this.deletedAt, - this.memberCount, + required this.memberCount, this.createdById, this.extraData}); factory ChannelEntity.fromData( Map data, GeneratedDatabase db, - {String prefix}) { + {String? prefix}) { final effectivePrefix = prefix ?? ''; - final stringType = db.typeSystem.forDartType(); - final boolType = db.typeSystem.forDartType(); - final dateTimeType = db.typeSystem.forDartType(); - final intType = db.typeSystem.forDartType(); return ChannelEntity( - id: stringType.mapFromDatabaseResponse(data['${effectivePrefix}id']), - type: stringType.mapFromDatabaseResponse(data['${effectivePrefix}type']), - cid: stringType.mapFromDatabaseResponse(data['${effectivePrefix}cid']), - config: $ChannelsTable.$converter0.mapToDart( - stringType.mapFromDatabaseResponse(data['${effectivePrefix}config'])), - frozen: - boolType.mapFromDatabaseResponse(data['${effectivePrefix}frozen']), - lastMessageAt: dateTimeType + id: const StringType() + .mapFromDatabaseResponse(data['${effectivePrefix}id'])!, + type: const StringType() + .mapFromDatabaseResponse(data['${effectivePrefix}type'])!, + cid: const StringType() + .mapFromDatabaseResponse(data['${effectivePrefix}cid'])!, + config: $ChannelsTable.$converter0.mapToDart(const StringType() + .mapFromDatabaseResponse(data['${effectivePrefix}config']))!, + frozen: const BoolType() + .mapFromDatabaseResponse(data['${effectivePrefix}frozen'])!, + lastMessageAt: const DateTimeType() .mapFromDatabaseResponse(data['${effectivePrefix}last_message_at']), - createdAt: dateTimeType - .mapFromDatabaseResponse(data['${effectivePrefix}created_at']), - updatedAt: dateTimeType - .mapFromDatabaseResponse(data['${effectivePrefix}updated_at']), - deletedAt: dateTimeType + createdAt: const DateTimeType() + .mapFromDatabaseResponse(data['${effectivePrefix}created_at'])!, + updatedAt: const DateTimeType() + .mapFromDatabaseResponse(data['${effectivePrefix}updated_at'])!, + deletedAt: const DateTimeType() .mapFromDatabaseResponse(data['${effectivePrefix}deleted_at']), - memberCount: intType - .mapFromDatabaseResponse(data['${effectivePrefix}member_count']), - createdById: stringType + memberCount: const IntType() + .mapFromDatabaseResponse(data['${effectivePrefix}member_count'])!, + createdById: const StringType() .mapFromDatabaseResponse(data['${effectivePrefix}created_by_id']), - extraData: $ChannelsTable.$converter1.mapToDart(stringType + extraData: $ChannelsTable.$converter1.mapToDart(const StringType() .mapFromDatabaseResponse(data['${effectivePrefix}extra_data'])), ); } @override Map toColumns(bool nullToAbsent) { final map = {}; - if (!nullToAbsent || id != null) { - map['id'] = Variable(id); - } - if (!nullToAbsent || type != null) { - map['type'] = Variable(type); - } - if (!nullToAbsent || cid != null) { - map['cid'] = Variable(cid); - } - if (!nullToAbsent || config != null) { + map['id'] = Variable(id); + map['type'] = Variable(type); + map['cid'] = Variable(cid); + { final converter = $ChannelsTable.$converter0; - map['config'] = Variable(converter.mapToSql(config)); - } - if (!nullToAbsent || frozen != null) { - map['frozen'] = Variable(frozen); + map['config'] = Variable(converter.mapToSql(config)!); } + map['frozen'] = Variable(frozen); if (!nullToAbsent || lastMessageAt != null) { - map['last_message_at'] = Variable(lastMessageAt); - } - if (!nullToAbsent || createdAt != null) { - map['created_at'] = Variable(createdAt); - } - if (!nullToAbsent || updatedAt != null) { - map['updated_at'] = Variable(updatedAt); + map['last_message_at'] = Variable(lastMessageAt); } + map['created_at'] = Variable(createdAt); + map['updated_at'] = Variable(updatedAt); if (!nullToAbsent || deletedAt != null) { - map['deleted_at'] = Variable(deletedAt); - } - if (!nullToAbsent || memberCount != null) { - map['member_count'] = Variable(memberCount); + map['deleted_at'] = Variable(deletedAt); } + map['member_count'] = Variable(memberCount); if (!nullToAbsent || createdById != null) { - map['created_by_id'] = Variable(createdById); + map['created_by_id'] = Variable(createdById); } if (!nullToAbsent || extraData != null) { final converter = $ChannelsTable.$converter1; - map['extra_data'] = Variable(converter.mapToSql(extraData)); + map['extra_data'] = Variable(converter.mapToSql(extraData)); } return map; } factory ChannelEntity.fromJson(Map json, - {ValueSerializer serializer}) { + {ValueSerializer? serializer}) { serializer ??= moorRuntimeOptions.defaultSerializer; return ChannelEntity( id: serializer.fromJson(json['id']), type: serializer.fromJson(json['type']), cid: serializer.fromJson(json['cid']), - config: serializer.fromJson>(json['config']), + config: serializer.fromJson>(json['config']), frozen: serializer.fromJson(json['frozen']), - lastMessageAt: serializer.fromJson(json['lastMessageAt']), + lastMessageAt: serializer.fromJson(json['lastMessageAt']), createdAt: serializer.fromJson(json['createdAt']), updatedAt: serializer.fromJson(json['updatedAt']), - deletedAt: serializer.fromJson(json['deletedAt']), + deletedAt: serializer.fromJson(json['deletedAt']), memberCount: serializer.fromJson(json['memberCount']), - createdById: serializer.fromJson(json['createdById']), - extraData: serializer.fromJson>(json['extraData']), + createdById: serializer.fromJson(json['createdById']), + extraData: serializer.fromJson?>(json['extraData']), ); } @override - Map toJson({ValueSerializer serializer}) { + Map toJson({ValueSerializer? serializer}) { serializer ??= moorRuntimeOptions.defaultSerializer; return { 'id': serializer.toJson(id), 'type': serializer.toJson(type), 'cid': serializer.toJson(cid), - 'config': serializer.toJson>(config), + 'config': serializer.toJson>(config), 'frozen': serializer.toJson(frozen), - 'lastMessageAt': serializer.toJson(lastMessageAt), + 'lastMessageAt': serializer.toJson(lastMessageAt), 'createdAt': serializer.toJson(createdAt), 'updatedAt': serializer.toJson(updatedAt), - 'deletedAt': serializer.toJson(deletedAt), + 'deletedAt': serializer.toJson(deletedAt), 'memberCount': serializer.toJson(memberCount), - 'createdById': serializer.toJson(createdById), - 'extraData': serializer.toJson>(extraData), + 'createdById': serializer.toJson(createdById), + 'extraData': serializer.toJson?>(extraData), }; } ChannelEntity copyWith( - {String id, - String type, - String cid, - Map config, - bool frozen, - Value lastMessageAt = const Value.absent(), - Value createdAt = const Value.absent(), - Value updatedAt = const Value.absent(), - Value deletedAt = const Value.absent(), - Value memberCount = const Value.absent(), - Value createdById = const Value.absent(), - Value> extraData = const Value.absent()}) => + {String? id, + String? type, + String? cid, + Map? config, + bool? frozen, + Value lastMessageAt = const Value.absent(), + DateTime? createdAt, + DateTime? updatedAt, + Value deletedAt = const Value.absent(), + int? memberCount, + Value createdById = const Value.absent(), + Value?> extraData = const Value.absent()}) => ChannelEntity( id: id ?? this.id, type: type ?? this.type, @@ -167,10 +175,10 @@ class ChannelEntity extends DataClass implements Insertable { frozen: frozen ?? this.frozen, lastMessageAt: lastMessageAt.present ? lastMessageAt.value : this.lastMessageAt, - createdAt: createdAt.present ? createdAt.value : this.createdAt, - updatedAt: updatedAt.present ? updatedAt.value : this.updatedAt, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, deletedAt: deletedAt.present ? deletedAt.value : this.deletedAt, - memberCount: memberCount.present ? memberCount.value : this.memberCount, + memberCount: memberCount ?? this.memberCount, createdById: createdById.present ? createdById.value : this.createdById, extraData: extraData.present ? extraData.value : this.extraData, ); @@ -217,7 +225,7 @@ class ChannelEntity extends DataClass implements Insertable { $mrjc(createdById.hashCode, extraData.hashCode)))))))))))); @override - bool operator ==(dynamic other) => + bool operator ==(Object other) => identical(this, other) || (other is ChannelEntity && other.id == this.id && @@ -238,15 +246,15 @@ class ChannelsCompanion extends UpdateCompanion { final Value id; final Value type; final Value cid; - final Value> config; + final Value> config; final Value frozen; - final Value lastMessageAt; + final Value lastMessageAt; final Value createdAt; final Value updatedAt; - final Value deletedAt; + final Value deletedAt; final Value memberCount; - final Value createdById; - final Value> extraData; + final Value createdById; + final Value?> extraData; const ChannelsCompanion({ this.id = const Value.absent(), this.type = const Value.absent(), @@ -262,10 +270,10 @@ class ChannelsCompanion extends UpdateCompanion { this.extraData = const Value.absent(), }); ChannelsCompanion.insert({ - @required String id, - @required String type, - @required String cid, - @required Map config, + required String id, + required String type, + required String cid, + required Map config, this.frozen = const Value.absent(), this.lastMessageAt = const Value.absent(), this.createdAt = const Value.absent(), @@ -279,18 +287,18 @@ class ChannelsCompanion extends UpdateCompanion { cid = Value(cid), config = Value(config); static Insertable custom({ - Expression id, - Expression type, - Expression cid, - Expression config, - Expression frozen, - Expression lastMessageAt, - Expression createdAt, - Expression updatedAt, - Expression deletedAt, - Expression memberCount, - Expression createdById, - Expression extraData, + Expression? id, + Expression? type, + Expression? cid, + Expression>? config, + Expression? frozen, + Expression? lastMessageAt, + Expression? createdAt, + Expression? updatedAt, + Expression? deletedAt, + Expression? memberCount, + Expression? createdById, + Expression?>? extraData, }) { return RawValuesInsertable({ if (id != null) 'id': id, @@ -309,18 +317,18 @@ class ChannelsCompanion extends UpdateCompanion { } ChannelsCompanion copyWith( - {Value id, - Value type, - Value cid, - Value> config, - Value frozen, - Value lastMessageAt, - Value createdAt, - Value updatedAt, - Value deletedAt, - Value memberCount, - Value createdById, - Value> extraData}) { + {Value? id, + Value? type, + Value? cid, + Value>? config, + Value? frozen, + Value? lastMessageAt, + Value? createdAt, + Value? updatedAt, + Value? deletedAt, + Value? memberCount, + Value? createdById, + Value?>? extraData}) { return ChannelsCompanion( id: id ?? this.id, type: type ?? this.type, @@ -351,13 +359,13 @@ class ChannelsCompanion extends UpdateCompanion { } if (config.present) { final converter = $ChannelsTable.$converter0; - map['config'] = Variable(converter.mapToSql(config.value)); + map['config'] = Variable(converter.mapToSql(config.value)!); } if (frozen.present) { map['frozen'] = Variable(frozen.value); } if (lastMessageAt.present) { - map['last_message_at'] = Variable(lastMessageAt.value); + map['last_message_at'] = Variable(lastMessageAt.value); } if (createdAt.present) { map['created_at'] = Variable(createdAt.value); @@ -366,17 +374,18 @@ class ChannelsCompanion extends UpdateCompanion { map['updated_at'] = Variable(updatedAt.value); } if (deletedAt.present) { - map['deleted_at'] = Variable(deletedAt.value); + map['deleted_at'] = Variable(deletedAt.value); } if (memberCount.present) { map['member_count'] = Variable(memberCount.value); } if (createdById.present) { - map['created_by_id'] = Variable(createdById.value); + map['created_by_id'] = Variable(createdById.value); } if (extraData.present) { final converter = $ChannelsTable.$converter1; - map['extra_data'] = Variable(converter.mapToSql(extraData.value)); + map['extra_data'] = + Variable(converter.mapToSql(extraData.value)); } return map; } @@ -404,155 +413,70 @@ class ChannelsCompanion extends UpdateCompanion { class $ChannelsTable extends Channels with TableInfo<$ChannelsTable, ChannelEntity> { final GeneratedDatabase _db; - final String _alias; + final String? _alias; $ChannelsTable(this._db, [this._alias]); final VerificationMeta _idMeta = const VerificationMeta('id'); - GeneratedTextColumn _id; - @override - GeneratedTextColumn get id => _id ??= _constructId(); - GeneratedTextColumn _constructId() { - return GeneratedTextColumn( - 'id', - $tableName, - false, - ); - } - + late final GeneratedColumn id = GeneratedColumn( + 'id', aliasedName, false, + typeName: 'TEXT', requiredDuringInsert: true); final VerificationMeta _typeMeta = const VerificationMeta('type'); - GeneratedTextColumn _type; - @override - GeneratedTextColumn get type => _type ??= _constructType(); - GeneratedTextColumn _constructType() { - return GeneratedTextColumn( - 'type', - $tableName, - false, - ); - } - + late final GeneratedColumn type = GeneratedColumn( + 'type', aliasedName, false, + typeName: 'TEXT', requiredDuringInsert: true); final VerificationMeta _cidMeta = const VerificationMeta('cid'); - GeneratedTextColumn _cid; - @override - GeneratedTextColumn get cid => _cid ??= _constructCid(); - GeneratedTextColumn _constructCid() { - return GeneratedTextColumn( - 'cid', - $tableName, - false, - ); - } - + late final GeneratedColumn cid = GeneratedColumn( + 'cid', aliasedName, false, + typeName: 'TEXT', requiredDuringInsert: true); final VerificationMeta _configMeta = const VerificationMeta('config'); - GeneratedTextColumn _config; - @override - GeneratedTextColumn get config => _config ??= _constructConfig(); - GeneratedTextColumn _constructConfig() { - return GeneratedTextColumn( - 'config', - $tableName, - false, - ); - } - + late final GeneratedColumnWithTypeConverter, String?> + config = GeneratedColumn('config', aliasedName, false, + typeName: 'TEXT', requiredDuringInsert: true) + .withConverter>($ChannelsTable.$converter0); final VerificationMeta _frozenMeta = const VerificationMeta('frozen'); - GeneratedBoolColumn _frozen; - @override - GeneratedBoolColumn get frozen => _frozen ??= _constructFrozen(); - GeneratedBoolColumn _constructFrozen() { - return GeneratedBoolColumn('frozen', $tableName, false, - defaultValue: Constant(false)); - } - + late final GeneratedColumn frozen = GeneratedColumn( + 'frozen', aliasedName, false, + typeName: 'INTEGER', + requiredDuringInsert: false, + defaultConstraints: 'CHECK (frozen IN (0, 1))', + defaultValue: const Constant(false)); final VerificationMeta _lastMessageAtMeta = const VerificationMeta('lastMessageAt'); - GeneratedDateTimeColumn _lastMessageAt; - @override - GeneratedDateTimeColumn get lastMessageAt => - _lastMessageAt ??= _constructLastMessageAt(); - GeneratedDateTimeColumn _constructLastMessageAt() { - return GeneratedDateTimeColumn( - 'last_message_at', - $tableName, - true, - ); - } - + late final GeneratedColumn lastMessageAt = + GeneratedColumn('last_message_at', aliasedName, true, + typeName: 'INTEGER', requiredDuringInsert: false); final VerificationMeta _createdAtMeta = const VerificationMeta('createdAt'); - GeneratedDateTimeColumn _createdAt; - @override - GeneratedDateTimeColumn get createdAt => _createdAt ??= _constructCreatedAt(); - GeneratedDateTimeColumn _constructCreatedAt() { - return GeneratedDateTimeColumn( - 'created_at', - $tableName, - true, - ); - } - + late final GeneratedColumn createdAt = GeneratedColumn( + 'created_at', aliasedName, false, + typeName: 'INTEGER', + requiredDuringInsert: false, + defaultValue: currentDateAndTime); final VerificationMeta _updatedAtMeta = const VerificationMeta('updatedAt'); - GeneratedDateTimeColumn _updatedAt; - @override - GeneratedDateTimeColumn get updatedAt => _updatedAt ??= _constructUpdatedAt(); - GeneratedDateTimeColumn _constructUpdatedAt() { - return GeneratedDateTimeColumn( - 'updated_at', - $tableName, - true, - ); - } - + late final GeneratedColumn updatedAt = GeneratedColumn( + 'updated_at', aliasedName, false, + typeName: 'INTEGER', + requiredDuringInsert: false, + defaultValue: currentDateAndTime); final VerificationMeta _deletedAtMeta = const VerificationMeta('deletedAt'); - GeneratedDateTimeColumn _deletedAt; - @override - GeneratedDateTimeColumn get deletedAt => _deletedAt ??= _constructDeletedAt(); - GeneratedDateTimeColumn _constructDeletedAt() { - return GeneratedDateTimeColumn( - 'deleted_at', - $tableName, - true, - ); - } - + late final GeneratedColumn deletedAt = GeneratedColumn( + 'deleted_at', aliasedName, true, + typeName: 'INTEGER', requiredDuringInsert: false); final VerificationMeta _memberCountMeta = const VerificationMeta('memberCount'); - GeneratedIntColumn _memberCount; - @override - GeneratedIntColumn get memberCount => - _memberCount ??= _constructMemberCount(); - GeneratedIntColumn _constructMemberCount() { - return GeneratedIntColumn( - 'member_count', - $tableName, - true, - ); - } - + late final GeneratedColumn memberCount = GeneratedColumn( + 'member_count', aliasedName, false, + typeName: 'INTEGER', + requiredDuringInsert: false, + defaultValue: const Constant(0)); final VerificationMeta _createdByIdMeta = const VerificationMeta('createdById'); - GeneratedTextColumn _createdById; - @override - GeneratedTextColumn get createdById => - _createdById ??= _constructCreatedById(); - GeneratedTextColumn _constructCreatedById() { - return GeneratedTextColumn( - 'created_by_id', - $tableName, - true, - ); - } - + late final GeneratedColumn createdById = GeneratedColumn( + 'created_by_id', aliasedName, true, + typeName: 'TEXT', requiredDuringInsert: false); final VerificationMeta _extraDataMeta = const VerificationMeta('extraData'); - GeneratedTextColumn _extraData; - @override - GeneratedTextColumn get extraData => _extraData ??= _constructExtraData(); - GeneratedTextColumn _constructExtraData() { - return GeneratedTextColumn( - 'extra_data', - $tableName, - true, - ); - } - + late final GeneratedColumnWithTypeConverter, String?> + extraData = GeneratedColumn('extra_data', aliasedName, true, + typeName: 'TEXT', requiredDuringInsert: false) + .withConverter>($ChannelsTable.$converter1); @override List get $columns => [ id, @@ -569,67 +493,65 @@ class $ChannelsTable extends Channels extraData ]; @override - $ChannelsTable get asDslTable => this; + String get aliasedName => _alias ?? 'channels'; @override - String get $tableName => _alias ?? 'channels'; - @override - final String actualTableName = 'channels'; + String get actualTableName => 'channels'; @override VerificationContext validateIntegrity(Insertable instance, {bool isInserting = false}) { final context = VerificationContext(); final data = instance.toColumns(true); if (data.containsKey('id')) { - context.handle(_idMeta, id.isAcceptableOrUnknown(data['id'], _idMeta)); + context.handle(_idMeta, id.isAcceptableOrUnknown(data['id']!, _idMeta)); } else if (isInserting) { context.missing(_idMeta); } if (data.containsKey('type')) { context.handle( - _typeMeta, type.isAcceptableOrUnknown(data['type'], _typeMeta)); + _typeMeta, type.isAcceptableOrUnknown(data['type']!, _typeMeta)); } else if (isInserting) { context.missing(_typeMeta); } if (data.containsKey('cid')) { context.handle( - _cidMeta, cid.isAcceptableOrUnknown(data['cid'], _cidMeta)); + _cidMeta, cid.isAcceptableOrUnknown(data['cid']!, _cidMeta)); } else if (isInserting) { context.missing(_cidMeta); } context.handle(_configMeta, const VerificationResult.success()); if (data.containsKey('frozen')) { context.handle(_frozenMeta, - frozen.isAcceptableOrUnknown(data['frozen'], _frozenMeta)); + frozen.isAcceptableOrUnknown(data['frozen']!, _frozenMeta)); } if (data.containsKey('last_message_at')) { context.handle( _lastMessageAtMeta, lastMessageAt.isAcceptableOrUnknown( - data['last_message_at'], _lastMessageAtMeta)); + data['last_message_at']!, _lastMessageAtMeta)); } if (data.containsKey('created_at')) { context.handle(_createdAtMeta, - createdAt.isAcceptableOrUnknown(data['created_at'], _createdAtMeta)); + createdAt.isAcceptableOrUnknown(data['created_at']!, _createdAtMeta)); } if (data.containsKey('updated_at')) { context.handle(_updatedAtMeta, - updatedAt.isAcceptableOrUnknown(data['updated_at'], _updatedAtMeta)); + updatedAt.isAcceptableOrUnknown(data['updated_at']!, _updatedAtMeta)); } if (data.containsKey('deleted_at')) { context.handle(_deletedAtMeta, - deletedAt.isAcceptableOrUnknown(data['deleted_at'], _deletedAtMeta)); + deletedAt.isAcceptableOrUnknown(data['deleted_at']!, _deletedAtMeta)); } if (data.containsKey('member_count')) { context.handle( _memberCountMeta, memberCount.isAcceptableOrUnknown( - data['member_count'], _memberCountMeta)); + data['member_count']!, _memberCountMeta)); } if (data.containsKey('created_by_id')) { context.handle( _createdByIdMeta, createdById.isAcceptableOrUnknown( - data['created_by_id'], _createdByIdMeta)); + data['created_by_id']!, _createdByIdMeta)); } context.handle(_extraDataMeta, const VerificationResult.success()); return context; @@ -638,9 +560,9 @@ class $ChannelsTable extends Channels @override Set get $primaryKey => {cid}; @override - ChannelEntity map(Map data, {String tablePrefix}) { - final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : null; - return ChannelEntity.fromData(data, _db, prefix: effectivePrefix); + ChannelEntity map(Map data, {String? tablePrefix}) { + return ChannelEntity.fromData(data, _db, + prefix: tablePrefix != null ? '$tablePrefix.' : null); } @override @@ -648,57 +570,105 @@ class $ChannelsTable extends Channels return $ChannelsTable(_db, alias); } - static TypeConverter, String> $converter0 = - MapConverter(); - static TypeConverter, String> $converter1 = - MapConverter(); + static TypeConverter, String> $converter0 = + MapConverter(); + static TypeConverter, String> $converter1 = + MapConverter(); } class MessageEntity extends DataClass implements Insertable { + /// The message id final String id; - final String messageText; + + /// The text of this message + final String? messageText; + + /// The list of attachments, either provided by the user + /// or generated from a command or as a result of URL scraping. final List attachments; + + /// The status of a sending message final MessageSendingStatus status; + + /// The message type final String type; + + /// The list of user mentioned in the message final List mentionedUsers; - final Map reactionCounts; - final Map reactionScores; - final String parentId; - final String quotedMessageId; - final int replyCount; - final bool showInChannel; + + /// A map describing the count of number of every reaction + final Map? reactionCounts; + + /// A map describing the count of score of every reaction + final Map? reactionScores; + + /// The ID of the parent message, if the message is a thread reply. + final String? parentId; + + /// The ID of the quoted message, if the message is a quoted reply. + final String? quotedMessageId; + + /// Number of replies for this message. + final int? replyCount; + + /// Check if this message needs to show in the channel. + final bool? showInChannel; + + /// If true the message is shadowed final bool shadowed; - final String command; + + /// A used command name. + final String? command; + + /// The DateTime when the message was created. final DateTime createdAt; + + /// The DateTime when the message was updated last time. final DateTime updatedAt; - final DateTime deletedAt; - final String userId; + + /// The DateTime when the message was deleted. + final DateTime? deletedAt; + + /// Id of the User who sent the message + final String? userId; + + /// Whether the message is pinned or not final bool pinned; - final DateTime pinnedAt; - final DateTime pinExpires; - final String pinnedByUserId; - final String channelCid; - final Map extraData; + + /// The DateTime at which the message was pinned + final DateTime? pinnedAt; + + /// The DateTime on which the message pin expires + final DateTime? pinExpires; + + /// Id of the User who pinned the message + final String? pinnedByUserId; + + /// The channel cid of which this message is part of + final String? channelCid; + + /// Message custom extraData + final Map? extraData; MessageEntity( - {@required this.id, + {required this.id, this.messageText, - this.attachments, - this.status, - this.type, - this.mentionedUsers, + required this.attachments, + required this.status, + required this.type, + required this.mentionedUsers, this.reactionCounts, this.reactionScores, this.parentId, this.quotedMessageId, this.replyCount, this.showInChannel, - this.shadowed, + required this.shadowed, this.command, - @required this.createdAt, - this.updatedAt, + required this.createdAt, + required this.updatedAt, this.deletedAt, this.userId, - @required this.pinned, + required this.pinned, this.pinnedAt, this.pinExpires, this.pinnedByUserId, @@ -706,244 +676,229 @@ class MessageEntity extends DataClass implements Insertable { this.extraData}); factory MessageEntity.fromData( Map data, GeneratedDatabase db, - {String prefix}) { + {String? prefix}) { final effectivePrefix = prefix ?? ''; - final stringType = db.typeSystem.forDartType(); - final intType = db.typeSystem.forDartType(); - final boolType = db.typeSystem.forDartType(); - final dateTimeType = db.typeSystem.forDartType(); return MessageEntity( - id: stringType.mapFromDatabaseResponse(data['${effectivePrefix}id']), - messageText: stringType + id: const StringType() + .mapFromDatabaseResponse(data['${effectivePrefix}id'])!, + messageText: const StringType() .mapFromDatabaseResponse(data['${effectivePrefix}message_text']), - attachments: $MessagesTable.$converter0.mapToDart(stringType - .mapFromDatabaseResponse(data['${effectivePrefix}attachments'])), - status: $MessagesTable.$converter1.mapToDart( - intType.mapFromDatabaseResponse(data['${effectivePrefix}status'])), - type: stringType.mapFromDatabaseResponse(data['${effectivePrefix}type']), - mentionedUsers: $MessagesTable.$converter2.mapToDart(stringType - .mapFromDatabaseResponse(data['${effectivePrefix}mentioned_users'])), - reactionCounts: $MessagesTable.$converter3.mapToDart(stringType + attachments: $MessagesTable.$converter0.mapToDart(const StringType() + .mapFromDatabaseResponse(data['${effectivePrefix}attachments']))!, + status: $MessagesTable.$converter1.mapToDart(const IntType() + .mapFromDatabaseResponse(data['${effectivePrefix}status']))!, + type: const StringType() + .mapFromDatabaseResponse(data['${effectivePrefix}type'])!, + mentionedUsers: $MessagesTable.$converter2.mapToDart(const StringType() + .mapFromDatabaseResponse(data['${effectivePrefix}mentioned_users']))!, + reactionCounts: $MessagesTable.$converter3.mapToDart(const StringType() .mapFromDatabaseResponse(data['${effectivePrefix}reaction_counts'])), - reactionScores: $MessagesTable.$converter4.mapToDart(stringType + reactionScores: $MessagesTable.$converter4.mapToDart(const StringType() .mapFromDatabaseResponse(data['${effectivePrefix}reaction_scores'])), - parentId: stringType + parentId: const StringType() .mapFromDatabaseResponse(data['${effectivePrefix}parent_id']), - quotedMessageId: stringType + quotedMessageId: const StringType() .mapFromDatabaseResponse(data['${effectivePrefix}quoted_message_id']), - replyCount: intType + replyCount: const IntType() .mapFromDatabaseResponse(data['${effectivePrefix}reply_count']), - showInChannel: boolType + showInChannel: const BoolType() .mapFromDatabaseResponse(data['${effectivePrefix}show_in_channel']), - shadowed: - boolType.mapFromDatabaseResponse(data['${effectivePrefix}shadowed']), - command: - stringType.mapFromDatabaseResponse(data['${effectivePrefix}command']), - createdAt: dateTimeType - .mapFromDatabaseResponse(data['${effectivePrefix}created_at']), - updatedAt: dateTimeType - .mapFromDatabaseResponse(data['${effectivePrefix}updated_at']), - deletedAt: dateTimeType + shadowed: const BoolType() + .mapFromDatabaseResponse(data['${effectivePrefix}shadowed'])!, + command: const StringType() + .mapFromDatabaseResponse(data['${effectivePrefix}command']), + createdAt: const DateTimeType() + .mapFromDatabaseResponse(data['${effectivePrefix}created_at'])!, + updatedAt: const DateTimeType() + .mapFromDatabaseResponse(data['${effectivePrefix}updated_at'])!, + deletedAt: const DateTimeType() .mapFromDatabaseResponse(data['${effectivePrefix}deleted_at']), - userId: - stringType.mapFromDatabaseResponse(data['${effectivePrefix}user_id']), - pinned: - boolType.mapFromDatabaseResponse(data['${effectivePrefix}pinned']), - pinnedAt: dateTimeType + userId: const StringType() + .mapFromDatabaseResponse(data['${effectivePrefix}user_id']), + pinned: const BoolType() + .mapFromDatabaseResponse(data['${effectivePrefix}pinned'])!, + pinnedAt: const DateTimeType() .mapFromDatabaseResponse(data['${effectivePrefix}pinned_at']), - pinExpires: dateTimeType + pinExpires: const DateTimeType() .mapFromDatabaseResponse(data['${effectivePrefix}pin_expires']), - pinnedByUserId: stringType + pinnedByUserId: const StringType() .mapFromDatabaseResponse(data['${effectivePrefix}pinned_by_user_id']), - channelCid: stringType + channelCid: const StringType() .mapFromDatabaseResponse(data['${effectivePrefix}channel_cid']), - extraData: $MessagesTable.$converter5.mapToDart(stringType + extraData: $MessagesTable.$converter5.mapToDart(const StringType() .mapFromDatabaseResponse(data['${effectivePrefix}extra_data'])), ); } @override Map toColumns(bool nullToAbsent) { final map = {}; - if (!nullToAbsent || id != null) { - map['id'] = Variable(id); - } + map['id'] = Variable(id); if (!nullToAbsent || messageText != null) { - map['message_text'] = Variable(messageText); + map['message_text'] = Variable(messageText); } - if (!nullToAbsent || attachments != null) { + { final converter = $MessagesTable.$converter0; - map['attachments'] = Variable(converter.mapToSql(attachments)); + map['attachments'] = Variable(converter.mapToSql(attachments)!); } - if (!nullToAbsent || status != null) { + { final converter = $MessagesTable.$converter1; - map['status'] = Variable(converter.mapToSql(status)); + map['status'] = Variable(converter.mapToSql(status)!); } - if (!nullToAbsent || type != null) { - map['type'] = Variable(type); - } - if (!nullToAbsent || mentionedUsers != null) { + map['type'] = Variable(type); + { final converter = $MessagesTable.$converter2; map['mentioned_users'] = - Variable(converter.mapToSql(mentionedUsers)); + Variable(converter.mapToSql(mentionedUsers)!); } if (!nullToAbsent || reactionCounts != null) { final converter = $MessagesTable.$converter3; map['reaction_counts'] = - Variable(converter.mapToSql(reactionCounts)); + Variable(converter.mapToSql(reactionCounts)); } if (!nullToAbsent || reactionScores != null) { final converter = $MessagesTable.$converter4; map['reaction_scores'] = - Variable(converter.mapToSql(reactionScores)); + Variable(converter.mapToSql(reactionScores)); } if (!nullToAbsent || parentId != null) { - map['parent_id'] = Variable(parentId); + map['parent_id'] = Variable(parentId); } if (!nullToAbsent || quotedMessageId != null) { - map['quoted_message_id'] = Variable(quotedMessageId); + map['quoted_message_id'] = Variable(quotedMessageId); } if (!nullToAbsent || replyCount != null) { - map['reply_count'] = Variable(replyCount); + map['reply_count'] = Variable(replyCount); } if (!nullToAbsent || showInChannel != null) { - map['show_in_channel'] = Variable(showInChannel); - } - if (!nullToAbsent || shadowed != null) { - map['shadowed'] = Variable(shadowed); + map['show_in_channel'] = Variable(showInChannel); } + map['shadowed'] = Variable(shadowed); if (!nullToAbsent || command != null) { - map['command'] = Variable(command); - } - if (!nullToAbsent || createdAt != null) { - map['created_at'] = Variable(createdAt); - } - if (!nullToAbsent || updatedAt != null) { - map['updated_at'] = Variable(updatedAt); + map['command'] = Variable(command); } + map['created_at'] = Variable(createdAt); + map['updated_at'] = Variable(updatedAt); if (!nullToAbsent || deletedAt != null) { - map['deleted_at'] = Variable(deletedAt); + map['deleted_at'] = Variable(deletedAt); } if (!nullToAbsent || userId != null) { - map['user_id'] = Variable(userId); - } - if (!nullToAbsent || pinned != null) { - map['pinned'] = Variable(pinned); + map['user_id'] = Variable(userId); } + map['pinned'] = Variable(pinned); if (!nullToAbsent || pinnedAt != null) { - map['pinned_at'] = Variable(pinnedAt); + map['pinned_at'] = Variable(pinnedAt); } if (!nullToAbsent || pinExpires != null) { - map['pin_expires'] = Variable(pinExpires); + map['pin_expires'] = Variable(pinExpires); } if (!nullToAbsent || pinnedByUserId != null) { - map['pinned_by_user_id'] = Variable(pinnedByUserId); + map['pinned_by_user_id'] = Variable(pinnedByUserId); } if (!nullToAbsent || channelCid != null) { - map['channel_cid'] = Variable(channelCid); + map['channel_cid'] = Variable(channelCid); } if (!nullToAbsent || extraData != null) { final converter = $MessagesTable.$converter5; - map['extra_data'] = Variable(converter.mapToSql(extraData)); + map['extra_data'] = Variable(converter.mapToSql(extraData)); } return map; } factory MessageEntity.fromJson(Map json, - {ValueSerializer serializer}) { + {ValueSerializer? serializer}) { serializer ??= moorRuntimeOptions.defaultSerializer; return MessageEntity( id: serializer.fromJson(json['id']), - messageText: serializer.fromJson(json['messageText']), + messageText: serializer.fromJson(json['messageText']), attachments: serializer.fromJson>(json['attachments']), status: serializer.fromJson(json['status']), type: serializer.fromJson(json['type']), mentionedUsers: serializer.fromJson>(json['mentionedUsers']), reactionCounts: - serializer.fromJson>(json['reactionCounts']), + serializer.fromJson?>(json['reactionCounts']), reactionScores: - serializer.fromJson>(json['reactionScores']), - parentId: serializer.fromJson(json['parentId']), - quotedMessageId: serializer.fromJson(json['quotedMessageId']), - replyCount: serializer.fromJson(json['replyCount']), - showInChannel: serializer.fromJson(json['showInChannel']), + serializer.fromJson?>(json['reactionScores']), + parentId: serializer.fromJson(json['parentId']), + quotedMessageId: serializer.fromJson(json['quotedMessageId']), + replyCount: serializer.fromJson(json['replyCount']), + showInChannel: serializer.fromJson(json['showInChannel']), shadowed: serializer.fromJson(json['shadowed']), - command: serializer.fromJson(json['command']), + command: serializer.fromJson(json['command']), createdAt: serializer.fromJson(json['createdAt']), updatedAt: serializer.fromJson(json['updatedAt']), - deletedAt: serializer.fromJson(json['deletedAt']), - userId: serializer.fromJson(json['userId']), + deletedAt: serializer.fromJson(json['deletedAt']), + userId: serializer.fromJson(json['userId']), pinned: serializer.fromJson(json['pinned']), - pinnedAt: serializer.fromJson(json['pinnedAt']), - pinExpires: serializer.fromJson(json['pinExpires']), - pinnedByUserId: serializer.fromJson(json['pinnedByUserId']), - channelCid: serializer.fromJson(json['channelCid']), - extraData: serializer.fromJson>(json['extraData']), + pinnedAt: serializer.fromJson(json['pinnedAt']), + pinExpires: serializer.fromJson(json['pinExpires']), + pinnedByUserId: serializer.fromJson(json['pinnedByUserId']), + channelCid: serializer.fromJson(json['channelCid']), + extraData: serializer.fromJson?>(json['extraData']), ); } @override - Map toJson({ValueSerializer serializer}) { + Map toJson({ValueSerializer? serializer}) { serializer ??= moorRuntimeOptions.defaultSerializer; return { 'id': serializer.toJson(id), - 'messageText': serializer.toJson(messageText), + 'messageText': serializer.toJson(messageText), 'attachments': serializer.toJson>(attachments), 'status': serializer.toJson(status), 'type': serializer.toJson(type), 'mentionedUsers': serializer.toJson>(mentionedUsers), - 'reactionCounts': serializer.toJson>(reactionCounts), - 'reactionScores': serializer.toJson>(reactionScores), - 'parentId': serializer.toJson(parentId), - 'quotedMessageId': serializer.toJson(quotedMessageId), - 'replyCount': serializer.toJson(replyCount), - 'showInChannel': serializer.toJson(showInChannel), + 'reactionCounts': serializer.toJson?>(reactionCounts), + 'reactionScores': serializer.toJson?>(reactionScores), + 'parentId': serializer.toJson(parentId), + 'quotedMessageId': serializer.toJson(quotedMessageId), + 'replyCount': serializer.toJson(replyCount), + 'showInChannel': serializer.toJson(showInChannel), 'shadowed': serializer.toJson(shadowed), - 'command': serializer.toJson(command), + 'command': serializer.toJson(command), 'createdAt': serializer.toJson(createdAt), 'updatedAt': serializer.toJson(updatedAt), - 'deletedAt': serializer.toJson(deletedAt), - 'userId': serializer.toJson(userId), + 'deletedAt': serializer.toJson(deletedAt), + 'userId': serializer.toJson(userId), 'pinned': serializer.toJson(pinned), - 'pinnedAt': serializer.toJson(pinnedAt), - 'pinExpires': serializer.toJson(pinExpires), - 'pinnedByUserId': serializer.toJson(pinnedByUserId), - 'channelCid': serializer.toJson(channelCid), - 'extraData': serializer.toJson>(extraData), + 'pinnedAt': serializer.toJson(pinnedAt), + 'pinExpires': serializer.toJson(pinExpires), + 'pinnedByUserId': serializer.toJson(pinnedByUserId), + 'channelCid': serializer.toJson(channelCid), + 'extraData': serializer.toJson?>(extraData), }; } MessageEntity copyWith( - {String id, - Value messageText = const Value.absent(), - Value> attachments = const Value.absent(), - Value status = const Value.absent(), - Value type = const Value.absent(), - Value> mentionedUsers = const Value.absent(), - Value> reactionCounts = const Value.absent(), - Value> reactionScores = const Value.absent(), - Value parentId = const Value.absent(), - Value quotedMessageId = const Value.absent(), - Value replyCount = const Value.absent(), - Value showInChannel = const Value.absent(), - Value shadowed = const Value.absent(), - Value command = const Value.absent(), - DateTime createdAt, - Value updatedAt = const Value.absent(), - Value deletedAt = const Value.absent(), - Value userId = const Value.absent(), - bool pinned, - Value pinnedAt = const Value.absent(), - Value pinExpires = const Value.absent(), - Value pinnedByUserId = const Value.absent(), - Value channelCid = const Value.absent(), - Value> extraData = const Value.absent()}) => + {String? id, + Value messageText = const Value.absent(), + List? attachments, + MessageSendingStatus? status, + String? type, + List? mentionedUsers, + Value?> reactionCounts = const Value.absent(), + Value?> reactionScores = const Value.absent(), + Value parentId = const Value.absent(), + Value quotedMessageId = const Value.absent(), + Value replyCount = const Value.absent(), + Value showInChannel = const Value.absent(), + bool? shadowed, + Value command = const Value.absent(), + DateTime? createdAt, + DateTime? updatedAt, + Value deletedAt = const Value.absent(), + Value userId = const Value.absent(), + bool? pinned, + Value pinnedAt = const Value.absent(), + Value pinExpires = const Value.absent(), + Value pinnedByUserId = const Value.absent(), + Value channelCid = const Value.absent(), + Value?> extraData = const Value.absent()}) => MessageEntity( id: id ?? this.id, messageText: messageText.present ? messageText.value : this.messageText, - attachments: attachments.present ? attachments.value : this.attachments, - status: status.present ? status.value : this.status, - type: type.present ? type.value : this.type, - mentionedUsers: - mentionedUsers.present ? mentionedUsers.value : this.mentionedUsers, + attachments: attachments ?? this.attachments, + status: status ?? this.status, + type: type ?? this.type, + mentionedUsers: mentionedUsers ?? this.mentionedUsers, reactionCounts: reactionCounts.present ? reactionCounts.value : this.reactionCounts, reactionScores: @@ -955,10 +910,10 @@ class MessageEntity extends DataClass implements Insertable { replyCount: replyCount.present ? replyCount.value : this.replyCount, showInChannel: showInChannel.present ? showInChannel.value : this.showInChannel, - shadowed: shadowed.present ? shadowed.value : this.shadowed, + shadowed: shadowed ?? this.shadowed, command: command.present ? command.value : this.command, createdAt: createdAt ?? this.createdAt, - updatedAt: updatedAt.present ? updatedAt.value : this.updatedAt, + updatedAt: updatedAt ?? this.updatedAt, deletedAt: deletedAt.present ? deletedAt.value : this.deletedAt, userId: userId.present ? userId.value : this.userId, pinned: pinned ?? this.pinned, @@ -1045,7 +1000,7 @@ class MessageEntity extends DataClass implements Insertable { pinned.hashCode, $mrjc(pinnedAt.hashCode, $mrjc(pinExpires.hashCode, $mrjc(pinnedByUserId.hashCode, $mrjc(channelCid.hashCode, extraData.hashCode)))))))))))))))))))))))); @override - bool operator ==(dynamic other) => + bool operator ==(Object other) => identical(this, other) || (other is MessageEntity && other.id == this.id && @@ -1076,29 +1031,29 @@ class MessageEntity extends DataClass implements Insertable { class MessagesCompanion extends UpdateCompanion { final Value id; - final Value messageText; + final Value messageText; final Value> attachments; final Value status; final Value type; final Value> mentionedUsers; - final Value> reactionCounts; - final Value> reactionScores; - final Value parentId; - final Value quotedMessageId; - final Value replyCount; - final Value showInChannel; + final Value?> reactionCounts; + final Value?> reactionScores; + final Value parentId; + final Value quotedMessageId; + final Value replyCount; + final Value showInChannel; final Value shadowed; - final Value command; + final Value command; final Value createdAt; final Value updatedAt; - final Value deletedAt; - final Value userId; + final Value deletedAt; + final Value userId; final Value pinned; - final Value pinnedAt; - final Value pinExpires; - final Value pinnedByUserId; - final Value channelCid; - final Value> extraData; + final Value pinnedAt; + final Value pinExpires; + final Value pinnedByUserId; + final Value channelCid; + final Value?> extraData; const MessagesCompanion({ this.id = const Value.absent(), this.messageText = const Value.absent(), @@ -1126,12 +1081,12 @@ class MessagesCompanion extends UpdateCompanion { this.extraData = const Value.absent(), }); MessagesCompanion.insert({ - @required String id, + required String id, this.messageText = const Value.absent(), - this.attachments = const Value.absent(), + required List attachments, this.status = const Value.absent(), this.type = const Value.absent(), - this.mentionedUsers = const Value.absent(), + required List mentionedUsers, this.reactionCounts = const Value.absent(), this.reactionScores = const Value.absent(), this.parentId = const Value.absent(), @@ -1140,7 +1095,7 @@ class MessagesCompanion extends UpdateCompanion { this.showInChannel = const Value.absent(), this.shadowed = const Value.absent(), this.command = const Value.absent(), - @required DateTime createdAt, + this.createdAt = const Value.absent(), this.updatedAt = const Value.absent(), this.deletedAt = const Value.absent(), this.userId = const Value.absent(), @@ -1151,32 +1106,33 @@ class MessagesCompanion extends UpdateCompanion { this.channelCid = const Value.absent(), this.extraData = const Value.absent(), }) : id = Value(id), - createdAt = Value(createdAt); + attachments = Value(attachments), + mentionedUsers = Value(mentionedUsers); static Insertable custom({ - Expression id, - Expression messageText, - Expression attachments, - Expression status, - Expression type, - Expression mentionedUsers, - Expression reactionCounts, - Expression reactionScores, - Expression parentId, - Expression quotedMessageId, - Expression replyCount, - Expression showInChannel, - Expression shadowed, - Expression command, - Expression createdAt, - Expression updatedAt, - Expression deletedAt, - Expression userId, - Expression pinned, - Expression pinnedAt, - Expression pinExpires, - Expression pinnedByUserId, - Expression channelCid, - Expression extraData, + Expression? id, + Expression? messageText, + Expression>? attachments, + Expression? status, + Expression? type, + Expression>? mentionedUsers, + Expression?>? reactionCounts, + Expression?>? reactionScores, + Expression? parentId, + Expression? quotedMessageId, + Expression? replyCount, + Expression? showInChannel, + Expression? shadowed, + Expression? command, + Expression? createdAt, + Expression? updatedAt, + Expression? deletedAt, + Expression? userId, + Expression? pinned, + Expression? pinnedAt, + Expression? pinExpires, + Expression? pinnedByUserId, + Expression? channelCid, + Expression?>? extraData, }) { return RawValuesInsertable({ if (id != null) 'id': id, @@ -1207,30 +1163,30 @@ class MessagesCompanion extends UpdateCompanion { } MessagesCompanion copyWith( - {Value id, - Value messageText, - Value> attachments, - Value status, - Value type, - Value> mentionedUsers, - Value> reactionCounts, - Value> reactionScores, - Value parentId, - Value quotedMessageId, - Value replyCount, - Value showInChannel, - Value shadowed, - Value command, - Value createdAt, - Value updatedAt, - Value deletedAt, - Value userId, - Value pinned, - Value pinnedAt, - Value pinExpires, - Value pinnedByUserId, - Value channelCid, - Value> extraData}) { + {Value? id, + Value? messageText, + Value>? attachments, + Value? status, + Value? type, + Value>? mentionedUsers, + Value?>? reactionCounts, + Value?>? reactionScores, + Value? parentId, + Value? quotedMessageId, + Value? replyCount, + Value? showInChannel, + Value? shadowed, + Value? command, + Value? createdAt, + Value? updatedAt, + Value? deletedAt, + Value? userId, + Value? pinned, + Value? pinnedAt, + Value? pinExpires, + Value? pinnedByUserId, + Value? channelCid, + Value?>? extraData}) { return MessagesCompanion( id: id ?? this.id, messageText: messageText ?? this.messageText, @@ -1266,16 +1222,16 @@ class MessagesCompanion extends UpdateCompanion { map['id'] = Variable(id.value); } if (messageText.present) { - map['message_text'] = Variable(messageText.value); + map['message_text'] = Variable(messageText.value); } if (attachments.present) { final converter = $MessagesTable.$converter0; map['attachments'] = - Variable(converter.mapToSql(attachments.value)); + Variable(converter.mapToSql(attachments.value)!); } if (status.present) { final converter = $MessagesTable.$converter1; - map['status'] = Variable(converter.mapToSql(status.value)); + map['status'] = Variable(converter.mapToSql(status.value)!); } if (type.present) { map['type'] = Variable(type.value); @@ -1283,35 +1239,35 @@ class MessagesCompanion extends UpdateCompanion { if (mentionedUsers.present) { final converter = $MessagesTable.$converter2; map['mentioned_users'] = - Variable(converter.mapToSql(mentionedUsers.value)); + Variable(converter.mapToSql(mentionedUsers.value)!); } if (reactionCounts.present) { final converter = $MessagesTable.$converter3; map['reaction_counts'] = - Variable(converter.mapToSql(reactionCounts.value)); + Variable(converter.mapToSql(reactionCounts.value)); } if (reactionScores.present) { final converter = $MessagesTable.$converter4; map['reaction_scores'] = - Variable(converter.mapToSql(reactionScores.value)); + Variable(converter.mapToSql(reactionScores.value)); } if (parentId.present) { - map['parent_id'] = Variable(parentId.value); + map['parent_id'] = Variable(parentId.value); } if (quotedMessageId.present) { - map['quoted_message_id'] = Variable(quotedMessageId.value); + map['quoted_message_id'] = Variable(quotedMessageId.value); } if (replyCount.present) { - map['reply_count'] = Variable(replyCount.value); + map['reply_count'] = Variable(replyCount.value); } if (showInChannel.present) { - map['show_in_channel'] = Variable(showInChannel.value); + map['show_in_channel'] = Variable(showInChannel.value); } if (shadowed.present) { map['shadowed'] = Variable(shadowed.value); } if (command.present) { - map['command'] = Variable(command.value); + map['command'] = Variable(command.value); } if (createdAt.present) { map['created_at'] = Variable(createdAt.value); @@ -1320,29 +1276,30 @@ class MessagesCompanion extends UpdateCompanion { map['updated_at'] = Variable(updatedAt.value); } if (deletedAt.present) { - map['deleted_at'] = Variable(deletedAt.value); + map['deleted_at'] = Variable(deletedAt.value); } if (userId.present) { - map['user_id'] = Variable(userId.value); + map['user_id'] = Variable(userId.value); } if (pinned.present) { map['pinned'] = Variable(pinned.value); } if (pinnedAt.present) { - map['pinned_at'] = Variable(pinnedAt.value); + map['pinned_at'] = Variable(pinnedAt.value); } if (pinExpires.present) { - map['pin_expires'] = Variable(pinExpires.value); + map['pin_expires'] = Variable(pinExpires.value); } if (pinnedByUserId.present) { - map['pinned_by_user_id'] = Variable(pinnedByUserId.value); + map['pinned_by_user_id'] = Variable(pinnedByUserId.value); } if (channelCid.present) { - map['channel_cid'] = Variable(channelCid.value); + map['channel_cid'] = Variable(channelCid.value); } if (extraData.present) { final converter = $MessagesTable.$converter5; - map['extra_data'] = Variable(converter.mapToSql(extraData.value)); + map['extra_data'] = + Variable(converter.mapToSql(extraData.value)); } return map; } @@ -1382,308 +1339,140 @@ class MessagesCompanion extends UpdateCompanion { class $MessagesTable extends Messages with TableInfo<$MessagesTable, MessageEntity> { final GeneratedDatabase _db; - final String _alias; + final String? _alias; $MessagesTable(this._db, [this._alias]); final VerificationMeta _idMeta = const VerificationMeta('id'); - GeneratedTextColumn _id; - @override - GeneratedTextColumn get id => _id ??= _constructId(); - GeneratedTextColumn _constructId() { - return GeneratedTextColumn( - 'id', - $tableName, - false, - ); - } - + late final GeneratedColumn id = GeneratedColumn( + 'id', aliasedName, false, + typeName: 'TEXT', requiredDuringInsert: true); final VerificationMeta _messageTextMeta = const VerificationMeta('messageText'); - GeneratedTextColumn _messageText; - @override - GeneratedTextColumn get messageText => - _messageText ??= _constructMessageText(); - GeneratedTextColumn _constructMessageText() { - return GeneratedTextColumn( - 'message_text', - $tableName, - true, - ); - } - + late final GeneratedColumn messageText = GeneratedColumn( + 'message_text', aliasedName, true, + typeName: 'TEXT', requiredDuringInsert: false); final VerificationMeta _attachmentsMeta = const VerificationMeta('attachments'); - GeneratedTextColumn _attachments; - @override - GeneratedTextColumn get attachments => - _attachments ??= _constructAttachments(); - GeneratedTextColumn _constructAttachments() { - return GeneratedTextColumn( - 'attachments', - $tableName, - true, - ); - } - + late final GeneratedColumnWithTypeConverter, String?> + attachments = GeneratedColumn('attachments', aliasedName, false, + typeName: 'TEXT', requiredDuringInsert: true) + .withConverter>($MessagesTable.$converter0); final VerificationMeta _statusMeta = const VerificationMeta('status'); - GeneratedIntColumn _status; - @override - GeneratedIntColumn get status => _status ??= _constructStatus(); - GeneratedIntColumn _constructStatus() { - return GeneratedIntColumn( - 'status', - $tableName, - true, - ); - } - + late final GeneratedColumnWithTypeConverter + status = GeneratedColumn('status', aliasedName, false, + typeName: 'INTEGER', + requiredDuringInsert: false, + defaultValue: const Constant(1)) + .withConverter($MessagesTable.$converter1); final VerificationMeta _typeMeta = const VerificationMeta('type'); - GeneratedTextColumn _type; - @override - GeneratedTextColumn get type => _type ??= _constructType(); - GeneratedTextColumn _constructType() { - return GeneratedTextColumn( - 'type', - $tableName, - true, - ); - } - + late final GeneratedColumn type = GeneratedColumn( + 'type', aliasedName, false, + typeName: 'TEXT', + requiredDuringInsert: false, + defaultValue: const Constant('regular')); final VerificationMeta _mentionedUsersMeta = const VerificationMeta('mentionedUsers'); - GeneratedTextColumn _mentionedUsers; - @override - GeneratedTextColumn get mentionedUsers => - _mentionedUsers ??= _constructMentionedUsers(); - GeneratedTextColumn _constructMentionedUsers() { - return GeneratedTextColumn( - 'mentioned_users', - $tableName, - true, - ); - } - + late final GeneratedColumnWithTypeConverter, String?> + mentionedUsers = GeneratedColumn( + 'mentioned_users', aliasedName, false, + typeName: 'TEXT', requiredDuringInsert: true) + .withConverter>($MessagesTable.$converter2); final VerificationMeta _reactionCountsMeta = const VerificationMeta('reactionCounts'); - GeneratedTextColumn _reactionCounts; - @override - GeneratedTextColumn get reactionCounts => - _reactionCounts ??= _constructReactionCounts(); - GeneratedTextColumn _constructReactionCounts() { - return GeneratedTextColumn( - 'reaction_counts', - $tableName, - true, - ); - } - + late final GeneratedColumnWithTypeConverter, String?> + reactionCounts = GeneratedColumn( + 'reaction_counts', aliasedName, true, + typeName: 'TEXT', requiredDuringInsert: false) + .withConverter>($MessagesTable.$converter3); final VerificationMeta _reactionScoresMeta = const VerificationMeta('reactionScores'); - GeneratedTextColumn _reactionScores; - @override - GeneratedTextColumn get reactionScores => - _reactionScores ??= _constructReactionScores(); - GeneratedTextColumn _constructReactionScores() { - return GeneratedTextColumn( - 'reaction_scores', - $tableName, - true, - ); - } - + late final GeneratedColumnWithTypeConverter, String?> + reactionScores = GeneratedColumn( + 'reaction_scores', aliasedName, true, + typeName: 'TEXT', requiredDuringInsert: false) + .withConverter>($MessagesTable.$converter4); final VerificationMeta _parentIdMeta = const VerificationMeta('parentId'); - GeneratedTextColumn _parentId; - @override - GeneratedTextColumn get parentId => _parentId ??= _constructParentId(); - GeneratedTextColumn _constructParentId() { - return GeneratedTextColumn( - 'parent_id', - $tableName, - true, - ); - } - + late final GeneratedColumn parentId = GeneratedColumn( + 'parent_id', aliasedName, true, + typeName: 'TEXT', requiredDuringInsert: false); final VerificationMeta _quotedMessageIdMeta = const VerificationMeta('quotedMessageId'); - GeneratedTextColumn _quotedMessageId; - @override - GeneratedTextColumn get quotedMessageId => - _quotedMessageId ??= _constructQuotedMessageId(); - GeneratedTextColumn _constructQuotedMessageId() { - return GeneratedTextColumn( - 'quoted_message_id', - $tableName, - true, - ); - } - + late final GeneratedColumn quotedMessageId = + GeneratedColumn('quoted_message_id', aliasedName, true, + typeName: 'TEXT', requiredDuringInsert: false); final VerificationMeta _replyCountMeta = const VerificationMeta('replyCount'); - GeneratedIntColumn _replyCount; - @override - GeneratedIntColumn get replyCount => _replyCount ??= _constructReplyCount(); - GeneratedIntColumn _constructReplyCount() { - return GeneratedIntColumn( - 'reply_count', - $tableName, - true, - ); - } - + late final GeneratedColumn replyCount = GeneratedColumn( + 'reply_count', aliasedName, true, + typeName: 'INTEGER', requiredDuringInsert: false); final VerificationMeta _showInChannelMeta = const VerificationMeta('showInChannel'); - GeneratedBoolColumn _showInChannel; - @override - GeneratedBoolColumn get showInChannel => - _showInChannel ??= _constructShowInChannel(); - GeneratedBoolColumn _constructShowInChannel() { - return GeneratedBoolColumn( - 'show_in_channel', - $tableName, - true, - ); - } - + late final GeneratedColumn showInChannel = GeneratedColumn( + 'show_in_channel', aliasedName, true, + typeName: 'INTEGER', + requiredDuringInsert: false, + defaultConstraints: 'CHECK (show_in_channel IN (0, 1))'); final VerificationMeta _shadowedMeta = const VerificationMeta('shadowed'); - GeneratedBoolColumn _shadowed; - @override - GeneratedBoolColumn get shadowed => _shadowed ??= _constructShadowed(); - GeneratedBoolColumn _constructShadowed() { - return GeneratedBoolColumn( - 'shadowed', - $tableName, - true, - ); - } - + late final GeneratedColumn shadowed = GeneratedColumn( + 'shadowed', aliasedName, false, + typeName: 'INTEGER', + requiredDuringInsert: false, + defaultConstraints: 'CHECK (shadowed IN (0, 1))', + defaultValue: const Constant(false)); final VerificationMeta _commandMeta = const VerificationMeta('command'); - GeneratedTextColumn _command; - @override - GeneratedTextColumn get command => _command ??= _constructCommand(); - GeneratedTextColumn _constructCommand() { - return GeneratedTextColumn( - 'command', - $tableName, - true, - ); - } - + late final GeneratedColumn command = GeneratedColumn( + 'command', aliasedName, true, + typeName: 'TEXT', requiredDuringInsert: false); final VerificationMeta _createdAtMeta = const VerificationMeta('createdAt'); - GeneratedDateTimeColumn _createdAt; - @override - GeneratedDateTimeColumn get createdAt => _createdAt ??= _constructCreatedAt(); - GeneratedDateTimeColumn _constructCreatedAt() { - return GeneratedDateTimeColumn( - 'created_at', - $tableName, - false, - ); - } - + late final GeneratedColumn createdAt = GeneratedColumn( + 'created_at', aliasedName, false, + typeName: 'INTEGER', + requiredDuringInsert: false, + defaultValue: currentDateAndTime); final VerificationMeta _updatedAtMeta = const VerificationMeta('updatedAt'); - GeneratedDateTimeColumn _updatedAt; - @override - GeneratedDateTimeColumn get updatedAt => _updatedAt ??= _constructUpdatedAt(); - GeneratedDateTimeColumn _constructUpdatedAt() { - return GeneratedDateTimeColumn( - 'updated_at', - $tableName, - true, - ); - } - + late final GeneratedColumn updatedAt = GeneratedColumn( + 'updated_at', aliasedName, false, + typeName: 'INTEGER', + requiredDuringInsert: false, + defaultValue: currentDateAndTime); final VerificationMeta _deletedAtMeta = const VerificationMeta('deletedAt'); - GeneratedDateTimeColumn _deletedAt; - @override - GeneratedDateTimeColumn get deletedAt => _deletedAt ??= _constructDeletedAt(); - GeneratedDateTimeColumn _constructDeletedAt() { - return GeneratedDateTimeColumn( - 'deleted_at', - $tableName, - true, - ); - } - + late final GeneratedColumn deletedAt = GeneratedColumn( + 'deleted_at', aliasedName, true, + typeName: 'INTEGER', requiredDuringInsert: false); final VerificationMeta _userIdMeta = const VerificationMeta('userId'); - GeneratedTextColumn _userId; - @override - GeneratedTextColumn get userId => _userId ??= _constructUserId(); - GeneratedTextColumn _constructUserId() { - return GeneratedTextColumn( - 'user_id', - $tableName, - true, - ); - } - + late final GeneratedColumn userId = GeneratedColumn( + 'user_id', aliasedName, true, + typeName: 'TEXT', requiredDuringInsert: false); final VerificationMeta _pinnedMeta = const VerificationMeta('pinned'); - GeneratedBoolColumn _pinned; - @override - GeneratedBoolColumn get pinned => _pinned ??= _constructPinned(); - GeneratedBoolColumn _constructPinned() { - return GeneratedBoolColumn('pinned', $tableName, false, - defaultValue: const Constant(false)); - } - + late final GeneratedColumn pinned = GeneratedColumn( + 'pinned', aliasedName, false, + typeName: 'INTEGER', + requiredDuringInsert: false, + defaultConstraints: 'CHECK (pinned IN (0, 1))', + defaultValue: const Constant(false)); final VerificationMeta _pinnedAtMeta = const VerificationMeta('pinnedAt'); - GeneratedDateTimeColumn _pinnedAt; - @override - GeneratedDateTimeColumn get pinnedAt => _pinnedAt ??= _constructPinnedAt(); - GeneratedDateTimeColumn _constructPinnedAt() { - return GeneratedDateTimeColumn( - 'pinned_at', - $tableName, - true, - ); - } - + late final GeneratedColumn pinnedAt = GeneratedColumn( + 'pinned_at', aliasedName, true, + typeName: 'INTEGER', requiredDuringInsert: false); final VerificationMeta _pinExpiresMeta = const VerificationMeta('pinExpires'); - GeneratedDateTimeColumn _pinExpires; - @override - GeneratedDateTimeColumn get pinExpires => - _pinExpires ??= _constructPinExpires(); - GeneratedDateTimeColumn _constructPinExpires() { - return GeneratedDateTimeColumn( - 'pin_expires', - $tableName, - true, - ); - } - + late final GeneratedColumn pinExpires = GeneratedColumn( + 'pin_expires', aliasedName, true, + typeName: 'INTEGER', requiredDuringInsert: false); final VerificationMeta _pinnedByUserIdMeta = const VerificationMeta('pinnedByUserId'); - GeneratedTextColumn _pinnedByUserId; - @override - GeneratedTextColumn get pinnedByUserId => - _pinnedByUserId ??= _constructPinnedByUserId(); - GeneratedTextColumn _constructPinnedByUserId() { - return GeneratedTextColumn( - 'pinned_by_user_id', - $tableName, - true, - ); - } - + late final GeneratedColumn pinnedByUserId = GeneratedColumn( + 'pinned_by_user_id', aliasedName, true, + typeName: 'TEXT', requiredDuringInsert: false); final VerificationMeta _channelCidMeta = const VerificationMeta('channelCid'); - GeneratedTextColumn _channelCid; - @override - GeneratedTextColumn get channelCid => _channelCid ??= _constructChannelCid(); - GeneratedTextColumn _constructChannelCid() { - return GeneratedTextColumn('channel_cid', $tableName, true, - $customConstraints: - 'NULLABLE REFERENCES channels(cid) ON DELETE CASCADE'); - } - + late final GeneratedColumn channelCid = GeneratedColumn( + 'channel_cid', aliasedName, true, + typeName: 'TEXT', + requiredDuringInsert: false, + $customConstraints: + 'NULLABLE REFERENCES channels(cid) ON DELETE CASCADE'); final VerificationMeta _extraDataMeta = const VerificationMeta('extraData'); - GeneratedTextColumn _extraData; - @override - GeneratedTextColumn get extraData => _extraData ??= _constructExtraData(); - GeneratedTextColumn _constructExtraData() { - return GeneratedTextColumn( - 'extra_data', - $tableName, - true, - ); - } - + late final GeneratedColumnWithTypeConverter, String?> + extraData = GeneratedColumn('extra_data', aliasedName, true, + typeName: 'TEXT', requiredDuringInsert: false) + .withConverter>($MessagesTable.$converter5); @override List get $columns => [ id, @@ -1712,18 +1501,16 @@ class $MessagesTable extends Messages extraData ]; @override - $MessagesTable get asDslTable => this; + String get aliasedName => _alias ?? 'messages'; @override - String get $tableName => _alias ?? 'messages'; - @override - final String actualTableName = 'messages'; + String get actualTableName => 'messages'; @override VerificationContext validateIntegrity(Insertable instance, {bool isInserting = false}) { final context = VerificationContext(); final data = instance.toColumns(true); if (data.containsKey('id')) { - context.handle(_idMeta, id.isAcceptableOrUnknown(data['id'], _idMeta)); + context.handle(_idMeta, id.isAcceptableOrUnknown(data['id']!, _idMeta)); } else if (isInserting) { context.missing(_idMeta); } @@ -1731,90 +1518,88 @@ class $MessagesTable extends Messages context.handle( _messageTextMeta, messageText.isAcceptableOrUnknown( - data['message_text'], _messageTextMeta)); + data['message_text']!, _messageTextMeta)); } context.handle(_attachmentsMeta, const VerificationResult.success()); context.handle(_statusMeta, const VerificationResult.success()); if (data.containsKey('type')) { context.handle( - _typeMeta, type.isAcceptableOrUnknown(data['type'], _typeMeta)); + _typeMeta, type.isAcceptableOrUnknown(data['type']!, _typeMeta)); } context.handle(_mentionedUsersMeta, const VerificationResult.success()); context.handle(_reactionCountsMeta, const VerificationResult.success()); context.handle(_reactionScoresMeta, const VerificationResult.success()); if (data.containsKey('parent_id')) { context.handle(_parentIdMeta, - parentId.isAcceptableOrUnknown(data['parent_id'], _parentIdMeta)); + parentId.isAcceptableOrUnknown(data['parent_id']!, _parentIdMeta)); } if (data.containsKey('quoted_message_id')) { context.handle( _quotedMessageIdMeta, quotedMessageId.isAcceptableOrUnknown( - data['quoted_message_id'], _quotedMessageIdMeta)); + data['quoted_message_id']!, _quotedMessageIdMeta)); } if (data.containsKey('reply_count')) { context.handle( _replyCountMeta, replyCount.isAcceptableOrUnknown( - data['reply_count'], _replyCountMeta)); + data['reply_count']!, _replyCountMeta)); } if (data.containsKey('show_in_channel')) { context.handle( _showInChannelMeta, showInChannel.isAcceptableOrUnknown( - data['show_in_channel'], _showInChannelMeta)); + data['show_in_channel']!, _showInChannelMeta)); } if (data.containsKey('shadowed')) { context.handle(_shadowedMeta, - shadowed.isAcceptableOrUnknown(data['shadowed'], _shadowedMeta)); + shadowed.isAcceptableOrUnknown(data['shadowed']!, _shadowedMeta)); } if (data.containsKey('command')) { context.handle(_commandMeta, - command.isAcceptableOrUnknown(data['command'], _commandMeta)); + command.isAcceptableOrUnknown(data['command']!, _commandMeta)); } if (data.containsKey('created_at')) { context.handle(_createdAtMeta, - createdAt.isAcceptableOrUnknown(data['created_at'], _createdAtMeta)); - } else if (isInserting) { - context.missing(_createdAtMeta); + createdAt.isAcceptableOrUnknown(data['created_at']!, _createdAtMeta)); } if (data.containsKey('updated_at')) { context.handle(_updatedAtMeta, - updatedAt.isAcceptableOrUnknown(data['updated_at'], _updatedAtMeta)); + updatedAt.isAcceptableOrUnknown(data['updated_at']!, _updatedAtMeta)); } if (data.containsKey('deleted_at')) { context.handle(_deletedAtMeta, - deletedAt.isAcceptableOrUnknown(data['deleted_at'], _deletedAtMeta)); + deletedAt.isAcceptableOrUnknown(data['deleted_at']!, _deletedAtMeta)); } if (data.containsKey('user_id')) { context.handle(_userIdMeta, - userId.isAcceptableOrUnknown(data['user_id'], _userIdMeta)); + userId.isAcceptableOrUnknown(data['user_id']!, _userIdMeta)); } if (data.containsKey('pinned')) { context.handle(_pinnedMeta, - pinned.isAcceptableOrUnknown(data['pinned'], _pinnedMeta)); + pinned.isAcceptableOrUnknown(data['pinned']!, _pinnedMeta)); } if (data.containsKey('pinned_at')) { context.handle(_pinnedAtMeta, - pinnedAt.isAcceptableOrUnknown(data['pinned_at'], _pinnedAtMeta)); + pinnedAt.isAcceptableOrUnknown(data['pinned_at']!, _pinnedAtMeta)); } if (data.containsKey('pin_expires')) { context.handle( _pinExpiresMeta, pinExpires.isAcceptableOrUnknown( - data['pin_expires'], _pinExpiresMeta)); + data['pin_expires']!, _pinExpiresMeta)); } if (data.containsKey('pinned_by_user_id')) { context.handle( _pinnedByUserIdMeta, pinnedByUserId.isAcceptableOrUnknown( - data['pinned_by_user_id'], _pinnedByUserIdMeta)); + data['pinned_by_user_id']!, _pinnedByUserIdMeta)); } if (data.containsKey('channel_cid')) { context.handle( _channelCidMeta, channelCid.isAcceptableOrUnknown( - data['channel_cid'], _channelCidMeta)); + data['channel_cid']!, _channelCidMeta)); } context.handle(_extraDataMeta, const VerificationResult.success()); return context; @@ -1823,9 +1608,9 @@ class $MessagesTable extends Messages @override Set get $primaryKey => {id}; @override - MessageEntity map(Map data, {String tablePrefix}) { - final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : null; - return MessageEntity.fromData(data, _db, prefix: effectivePrefix); + MessageEntity map(Map data, {String? tablePrefix}) { + return MessageEntity.fromData(data, _db, + prefix: tablePrefix != null ? '$tablePrefix.' : null); } @override @@ -1843,56 +1628,104 @@ class $MessagesTable extends Messages MapConverter(); static TypeConverter, String> $converter4 = MapConverter(); - static TypeConverter, String> $converter5 = - MapConverter(); + static TypeConverter, String> $converter5 = + MapConverter(); } class PinnedMessageEntity extends DataClass implements Insertable { + /// The message id final String id; - final String messageText; + + /// The text of this message + final String? messageText; + + /// The list of attachments, either provided by the user + /// or generated from a command or as a result of URL scraping. final List attachments; + + /// The status of a sending message final MessageSendingStatus status; + + /// The message type final String type; + + /// The list of user mentioned in the message final List mentionedUsers; - final Map reactionCounts; - final Map reactionScores; - final String parentId; - final String quotedMessageId; - final int replyCount; - final bool showInChannel; + + /// A map describing the count of number of every reaction + final Map? reactionCounts; + + /// A map describing the count of score of every reaction + final Map? reactionScores; + + /// The ID of the parent message, if the message is a thread reply. + final String? parentId; + + /// The ID of the quoted message, if the message is a quoted reply. + final String? quotedMessageId; + + /// Number of replies for this message. + final int? replyCount; + + /// Check if this message needs to show in the channel. + final bool? showInChannel; + + /// If true the message is shadowed final bool shadowed; - final String command; + + /// A used command name. + final String? command; + + /// The DateTime when the message was created. final DateTime createdAt; + + /// The DateTime when the message was updated last time. final DateTime updatedAt; - final DateTime deletedAt; - final String userId; + + /// The DateTime when the message was deleted. + final DateTime? deletedAt; + + /// Id of the User who sent the message + final String? userId; + + /// Whether the message is pinned or not final bool pinned; - final DateTime pinnedAt; - final DateTime pinExpires; - final String pinnedByUserId; - final String channelCid; - final Map extraData; + + /// The DateTime at which the message was pinned + final DateTime? pinnedAt; + + /// The DateTime on which the message pin expires + final DateTime? pinExpires; + + /// Id of the User who pinned the message + final String? pinnedByUserId; + + /// The channel cid of which this message is part of + final String? channelCid; + + /// Message custom extraData + final Map? extraData; PinnedMessageEntity( - {@required this.id, + {required this.id, this.messageText, - this.attachments, - this.status, - this.type, - this.mentionedUsers, + required this.attachments, + required this.status, + required this.type, + required this.mentionedUsers, this.reactionCounts, this.reactionScores, this.parentId, this.quotedMessageId, this.replyCount, this.showInChannel, - this.shadowed, + required this.shadowed, this.command, - @required this.createdAt, - this.updatedAt, + required this.createdAt, + required this.updatedAt, this.deletedAt, this.userId, - @required this.pinned, + required this.pinned, this.pinnedAt, this.pinExpires, this.pinnedByUserId, @@ -1900,244 +1733,232 @@ class PinnedMessageEntity extends DataClass this.extraData}); factory PinnedMessageEntity.fromData( Map data, GeneratedDatabase db, - {String prefix}) { + {String? prefix}) { final effectivePrefix = prefix ?? ''; - final stringType = db.typeSystem.forDartType(); - final intType = db.typeSystem.forDartType(); - final boolType = db.typeSystem.forDartType(); - final dateTimeType = db.typeSystem.forDartType(); return PinnedMessageEntity( - id: stringType.mapFromDatabaseResponse(data['${effectivePrefix}id']), - messageText: stringType + id: const StringType() + .mapFromDatabaseResponse(data['${effectivePrefix}id'])!, + messageText: const StringType() .mapFromDatabaseResponse(data['${effectivePrefix}message_text']), - attachments: $PinnedMessagesTable.$converter0.mapToDart(stringType - .mapFromDatabaseResponse(data['${effectivePrefix}attachments'])), - status: $PinnedMessagesTable.$converter1.mapToDart( - intType.mapFromDatabaseResponse(data['${effectivePrefix}status'])), - type: stringType.mapFromDatabaseResponse(data['${effectivePrefix}type']), - mentionedUsers: $PinnedMessagesTable.$converter2.mapToDart(stringType - .mapFromDatabaseResponse(data['${effectivePrefix}mentioned_users'])), - reactionCounts: $PinnedMessagesTable.$converter3.mapToDart(stringType - .mapFromDatabaseResponse(data['${effectivePrefix}reaction_counts'])), - reactionScores: $PinnedMessagesTable.$converter4.mapToDart(stringType - .mapFromDatabaseResponse(data['${effectivePrefix}reaction_scores'])), - parentId: stringType + attachments: $PinnedMessagesTable.$converter0.mapToDart(const StringType() + .mapFromDatabaseResponse(data['${effectivePrefix}attachments']))!, + status: $PinnedMessagesTable.$converter1.mapToDart(const IntType() + .mapFromDatabaseResponse(data['${effectivePrefix}status']))!, + type: const StringType() + .mapFromDatabaseResponse(data['${effectivePrefix}type'])!, + mentionedUsers: $PinnedMessagesTable.$converter2.mapToDart( + const StringType().mapFromDatabaseResponse( + data['${effectivePrefix}mentioned_users']))!, + reactionCounts: $PinnedMessagesTable.$converter3.mapToDart( + const StringType().mapFromDatabaseResponse( + data['${effectivePrefix}reaction_counts'])), + reactionScores: $PinnedMessagesTable.$converter4.mapToDart( + const StringType().mapFromDatabaseResponse( + data['${effectivePrefix}reaction_scores'])), + parentId: const StringType() .mapFromDatabaseResponse(data['${effectivePrefix}parent_id']), - quotedMessageId: stringType + quotedMessageId: const StringType() .mapFromDatabaseResponse(data['${effectivePrefix}quoted_message_id']), - replyCount: intType + replyCount: const IntType() .mapFromDatabaseResponse(data['${effectivePrefix}reply_count']), - showInChannel: boolType + showInChannel: const BoolType() .mapFromDatabaseResponse(data['${effectivePrefix}show_in_channel']), - shadowed: - boolType.mapFromDatabaseResponse(data['${effectivePrefix}shadowed']), - command: - stringType.mapFromDatabaseResponse(data['${effectivePrefix}command']), - createdAt: dateTimeType - .mapFromDatabaseResponse(data['${effectivePrefix}created_at']), - updatedAt: dateTimeType - .mapFromDatabaseResponse(data['${effectivePrefix}updated_at']), - deletedAt: dateTimeType + shadowed: const BoolType() + .mapFromDatabaseResponse(data['${effectivePrefix}shadowed'])!, + command: const StringType() + .mapFromDatabaseResponse(data['${effectivePrefix}command']), + createdAt: const DateTimeType() + .mapFromDatabaseResponse(data['${effectivePrefix}created_at'])!, + updatedAt: const DateTimeType() + .mapFromDatabaseResponse(data['${effectivePrefix}updated_at'])!, + deletedAt: const DateTimeType() .mapFromDatabaseResponse(data['${effectivePrefix}deleted_at']), - userId: - stringType.mapFromDatabaseResponse(data['${effectivePrefix}user_id']), - pinned: - boolType.mapFromDatabaseResponse(data['${effectivePrefix}pinned']), - pinnedAt: dateTimeType + userId: const StringType() + .mapFromDatabaseResponse(data['${effectivePrefix}user_id']), + pinned: const BoolType() + .mapFromDatabaseResponse(data['${effectivePrefix}pinned'])!, + pinnedAt: const DateTimeType() .mapFromDatabaseResponse(data['${effectivePrefix}pinned_at']), - pinExpires: dateTimeType + pinExpires: const DateTimeType() .mapFromDatabaseResponse(data['${effectivePrefix}pin_expires']), - pinnedByUserId: stringType + pinnedByUserId: const StringType() .mapFromDatabaseResponse(data['${effectivePrefix}pinned_by_user_id']), - channelCid: stringType + channelCid: const StringType() .mapFromDatabaseResponse(data['${effectivePrefix}channel_cid']), - extraData: $PinnedMessagesTable.$converter5.mapToDart(stringType + extraData: $PinnedMessagesTable.$converter5.mapToDart(const StringType() .mapFromDatabaseResponse(data['${effectivePrefix}extra_data'])), ); } @override Map toColumns(bool nullToAbsent) { final map = {}; - if (!nullToAbsent || id != null) { - map['id'] = Variable(id); - } + map['id'] = Variable(id); if (!nullToAbsent || messageText != null) { - map['message_text'] = Variable(messageText); + map['message_text'] = Variable(messageText); } - if (!nullToAbsent || attachments != null) { + { final converter = $PinnedMessagesTable.$converter0; - map['attachments'] = Variable(converter.mapToSql(attachments)); + map['attachments'] = Variable(converter.mapToSql(attachments)!); } - if (!nullToAbsent || status != null) { + { final converter = $PinnedMessagesTable.$converter1; - map['status'] = Variable(converter.mapToSql(status)); + map['status'] = Variable(converter.mapToSql(status)!); } - if (!nullToAbsent || type != null) { - map['type'] = Variable(type); - } - if (!nullToAbsent || mentionedUsers != null) { + map['type'] = Variable(type); + { final converter = $PinnedMessagesTable.$converter2; map['mentioned_users'] = - Variable(converter.mapToSql(mentionedUsers)); + Variable(converter.mapToSql(mentionedUsers)!); } if (!nullToAbsent || reactionCounts != null) { final converter = $PinnedMessagesTable.$converter3; map['reaction_counts'] = - Variable(converter.mapToSql(reactionCounts)); + Variable(converter.mapToSql(reactionCounts)); } if (!nullToAbsent || reactionScores != null) { final converter = $PinnedMessagesTable.$converter4; map['reaction_scores'] = - Variable(converter.mapToSql(reactionScores)); + Variable(converter.mapToSql(reactionScores)); } if (!nullToAbsent || parentId != null) { - map['parent_id'] = Variable(parentId); + map['parent_id'] = Variable(parentId); } if (!nullToAbsent || quotedMessageId != null) { - map['quoted_message_id'] = Variable(quotedMessageId); + map['quoted_message_id'] = Variable(quotedMessageId); } if (!nullToAbsent || replyCount != null) { - map['reply_count'] = Variable(replyCount); + map['reply_count'] = Variable(replyCount); } if (!nullToAbsent || showInChannel != null) { - map['show_in_channel'] = Variable(showInChannel); - } - if (!nullToAbsent || shadowed != null) { - map['shadowed'] = Variable(shadowed); + map['show_in_channel'] = Variable(showInChannel); } + map['shadowed'] = Variable(shadowed); if (!nullToAbsent || command != null) { - map['command'] = Variable(command); - } - if (!nullToAbsent || createdAt != null) { - map['created_at'] = Variable(createdAt); - } - if (!nullToAbsent || updatedAt != null) { - map['updated_at'] = Variable(updatedAt); + map['command'] = Variable(command); } + map['created_at'] = Variable(createdAt); + map['updated_at'] = Variable(updatedAt); if (!nullToAbsent || deletedAt != null) { - map['deleted_at'] = Variable(deletedAt); + map['deleted_at'] = Variable(deletedAt); } if (!nullToAbsent || userId != null) { - map['user_id'] = Variable(userId); - } - if (!nullToAbsent || pinned != null) { - map['pinned'] = Variable(pinned); + map['user_id'] = Variable(userId); } + map['pinned'] = Variable(pinned); if (!nullToAbsent || pinnedAt != null) { - map['pinned_at'] = Variable(pinnedAt); + map['pinned_at'] = Variable(pinnedAt); } if (!nullToAbsent || pinExpires != null) { - map['pin_expires'] = Variable(pinExpires); + map['pin_expires'] = Variable(pinExpires); } if (!nullToAbsent || pinnedByUserId != null) { - map['pinned_by_user_id'] = Variable(pinnedByUserId); + map['pinned_by_user_id'] = Variable(pinnedByUserId); } if (!nullToAbsent || channelCid != null) { - map['channel_cid'] = Variable(channelCid); + map['channel_cid'] = Variable(channelCid); } if (!nullToAbsent || extraData != null) { final converter = $PinnedMessagesTable.$converter5; - map['extra_data'] = Variable(converter.mapToSql(extraData)); + map['extra_data'] = Variable(converter.mapToSql(extraData)); } return map; } factory PinnedMessageEntity.fromJson(Map json, - {ValueSerializer serializer}) { + {ValueSerializer? serializer}) { serializer ??= moorRuntimeOptions.defaultSerializer; return PinnedMessageEntity( id: serializer.fromJson(json['id']), - messageText: serializer.fromJson(json['messageText']), + messageText: serializer.fromJson(json['messageText']), attachments: serializer.fromJson>(json['attachments']), status: serializer.fromJson(json['status']), type: serializer.fromJson(json['type']), mentionedUsers: serializer.fromJson>(json['mentionedUsers']), reactionCounts: - serializer.fromJson>(json['reactionCounts']), + serializer.fromJson?>(json['reactionCounts']), reactionScores: - serializer.fromJson>(json['reactionScores']), - parentId: serializer.fromJson(json['parentId']), - quotedMessageId: serializer.fromJson(json['quotedMessageId']), - replyCount: serializer.fromJson(json['replyCount']), - showInChannel: serializer.fromJson(json['showInChannel']), + serializer.fromJson?>(json['reactionScores']), + parentId: serializer.fromJson(json['parentId']), + quotedMessageId: serializer.fromJson(json['quotedMessageId']), + replyCount: serializer.fromJson(json['replyCount']), + showInChannel: serializer.fromJson(json['showInChannel']), shadowed: serializer.fromJson(json['shadowed']), - command: serializer.fromJson(json['command']), + command: serializer.fromJson(json['command']), createdAt: serializer.fromJson(json['createdAt']), updatedAt: serializer.fromJson(json['updatedAt']), - deletedAt: serializer.fromJson(json['deletedAt']), - userId: serializer.fromJson(json['userId']), + deletedAt: serializer.fromJson(json['deletedAt']), + userId: serializer.fromJson(json['userId']), pinned: serializer.fromJson(json['pinned']), - pinnedAt: serializer.fromJson(json['pinnedAt']), - pinExpires: serializer.fromJson(json['pinExpires']), - pinnedByUserId: serializer.fromJson(json['pinnedByUserId']), - channelCid: serializer.fromJson(json['channelCid']), - extraData: serializer.fromJson>(json['extraData']), + pinnedAt: serializer.fromJson(json['pinnedAt']), + pinExpires: serializer.fromJson(json['pinExpires']), + pinnedByUserId: serializer.fromJson(json['pinnedByUserId']), + channelCid: serializer.fromJson(json['channelCid']), + extraData: serializer.fromJson?>(json['extraData']), ); } @override - Map toJson({ValueSerializer serializer}) { + Map toJson({ValueSerializer? serializer}) { serializer ??= moorRuntimeOptions.defaultSerializer; return { 'id': serializer.toJson(id), - 'messageText': serializer.toJson(messageText), + 'messageText': serializer.toJson(messageText), 'attachments': serializer.toJson>(attachments), 'status': serializer.toJson(status), 'type': serializer.toJson(type), 'mentionedUsers': serializer.toJson>(mentionedUsers), - 'reactionCounts': serializer.toJson>(reactionCounts), - 'reactionScores': serializer.toJson>(reactionScores), - 'parentId': serializer.toJson(parentId), - 'quotedMessageId': serializer.toJson(quotedMessageId), - 'replyCount': serializer.toJson(replyCount), - 'showInChannel': serializer.toJson(showInChannel), + 'reactionCounts': serializer.toJson?>(reactionCounts), + 'reactionScores': serializer.toJson?>(reactionScores), + 'parentId': serializer.toJson(parentId), + 'quotedMessageId': serializer.toJson(quotedMessageId), + 'replyCount': serializer.toJson(replyCount), + 'showInChannel': serializer.toJson(showInChannel), 'shadowed': serializer.toJson(shadowed), - 'command': serializer.toJson(command), + 'command': serializer.toJson(command), 'createdAt': serializer.toJson(createdAt), 'updatedAt': serializer.toJson(updatedAt), - 'deletedAt': serializer.toJson(deletedAt), - 'userId': serializer.toJson(userId), + 'deletedAt': serializer.toJson(deletedAt), + 'userId': serializer.toJson(userId), 'pinned': serializer.toJson(pinned), - 'pinnedAt': serializer.toJson(pinnedAt), - 'pinExpires': serializer.toJson(pinExpires), - 'pinnedByUserId': serializer.toJson(pinnedByUserId), - 'channelCid': serializer.toJson(channelCid), - 'extraData': serializer.toJson>(extraData), + 'pinnedAt': serializer.toJson(pinnedAt), + 'pinExpires': serializer.toJson(pinExpires), + 'pinnedByUserId': serializer.toJson(pinnedByUserId), + 'channelCid': serializer.toJson(channelCid), + 'extraData': serializer.toJson?>(extraData), }; } PinnedMessageEntity copyWith( - {String id, - Value messageText = const Value.absent(), - Value> attachments = const Value.absent(), - Value status = const Value.absent(), - Value type = const Value.absent(), - Value> mentionedUsers = const Value.absent(), - Value> reactionCounts = const Value.absent(), - Value> reactionScores = const Value.absent(), - Value parentId = const Value.absent(), - Value quotedMessageId = const Value.absent(), - Value replyCount = const Value.absent(), - Value showInChannel = const Value.absent(), - Value shadowed = const Value.absent(), - Value command = const Value.absent(), - DateTime createdAt, - Value updatedAt = const Value.absent(), - Value deletedAt = const Value.absent(), - Value userId = const Value.absent(), - bool pinned, - Value pinnedAt = const Value.absent(), - Value pinExpires = const Value.absent(), - Value pinnedByUserId = const Value.absent(), - Value channelCid = const Value.absent(), - Value> extraData = const Value.absent()}) => + {String? id, + Value messageText = const Value.absent(), + List? attachments, + MessageSendingStatus? status, + String? type, + List? mentionedUsers, + Value?> reactionCounts = const Value.absent(), + Value?> reactionScores = const Value.absent(), + Value parentId = const Value.absent(), + Value quotedMessageId = const Value.absent(), + Value replyCount = const Value.absent(), + Value showInChannel = const Value.absent(), + bool? shadowed, + Value command = const Value.absent(), + DateTime? createdAt, + DateTime? updatedAt, + Value deletedAt = const Value.absent(), + Value userId = const Value.absent(), + bool? pinned, + Value pinnedAt = const Value.absent(), + Value pinExpires = const Value.absent(), + Value pinnedByUserId = const Value.absent(), + Value channelCid = const Value.absent(), + Value?> extraData = const Value.absent()}) => PinnedMessageEntity( id: id ?? this.id, messageText: messageText.present ? messageText.value : this.messageText, - attachments: attachments.present ? attachments.value : this.attachments, - status: status.present ? status.value : this.status, - type: type.present ? type.value : this.type, - mentionedUsers: - mentionedUsers.present ? mentionedUsers.value : this.mentionedUsers, + attachments: attachments ?? this.attachments, + status: status ?? this.status, + type: type ?? this.type, + mentionedUsers: mentionedUsers ?? this.mentionedUsers, reactionCounts: reactionCounts.present ? reactionCounts.value : this.reactionCounts, reactionScores: @@ -2149,10 +1970,10 @@ class PinnedMessageEntity extends DataClass replyCount: replyCount.present ? replyCount.value : this.replyCount, showInChannel: showInChannel.present ? showInChannel.value : this.showInChannel, - shadowed: shadowed.present ? shadowed.value : this.shadowed, + shadowed: shadowed ?? this.shadowed, command: command.present ? command.value : this.command, createdAt: createdAt ?? this.createdAt, - updatedAt: updatedAt.present ? updatedAt.value : this.updatedAt, + updatedAt: updatedAt ?? this.updatedAt, deletedAt: deletedAt.present ? deletedAt.value : this.deletedAt, userId: userId.present ? userId.value : this.userId, pinned: pinned ?? this.pinned, @@ -2239,7 +2060,7 @@ class PinnedMessageEntity extends DataClass pinned.hashCode, $mrjc(pinnedAt.hashCode, $mrjc(pinExpires.hashCode, $mrjc(pinnedByUserId.hashCode, $mrjc(channelCid.hashCode, extraData.hashCode)))))))))))))))))))))))); @override - bool operator ==(dynamic other) => + bool operator ==(Object other) => identical(this, other) || (other is PinnedMessageEntity && other.id == this.id && @@ -2270,29 +2091,29 @@ class PinnedMessageEntity extends DataClass class PinnedMessagesCompanion extends UpdateCompanion { final Value id; - final Value messageText; + final Value messageText; final Value> attachments; final Value status; final Value type; final Value> mentionedUsers; - final Value> reactionCounts; - final Value> reactionScores; - final Value parentId; - final Value quotedMessageId; - final Value replyCount; - final Value showInChannel; + final Value?> reactionCounts; + final Value?> reactionScores; + final Value parentId; + final Value quotedMessageId; + final Value replyCount; + final Value showInChannel; final Value shadowed; - final Value command; + final Value command; final Value createdAt; final Value updatedAt; - final Value deletedAt; - final Value userId; + final Value deletedAt; + final Value userId; final Value pinned; - final Value pinnedAt; - final Value pinExpires; - final Value pinnedByUserId; - final Value channelCid; - final Value> extraData; + final Value pinnedAt; + final Value pinExpires; + final Value pinnedByUserId; + final Value channelCid; + final Value?> extraData; const PinnedMessagesCompanion({ this.id = const Value.absent(), this.messageText = const Value.absent(), @@ -2320,12 +2141,12 @@ class PinnedMessagesCompanion extends UpdateCompanion { this.extraData = const Value.absent(), }); PinnedMessagesCompanion.insert({ - @required String id, + required String id, this.messageText = const Value.absent(), - this.attachments = const Value.absent(), + required List attachments, this.status = const Value.absent(), this.type = const Value.absent(), - this.mentionedUsers = const Value.absent(), + required List mentionedUsers, this.reactionCounts = const Value.absent(), this.reactionScores = const Value.absent(), this.parentId = const Value.absent(), @@ -2334,7 +2155,7 @@ class PinnedMessagesCompanion extends UpdateCompanion { this.showInChannel = const Value.absent(), this.shadowed = const Value.absent(), this.command = const Value.absent(), - @required DateTime createdAt, + this.createdAt = const Value.absent(), this.updatedAt = const Value.absent(), this.deletedAt = const Value.absent(), this.userId = const Value.absent(), @@ -2345,32 +2166,33 @@ class PinnedMessagesCompanion extends UpdateCompanion { this.channelCid = const Value.absent(), this.extraData = const Value.absent(), }) : id = Value(id), - createdAt = Value(createdAt); + attachments = Value(attachments), + mentionedUsers = Value(mentionedUsers); static Insertable custom({ - Expression id, - Expression messageText, - Expression attachments, - Expression status, - Expression type, - Expression mentionedUsers, - Expression reactionCounts, - Expression reactionScores, - Expression parentId, - Expression quotedMessageId, - Expression replyCount, - Expression showInChannel, - Expression shadowed, - Expression command, - Expression createdAt, - Expression updatedAt, - Expression deletedAt, - Expression userId, - Expression pinned, - Expression pinnedAt, - Expression pinExpires, - Expression pinnedByUserId, - Expression channelCid, - Expression extraData, + Expression? id, + Expression? messageText, + Expression>? attachments, + Expression? status, + Expression? type, + Expression>? mentionedUsers, + Expression?>? reactionCounts, + Expression?>? reactionScores, + Expression? parentId, + Expression? quotedMessageId, + Expression? replyCount, + Expression? showInChannel, + Expression? shadowed, + Expression? command, + Expression? createdAt, + Expression? updatedAt, + Expression? deletedAt, + Expression? userId, + Expression? pinned, + Expression? pinnedAt, + Expression? pinExpires, + Expression? pinnedByUserId, + Expression? channelCid, + Expression?>? extraData, }) { return RawValuesInsertable({ if (id != null) 'id': id, @@ -2401,30 +2223,30 @@ class PinnedMessagesCompanion extends UpdateCompanion { } PinnedMessagesCompanion copyWith( - {Value id, - Value messageText, - Value> attachments, - Value status, - Value type, - Value> mentionedUsers, - Value> reactionCounts, - Value> reactionScores, - Value parentId, - Value quotedMessageId, - Value replyCount, - Value showInChannel, - Value shadowed, - Value command, - Value createdAt, - Value updatedAt, - Value deletedAt, - Value userId, - Value pinned, - Value pinnedAt, - Value pinExpires, - Value pinnedByUserId, - Value channelCid, - Value> extraData}) { + {Value? id, + Value? messageText, + Value>? attachments, + Value? status, + Value? type, + Value>? mentionedUsers, + Value?>? reactionCounts, + Value?>? reactionScores, + Value? parentId, + Value? quotedMessageId, + Value? replyCount, + Value? showInChannel, + Value? shadowed, + Value? command, + Value? createdAt, + Value? updatedAt, + Value? deletedAt, + Value? userId, + Value? pinned, + Value? pinnedAt, + Value? pinExpires, + Value? pinnedByUserId, + Value? channelCid, + Value?>? extraData}) { return PinnedMessagesCompanion( id: id ?? this.id, messageText: messageText ?? this.messageText, @@ -2460,16 +2282,16 @@ class PinnedMessagesCompanion extends UpdateCompanion { map['id'] = Variable(id.value); } if (messageText.present) { - map['message_text'] = Variable(messageText.value); + map['message_text'] = Variable(messageText.value); } if (attachments.present) { final converter = $PinnedMessagesTable.$converter0; map['attachments'] = - Variable(converter.mapToSql(attachments.value)); + Variable(converter.mapToSql(attachments.value)!); } if (status.present) { final converter = $PinnedMessagesTable.$converter1; - map['status'] = Variable(converter.mapToSql(status.value)); + map['status'] = Variable(converter.mapToSql(status.value)!); } if (type.present) { map['type'] = Variable(type.value); @@ -2477,35 +2299,35 @@ class PinnedMessagesCompanion extends UpdateCompanion { if (mentionedUsers.present) { final converter = $PinnedMessagesTable.$converter2; map['mentioned_users'] = - Variable(converter.mapToSql(mentionedUsers.value)); + Variable(converter.mapToSql(mentionedUsers.value)!); } if (reactionCounts.present) { final converter = $PinnedMessagesTable.$converter3; map['reaction_counts'] = - Variable(converter.mapToSql(reactionCounts.value)); + Variable(converter.mapToSql(reactionCounts.value)); } if (reactionScores.present) { final converter = $PinnedMessagesTable.$converter4; map['reaction_scores'] = - Variable(converter.mapToSql(reactionScores.value)); + Variable(converter.mapToSql(reactionScores.value)); } if (parentId.present) { - map['parent_id'] = Variable(parentId.value); + map['parent_id'] = Variable(parentId.value); } if (quotedMessageId.present) { - map['quoted_message_id'] = Variable(quotedMessageId.value); + map['quoted_message_id'] = Variable(quotedMessageId.value); } if (replyCount.present) { - map['reply_count'] = Variable(replyCount.value); + map['reply_count'] = Variable(replyCount.value); } if (showInChannel.present) { - map['show_in_channel'] = Variable(showInChannel.value); + map['show_in_channel'] = Variable(showInChannel.value); } if (shadowed.present) { map['shadowed'] = Variable(shadowed.value); } if (command.present) { - map['command'] = Variable(command.value); + map['command'] = Variable(command.value); } if (createdAt.present) { map['created_at'] = Variable(createdAt.value); @@ -2514,29 +2336,30 @@ class PinnedMessagesCompanion extends UpdateCompanion { map['updated_at'] = Variable(updatedAt.value); } if (deletedAt.present) { - map['deleted_at'] = Variable(deletedAt.value); + map['deleted_at'] = Variable(deletedAt.value); } if (userId.present) { - map['user_id'] = Variable(userId.value); + map['user_id'] = Variable(userId.value); } if (pinned.present) { map['pinned'] = Variable(pinned.value); } if (pinnedAt.present) { - map['pinned_at'] = Variable(pinnedAt.value); + map['pinned_at'] = Variable(pinnedAt.value); } if (pinExpires.present) { - map['pin_expires'] = Variable(pinExpires.value); + map['pin_expires'] = Variable(pinExpires.value); } if (pinnedByUserId.present) { - map['pinned_by_user_id'] = Variable(pinnedByUserId.value); + map['pinned_by_user_id'] = Variable(pinnedByUserId.value); } if (channelCid.present) { - map['channel_cid'] = Variable(channelCid.value); + map['channel_cid'] = Variable(channelCid.value); } if (extraData.present) { final converter = $PinnedMessagesTable.$converter5; - map['extra_data'] = Variable(converter.mapToSql(extraData.value)); + map['extra_data'] = + Variable(converter.mapToSql(extraData.value)); } return map; } @@ -2576,308 +2399,142 @@ class PinnedMessagesCompanion extends UpdateCompanion { class $PinnedMessagesTable extends PinnedMessages with TableInfo<$PinnedMessagesTable, PinnedMessageEntity> { final GeneratedDatabase _db; - final String _alias; + final String? _alias; $PinnedMessagesTable(this._db, [this._alias]); final VerificationMeta _idMeta = const VerificationMeta('id'); - GeneratedTextColumn _id; - @override - GeneratedTextColumn get id => _id ??= _constructId(); - GeneratedTextColumn _constructId() { - return GeneratedTextColumn( - 'id', - $tableName, - false, - ); - } - + late final GeneratedColumn id = GeneratedColumn( + 'id', aliasedName, false, + typeName: 'TEXT', requiredDuringInsert: true); final VerificationMeta _messageTextMeta = const VerificationMeta('messageText'); - GeneratedTextColumn _messageText; - @override - GeneratedTextColumn get messageText => - _messageText ??= _constructMessageText(); - GeneratedTextColumn _constructMessageText() { - return GeneratedTextColumn( - 'message_text', - $tableName, - true, - ); - } - + late final GeneratedColumn messageText = GeneratedColumn( + 'message_text', aliasedName, true, + typeName: 'TEXT', requiredDuringInsert: false); final VerificationMeta _attachmentsMeta = const VerificationMeta('attachments'); - GeneratedTextColumn _attachments; - @override - GeneratedTextColumn get attachments => - _attachments ??= _constructAttachments(); - GeneratedTextColumn _constructAttachments() { - return GeneratedTextColumn( - 'attachments', - $tableName, - true, - ); - } - + late final GeneratedColumnWithTypeConverter, String?> + attachments = GeneratedColumn('attachments', aliasedName, false, + typeName: 'TEXT', requiredDuringInsert: true) + .withConverter>($PinnedMessagesTable.$converter0); final VerificationMeta _statusMeta = const VerificationMeta('status'); - GeneratedIntColumn _status; - @override - GeneratedIntColumn get status => _status ??= _constructStatus(); - GeneratedIntColumn _constructStatus() { - return GeneratedIntColumn( - 'status', - $tableName, - true, - ); - } - + late final GeneratedColumnWithTypeConverter + status = GeneratedColumn('status', aliasedName, false, + typeName: 'INTEGER', + requiredDuringInsert: false, + defaultValue: const Constant(1)) + .withConverter( + $PinnedMessagesTable.$converter1); final VerificationMeta _typeMeta = const VerificationMeta('type'); - GeneratedTextColumn _type; - @override - GeneratedTextColumn get type => _type ??= _constructType(); - GeneratedTextColumn _constructType() { - return GeneratedTextColumn( - 'type', - $tableName, - true, - ); - } - + late final GeneratedColumn type = GeneratedColumn( + 'type', aliasedName, false, + typeName: 'TEXT', + requiredDuringInsert: false, + defaultValue: const Constant('regular')); final VerificationMeta _mentionedUsersMeta = const VerificationMeta('mentionedUsers'); - GeneratedTextColumn _mentionedUsers; - @override - GeneratedTextColumn get mentionedUsers => - _mentionedUsers ??= _constructMentionedUsers(); - GeneratedTextColumn _constructMentionedUsers() { - return GeneratedTextColumn( - 'mentioned_users', - $tableName, - true, - ); - } - + late final GeneratedColumnWithTypeConverter, String?> + mentionedUsers = GeneratedColumn( + 'mentioned_users', aliasedName, false, + typeName: 'TEXT', requiredDuringInsert: true) + .withConverter>($PinnedMessagesTable.$converter2); final VerificationMeta _reactionCountsMeta = const VerificationMeta('reactionCounts'); - GeneratedTextColumn _reactionCounts; - @override - GeneratedTextColumn get reactionCounts => - _reactionCounts ??= _constructReactionCounts(); - GeneratedTextColumn _constructReactionCounts() { - return GeneratedTextColumn( - 'reaction_counts', - $tableName, - true, - ); - } - + late final GeneratedColumnWithTypeConverter, String?> + reactionCounts = GeneratedColumn( + 'reaction_counts', aliasedName, true, + typeName: 'TEXT', requiredDuringInsert: false) + .withConverter>($PinnedMessagesTable.$converter3); final VerificationMeta _reactionScoresMeta = const VerificationMeta('reactionScores'); - GeneratedTextColumn _reactionScores; - @override - GeneratedTextColumn get reactionScores => - _reactionScores ??= _constructReactionScores(); - GeneratedTextColumn _constructReactionScores() { - return GeneratedTextColumn( - 'reaction_scores', - $tableName, - true, - ); - } - + late final GeneratedColumnWithTypeConverter, String?> + reactionScores = GeneratedColumn( + 'reaction_scores', aliasedName, true, + typeName: 'TEXT', requiredDuringInsert: false) + .withConverter>($PinnedMessagesTable.$converter4); final VerificationMeta _parentIdMeta = const VerificationMeta('parentId'); - GeneratedTextColumn _parentId; - @override - GeneratedTextColumn get parentId => _parentId ??= _constructParentId(); - GeneratedTextColumn _constructParentId() { - return GeneratedTextColumn( - 'parent_id', - $tableName, - true, - ); - } - + late final GeneratedColumn parentId = GeneratedColumn( + 'parent_id', aliasedName, true, + typeName: 'TEXT', requiredDuringInsert: false); final VerificationMeta _quotedMessageIdMeta = const VerificationMeta('quotedMessageId'); - GeneratedTextColumn _quotedMessageId; - @override - GeneratedTextColumn get quotedMessageId => - _quotedMessageId ??= _constructQuotedMessageId(); - GeneratedTextColumn _constructQuotedMessageId() { - return GeneratedTextColumn( - 'quoted_message_id', - $tableName, - true, - ); - } - + late final GeneratedColumn quotedMessageId = + GeneratedColumn('quoted_message_id', aliasedName, true, + typeName: 'TEXT', requiredDuringInsert: false); final VerificationMeta _replyCountMeta = const VerificationMeta('replyCount'); - GeneratedIntColumn _replyCount; - @override - GeneratedIntColumn get replyCount => _replyCount ??= _constructReplyCount(); - GeneratedIntColumn _constructReplyCount() { - return GeneratedIntColumn( - 'reply_count', - $tableName, - true, - ); - } - + late final GeneratedColumn replyCount = GeneratedColumn( + 'reply_count', aliasedName, true, + typeName: 'INTEGER', requiredDuringInsert: false); final VerificationMeta _showInChannelMeta = const VerificationMeta('showInChannel'); - GeneratedBoolColumn _showInChannel; - @override - GeneratedBoolColumn get showInChannel => - _showInChannel ??= _constructShowInChannel(); - GeneratedBoolColumn _constructShowInChannel() { - return GeneratedBoolColumn( - 'show_in_channel', - $tableName, - true, - ); - } - + late final GeneratedColumn showInChannel = GeneratedColumn( + 'show_in_channel', aliasedName, true, + typeName: 'INTEGER', + requiredDuringInsert: false, + defaultConstraints: 'CHECK (show_in_channel IN (0, 1))'); final VerificationMeta _shadowedMeta = const VerificationMeta('shadowed'); - GeneratedBoolColumn _shadowed; - @override - GeneratedBoolColumn get shadowed => _shadowed ??= _constructShadowed(); - GeneratedBoolColumn _constructShadowed() { - return GeneratedBoolColumn( - 'shadowed', - $tableName, - true, - ); - } - + late final GeneratedColumn shadowed = GeneratedColumn( + 'shadowed', aliasedName, false, + typeName: 'INTEGER', + requiredDuringInsert: false, + defaultConstraints: 'CHECK (shadowed IN (0, 1))', + defaultValue: const Constant(false)); final VerificationMeta _commandMeta = const VerificationMeta('command'); - GeneratedTextColumn _command; - @override - GeneratedTextColumn get command => _command ??= _constructCommand(); - GeneratedTextColumn _constructCommand() { - return GeneratedTextColumn( - 'command', - $tableName, - true, - ); - } - + late final GeneratedColumn command = GeneratedColumn( + 'command', aliasedName, true, + typeName: 'TEXT', requiredDuringInsert: false); final VerificationMeta _createdAtMeta = const VerificationMeta('createdAt'); - GeneratedDateTimeColumn _createdAt; - @override - GeneratedDateTimeColumn get createdAt => _createdAt ??= _constructCreatedAt(); - GeneratedDateTimeColumn _constructCreatedAt() { - return GeneratedDateTimeColumn( - 'created_at', - $tableName, - false, - ); - } - + late final GeneratedColumn createdAt = GeneratedColumn( + 'created_at', aliasedName, false, + typeName: 'INTEGER', + requiredDuringInsert: false, + defaultValue: currentDateAndTime); final VerificationMeta _updatedAtMeta = const VerificationMeta('updatedAt'); - GeneratedDateTimeColumn _updatedAt; - @override - GeneratedDateTimeColumn get updatedAt => _updatedAt ??= _constructUpdatedAt(); - GeneratedDateTimeColumn _constructUpdatedAt() { - return GeneratedDateTimeColumn( - 'updated_at', - $tableName, - true, - ); - } - + late final GeneratedColumn updatedAt = GeneratedColumn( + 'updated_at', aliasedName, false, + typeName: 'INTEGER', + requiredDuringInsert: false, + defaultValue: currentDateAndTime); final VerificationMeta _deletedAtMeta = const VerificationMeta('deletedAt'); - GeneratedDateTimeColumn _deletedAt; - @override - GeneratedDateTimeColumn get deletedAt => _deletedAt ??= _constructDeletedAt(); - GeneratedDateTimeColumn _constructDeletedAt() { - return GeneratedDateTimeColumn( - 'deleted_at', - $tableName, - true, - ); - } - + late final GeneratedColumn deletedAt = GeneratedColumn( + 'deleted_at', aliasedName, true, + typeName: 'INTEGER', requiredDuringInsert: false); final VerificationMeta _userIdMeta = const VerificationMeta('userId'); - GeneratedTextColumn _userId; - @override - GeneratedTextColumn get userId => _userId ??= _constructUserId(); - GeneratedTextColumn _constructUserId() { - return GeneratedTextColumn( - 'user_id', - $tableName, - true, - ); - } - + late final GeneratedColumn userId = GeneratedColumn( + 'user_id', aliasedName, true, + typeName: 'TEXT', requiredDuringInsert: false); final VerificationMeta _pinnedMeta = const VerificationMeta('pinned'); - GeneratedBoolColumn _pinned; - @override - GeneratedBoolColumn get pinned => _pinned ??= _constructPinned(); - GeneratedBoolColumn _constructPinned() { - return GeneratedBoolColumn('pinned', $tableName, false, - defaultValue: const Constant(false)); - } - + late final GeneratedColumn pinned = GeneratedColumn( + 'pinned', aliasedName, false, + typeName: 'INTEGER', + requiredDuringInsert: false, + defaultConstraints: 'CHECK (pinned IN (0, 1))', + defaultValue: const Constant(false)); final VerificationMeta _pinnedAtMeta = const VerificationMeta('pinnedAt'); - GeneratedDateTimeColumn _pinnedAt; - @override - GeneratedDateTimeColumn get pinnedAt => _pinnedAt ??= _constructPinnedAt(); - GeneratedDateTimeColumn _constructPinnedAt() { - return GeneratedDateTimeColumn( - 'pinned_at', - $tableName, - true, - ); - } - + late final GeneratedColumn pinnedAt = GeneratedColumn( + 'pinned_at', aliasedName, true, + typeName: 'INTEGER', requiredDuringInsert: false); final VerificationMeta _pinExpiresMeta = const VerificationMeta('pinExpires'); - GeneratedDateTimeColumn _pinExpires; - @override - GeneratedDateTimeColumn get pinExpires => - _pinExpires ??= _constructPinExpires(); - GeneratedDateTimeColumn _constructPinExpires() { - return GeneratedDateTimeColumn( - 'pin_expires', - $tableName, - true, - ); - } - + late final GeneratedColumn pinExpires = GeneratedColumn( + 'pin_expires', aliasedName, true, + typeName: 'INTEGER', requiredDuringInsert: false); final VerificationMeta _pinnedByUserIdMeta = const VerificationMeta('pinnedByUserId'); - GeneratedTextColumn _pinnedByUserId; - @override - GeneratedTextColumn get pinnedByUserId => - _pinnedByUserId ??= _constructPinnedByUserId(); - GeneratedTextColumn _constructPinnedByUserId() { - return GeneratedTextColumn( - 'pinned_by_user_id', - $tableName, - true, - ); - } - + late final GeneratedColumn pinnedByUserId = GeneratedColumn( + 'pinned_by_user_id', aliasedName, true, + typeName: 'TEXT', requiredDuringInsert: false); final VerificationMeta _channelCidMeta = const VerificationMeta('channelCid'); - GeneratedTextColumn _channelCid; - @override - GeneratedTextColumn get channelCid => _channelCid ??= _constructChannelCid(); - GeneratedTextColumn _constructChannelCid() { - return GeneratedTextColumn('channel_cid', $tableName, true, - $customConstraints: - 'NULLABLE REFERENCES channels(cid) ON DELETE CASCADE'); - } - + late final GeneratedColumn channelCid = GeneratedColumn( + 'channel_cid', aliasedName, true, + typeName: 'TEXT', + requiredDuringInsert: false, + $customConstraints: + 'NULLABLE REFERENCES channels(cid) ON DELETE CASCADE'); final VerificationMeta _extraDataMeta = const VerificationMeta('extraData'); - GeneratedTextColumn _extraData; - @override - GeneratedTextColumn get extraData => _extraData ??= _constructExtraData(); - GeneratedTextColumn _constructExtraData() { - return GeneratedTextColumn( - 'extra_data', - $tableName, - true, - ); - } - + late final GeneratedColumnWithTypeConverter, String?> + extraData = GeneratedColumn('extra_data', aliasedName, true, + typeName: 'TEXT', requiredDuringInsert: false) + .withConverter>( + $PinnedMessagesTable.$converter5); @override List get $columns => [ id, @@ -2906,11 +2563,9 @@ class $PinnedMessagesTable extends PinnedMessages extraData ]; @override - $PinnedMessagesTable get asDslTable => this; + String get aliasedName => _alias ?? 'pinned_messages'; @override - String get $tableName => _alias ?? 'pinned_messages'; - @override - final String actualTableName = 'pinned_messages'; + String get actualTableName => 'pinned_messages'; @override VerificationContext validateIntegrity( Insertable instance, @@ -2918,7 +2573,7 @@ class $PinnedMessagesTable extends PinnedMessages final context = VerificationContext(); final data = instance.toColumns(true); if (data.containsKey('id')) { - context.handle(_idMeta, id.isAcceptableOrUnknown(data['id'], _idMeta)); + context.handle(_idMeta, id.isAcceptableOrUnknown(data['id']!, _idMeta)); } else if (isInserting) { context.missing(_idMeta); } @@ -2926,90 +2581,88 @@ class $PinnedMessagesTable extends PinnedMessages context.handle( _messageTextMeta, messageText.isAcceptableOrUnknown( - data['message_text'], _messageTextMeta)); + data['message_text']!, _messageTextMeta)); } context.handle(_attachmentsMeta, const VerificationResult.success()); context.handle(_statusMeta, const VerificationResult.success()); if (data.containsKey('type')) { context.handle( - _typeMeta, type.isAcceptableOrUnknown(data['type'], _typeMeta)); + _typeMeta, type.isAcceptableOrUnknown(data['type']!, _typeMeta)); } context.handle(_mentionedUsersMeta, const VerificationResult.success()); context.handle(_reactionCountsMeta, const VerificationResult.success()); context.handle(_reactionScoresMeta, const VerificationResult.success()); if (data.containsKey('parent_id')) { context.handle(_parentIdMeta, - parentId.isAcceptableOrUnknown(data['parent_id'], _parentIdMeta)); + parentId.isAcceptableOrUnknown(data['parent_id']!, _parentIdMeta)); } if (data.containsKey('quoted_message_id')) { context.handle( _quotedMessageIdMeta, quotedMessageId.isAcceptableOrUnknown( - data['quoted_message_id'], _quotedMessageIdMeta)); + data['quoted_message_id']!, _quotedMessageIdMeta)); } if (data.containsKey('reply_count')) { context.handle( _replyCountMeta, replyCount.isAcceptableOrUnknown( - data['reply_count'], _replyCountMeta)); + data['reply_count']!, _replyCountMeta)); } if (data.containsKey('show_in_channel')) { context.handle( _showInChannelMeta, showInChannel.isAcceptableOrUnknown( - data['show_in_channel'], _showInChannelMeta)); + data['show_in_channel']!, _showInChannelMeta)); } if (data.containsKey('shadowed')) { context.handle(_shadowedMeta, - shadowed.isAcceptableOrUnknown(data['shadowed'], _shadowedMeta)); + shadowed.isAcceptableOrUnknown(data['shadowed']!, _shadowedMeta)); } if (data.containsKey('command')) { context.handle(_commandMeta, - command.isAcceptableOrUnknown(data['command'], _commandMeta)); + command.isAcceptableOrUnknown(data['command']!, _commandMeta)); } if (data.containsKey('created_at')) { context.handle(_createdAtMeta, - createdAt.isAcceptableOrUnknown(data['created_at'], _createdAtMeta)); - } else if (isInserting) { - context.missing(_createdAtMeta); + createdAt.isAcceptableOrUnknown(data['created_at']!, _createdAtMeta)); } if (data.containsKey('updated_at')) { context.handle(_updatedAtMeta, - updatedAt.isAcceptableOrUnknown(data['updated_at'], _updatedAtMeta)); + updatedAt.isAcceptableOrUnknown(data['updated_at']!, _updatedAtMeta)); } if (data.containsKey('deleted_at')) { context.handle(_deletedAtMeta, - deletedAt.isAcceptableOrUnknown(data['deleted_at'], _deletedAtMeta)); + deletedAt.isAcceptableOrUnknown(data['deleted_at']!, _deletedAtMeta)); } if (data.containsKey('user_id')) { context.handle(_userIdMeta, - userId.isAcceptableOrUnknown(data['user_id'], _userIdMeta)); + userId.isAcceptableOrUnknown(data['user_id']!, _userIdMeta)); } if (data.containsKey('pinned')) { context.handle(_pinnedMeta, - pinned.isAcceptableOrUnknown(data['pinned'], _pinnedMeta)); + pinned.isAcceptableOrUnknown(data['pinned']!, _pinnedMeta)); } if (data.containsKey('pinned_at')) { context.handle(_pinnedAtMeta, - pinnedAt.isAcceptableOrUnknown(data['pinned_at'], _pinnedAtMeta)); + pinnedAt.isAcceptableOrUnknown(data['pinned_at']!, _pinnedAtMeta)); } if (data.containsKey('pin_expires')) { context.handle( _pinExpiresMeta, pinExpires.isAcceptableOrUnknown( - data['pin_expires'], _pinExpiresMeta)); + data['pin_expires']!, _pinExpiresMeta)); } if (data.containsKey('pinned_by_user_id')) { context.handle( _pinnedByUserIdMeta, pinnedByUserId.isAcceptableOrUnknown( - data['pinned_by_user_id'], _pinnedByUserIdMeta)); + data['pinned_by_user_id']!, _pinnedByUserIdMeta)); } if (data.containsKey('channel_cid')) { context.handle( _channelCidMeta, channelCid.isAcceptableOrUnknown( - data['channel_cid'], _channelCidMeta)); + data['channel_cid']!, _channelCidMeta)); } context.handle(_extraDataMeta, const VerificationResult.success()); return context; @@ -3018,9 +2671,9 @@ class $PinnedMessagesTable extends PinnedMessages @override Set get $primaryKey => {id}; @override - PinnedMessageEntity map(Map data, {String tablePrefix}) { - final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : null; - return PinnedMessageEntity.fromData(data, _db, prefix: effectivePrefix); + PinnedMessageEntity map(Map data, {String? tablePrefix}) { + return PinnedMessageEntity.fromData(data, _db, + prefix: tablePrefix != null ? '$tablePrefix.' : null); } @override @@ -3038,71 +2691,71 @@ class $PinnedMessagesTable extends PinnedMessages MapConverter(); static TypeConverter, String> $converter4 = MapConverter(); - static TypeConverter, String> $converter5 = - MapConverter(); + static TypeConverter, String> $converter5 = + MapConverter(); } class ReactionEntity extends DataClass implements Insertable { + /// The id of the user that sent the reaction final String userId; + + /// The messageId to which the reaction belongs final String messageId; + + /// The type of the reaction final String type; + + /// The DateTime on which the reaction is created final DateTime createdAt; + + /// The score of the reaction (ie. number of reactions sent) final int score; - final Map extraData; + + /// Reaction custom extraData + final Map? extraData; ReactionEntity( - {@required this.userId, - @required this.messageId, - @required this.type, - @required this.createdAt, - this.score, + {required this.userId, + required this.messageId, + required this.type, + required this.createdAt, + required this.score, this.extraData}); factory ReactionEntity.fromData( Map data, GeneratedDatabase db, - {String prefix}) { + {String? prefix}) { final effectivePrefix = prefix ?? ''; - final stringType = db.typeSystem.forDartType(); - final dateTimeType = db.typeSystem.forDartType(); - final intType = db.typeSystem.forDartType(); return ReactionEntity( - userId: - stringType.mapFromDatabaseResponse(data['${effectivePrefix}user_id']), - messageId: stringType - .mapFromDatabaseResponse(data['${effectivePrefix}message_id']), - type: stringType.mapFromDatabaseResponse(data['${effectivePrefix}type']), - createdAt: dateTimeType - .mapFromDatabaseResponse(data['${effectivePrefix}created_at']), - score: intType.mapFromDatabaseResponse(data['${effectivePrefix}score']), - extraData: $ReactionsTable.$converter0.mapToDart(stringType + userId: const StringType() + .mapFromDatabaseResponse(data['${effectivePrefix}user_id'])!, + messageId: const StringType() + .mapFromDatabaseResponse(data['${effectivePrefix}message_id'])!, + type: const StringType() + .mapFromDatabaseResponse(data['${effectivePrefix}type'])!, + createdAt: const DateTimeType() + .mapFromDatabaseResponse(data['${effectivePrefix}created_at'])!, + score: const IntType() + .mapFromDatabaseResponse(data['${effectivePrefix}score'])!, + extraData: $ReactionsTable.$converter0.mapToDart(const StringType() .mapFromDatabaseResponse(data['${effectivePrefix}extra_data'])), ); } @override Map toColumns(bool nullToAbsent) { final map = {}; - if (!nullToAbsent || userId != null) { - map['user_id'] = Variable(userId); - } - if (!nullToAbsent || messageId != null) { - map['message_id'] = Variable(messageId); - } - if (!nullToAbsent || type != null) { - map['type'] = Variable(type); - } - if (!nullToAbsent || createdAt != null) { - map['created_at'] = Variable(createdAt); - } - if (!nullToAbsent || score != null) { - map['score'] = Variable(score); - } + map['user_id'] = Variable(userId); + map['message_id'] = Variable(messageId); + map['type'] = Variable(type); + map['created_at'] = Variable(createdAt); + map['score'] = Variable(score); if (!nullToAbsent || extraData != null) { final converter = $ReactionsTable.$converter0; - map['extra_data'] = Variable(converter.mapToSql(extraData)); + map['extra_data'] = Variable(converter.mapToSql(extraData)); } return map; } factory ReactionEntity.fromJson(Map json, - {ValueSerializer serializer}) { + {ValueSerializer? serializer}) { serializer ??= moorRuntimeOptions.defaultSerializer; return ReactionEntity( userId: serializer.fromJson(json['userId']), @@ -3110,11 +2763,11 @@ class ReactionEntity extends DataClass implements Insertable { type: serializer.fromJson(json['type']), createdAt: serializer.fromJson(json['createdAt']), score: serializer.fromJson(json['score']), - extraData: serializer.fromJson>(json['extraData']), + extraData: serializer.fromJson?>(json['extraData']), ); } @override - Map toJson({ValueSerializer serializer}) { + Map toJson({ValueSerializer? serializer}) { serializer ??= moorRuntimeOptions.defaultSerializer; return { 'userId': serializer.toJson(userId), @@ -3122,23 +2775,23 @@ class ReactionEntity extends DataClass implements Insertable { 'type': serializer.toJson(type), 'createdAt': serializer.toJson(createdAt), 'score': serializer.toJson(score), - 'extraData': serializer.toJson>(extraData), + 'extraData': serializer.toJson?>(extraData), }; } ReactionEntity copyWith( - {String userId, - String messageId, - String type, - DateTime createdAt, - Value score = const Value.absent(), - Value> extraData = const Value.absent()}) => + {String? userId, + String? messageId, + String? type, + DateTime? createdAt, + int? score, + Value?> extraData = const Value.absent()}) => ReactionEntity( userId: userId ?? this.userId, messageId: messageId ?? this.messageId, type: type ?? this.type, createdAt: createdAt ?? this.createdAt, - score: score.present ? score.value : this.score, + score: score ?? this.score, extraData: extraData.present ? extraData.value : this.extraData, ); @override @@ -3164,7 +2817,7 @@ class ReactionEntity extends DataClass implements Insertable { $mrjc(createdAt.hashCode, $mrjc(score.hashCode, extraData.hashCode)))))); @override - bool operator ==(dynamic other) => + bool operator ==(Object other) => identical(this, other) || (other is ReactionEntity && other.userId == this.userId && @@ -3181,7 +2834,7 @@ class ReactionsCompanion extends UpdateCompanion { final Value type; final Value createdAt; final Value score; - final Value> extraData; + final Value?> extraData; const ReactionsCompanion({ this.userId = const Value.absent(), this.messageId = const Value.absent(), @@ -3191,23 +2844,22 @@ class ReactionsCompanion extends UpdateCompanion { this.extraData = const Value.absent(), }); ReactionsCompanion.insert({ - @required String userId, - @required String messageId, - @required String type, - @required DateTime createdAt, + required String userId, + required String messageId, + required String type, + this.createdAt = const Value.absent(), this.score = const Value.absent(), this.extraData = const Value.absent(), }) : userId = Value(userId), messageId = Value(messageId), - type = Value(type), - createdAt = Value(createdAt); + type = Value(type); static Insertable custom({ - Expression userId, - Expression messageId, - Expression type, - Expression createdAt, - Expression score, - Expression extraData, + Expression? userId, + Expression? messageId, + Expression? type, + Expression? createdAt, + Expression? score, + Expression?>? extraData, }) { return RawValuesInsertable({ if (userId != null) 'user_id': userId, @@ -3220,12 +2872,12 @@ class ReactionsCompanion extends UpdateCompanion { } ReactionsCompanion copyWith( - {Value userId, - Value messageId, - Value type, - Value createdAt, - Value score, - Value> extraData}) { + {Value? userId, + Value? messageId, + Value? type, + Value? createdAt, + Value? score, + Value?>? extraData}) { return ReactionsCompanion( userId: userId ?? this.userId, messageId: messageId ?? this.messageId, @@ -3256,7 +2908,8 @@ class ReactionsCompanion extends UpdateCompanion { } if (extraData.present) { final converter = $ReactionsTable.$converter0; - map['extra_data'] = Variable(converter.mapToSql(extraData.value)); + map['extra_data'] = + Variable(converter.mapToSql(extraData.value)); } return map; } @@ -3278,86 +2931,46 @@ class ReactionsCompanion extends UpdateCompanion { class $ReactionsTable extends Reactions with TableInfo<$ReactionsTable, ReactionEntity> { final GeneratedDatabase _db; - final String _alias; + final String? _alias; $ReactionsTable(this._db, [this._alias]); final VerificationMeta _userIdMeta = const VerificationMeta('userId'); - GeneratedTextColumn _userId; - @override - GeneratedTextColumn get userId => _userId ??= _constructUserId(); - GeneratedTextColumn _constructUserId() { - return GeneratedTextColumn( - 'user_id', - $tableName, - false, - ); - } - + late final GeneratedColumn userId = GeneratedColumn( + 'user_id', aliasedName, false, + typeName: 'TEXT', requiredDuringInsert: true); final VerificationMeta _messageIdMeta = const VerificationMeta('messageId'); - GeneratedTextColumn _messageId; - @override - GeneratedTextColumn get messageId => _messageId ??= _constructMessageId(); - GeneratedTextColumn _constructMessageId() { - return GeneratedTextColumn('message_id', $tableName, false, - $customConstraints: 'REFERENCES messages(id) ON DELETE CASCADE'); - } - + late final GeneratedColumn messageId = GeneratedColumn( + 'message_id', aliasedName, false, + typeName: 'TEXT', + requiredDuringInsert: true, + $customConstraints: 'REFERENCES messages(id) ON DELETE CASCADE'); final VerificationMeta _typeMeta = const VerificationMeta('type'); - GeneratedTextColumn _type; - @override - GeneratedTextColumn get type => _type ??= _constructType(); - GeneratedTextColumn _constructType() { - return GeneratedTextColumn( - 'type', - $tableName, - false, - ); - } - + late final GeneratedColumn type = GeneratedColumn( + 'type', aliasedName, false, + typeName: 'TEXT', requiredDuringInsert: true); final VerificationMeta _createdAtMeta = const VerificationMeta('createdAt'); - GeneratedDateTimeColumn _createdAt; - @override - GeneratedDateTimeColumn get createdAt => _createdAt ??= _constructCreatedAt(); - GeneratedDateTimeColumn _constructCreatedAt() { - return GeneratedDateTimeColumn( - 'created_at', - $tableName, - false, - ); - } - + late final GeneratedColumn createdAt = GeneratedColumn( + 'created_at', aliasedName, false, + typeName: 'INTEGER', + requiredDuringInsert: false, + defaultValue: currentDateAndTime); final VerificationMeta _scoreMeta = const VerificationMeta('score'); - GeneratedIntColumn _score; - @override - GeneratedIntColumn get score => _score ??= _constructScore(); - GeneratedIntColumn _constructScore() { - return GeneratedIntColumn( - 'score', - $tableName, - true, - ); - } - + late final GeneratedColumn score = GeneratedColumn( + 'score', aliasedName, false, + typeName: 'INTEGER', + requiredDuringInsert: false, + defaultValue: const Constant(0)); final VerificationMeta _extraDataMeta = const VerificationMeta('extraData'); - GeneratedTextColumn _extraData; - @override - GeneratedTextColumn get extraData => _extraData ??= _constructExtraData(); - GeneratedTextColumn _constructExtraData() { - return GeneratedTextColumn( - 'extra_data', - $tableName, - true, - ); - } - + late final GeneratedColumnWithTypeConverter, String?> + extraData = GeneratedColumn('extra_data', aliasedName, true, + typeName: 'TEXT', requiredDuringInsert: false) + .withConverter>($ReactionsTable.$converter0); @override List get $columns => [userId, messageId, type, createdAt, score, extraData]; @override - $ReactionsTable get asDslTable => this; + String get aliasedName => _alias ?? 'reactions'; @override - String get $tableName => _alias ?? 'reactions'; - @override - final String actualTableName = 'reactions'; + String get actualTableName => 'reactions'; @override VerificationContext validateIntegrity(Insertable instance, {bool isInserting = false}) { @@ -3365,31 +2978,29 @@ class $ReactionsTable extends Reactions final data = instance.toColumns(true); if (data.containsKey('user_id')) { context.handle(_userIdMeta, - userId.isAcceptableOrUnknown(data['user_id'], _userIdMeta)); + userId.isAcceptableOrUnknown(data['user_id']!, _userIdMeta)); } else if (isInserting) { context.missing(_userIdMeta); } if (data.containsKey('message_id')) { context.handle(_messageIdMeta, - messageId.isAcceptableOrUnknown(data['message_id'], _messageIdMeta)); + messageId.isAcceptableOrUnknown(data['message_id']!, _messageIdMeta)); } else if (isInserting) { context.missing(_messageIdMeta); } if (data.containsKey('type')) { context.handle( - _typeMeta, type.isAcceptableOrUnknown(data['type'], _typeMeta)); + _typeMeta, type.isAcceptableOrUnknown(data['type']!, _typeMeta)); } else if (isInserting) { context.missing(_typeMeta); } if (data.containsKey('created_at')) { context.handle(_createdAtMeta, - createdAt.isAcceptableOrUnknown(data['created_at'], _createdAtMeta)); - } else if (isInserting) { - context.missing(_createdAtMeta); + createdAt.isAcceptableOrUnknown(data['created_at']!, _createdAtMeta)); } if (data.containsKey('score')) { context.handle( - _scoreMeta, score.isAcceptableOrUnknown(data['score'], _scoreMeta)); + _scoreMeta, score.isAcceptableOrUnknown(data['score']!, _scoreMeta)); } context.handle(_extraDataMeta, const VerificationResult.success()); return context; @@ -3398,9 +3009,9 @@ class $ReactionsTable extends Reactions @override Set get $primaryKey => {messageId, type, userId}; @override - ReactionEntity map(Map data, {String tablePrefix}) { - final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : null; - return ReactionEntity.fromData(data, _db, prefix: effectivePrefix); + ReactionEntity map(Map data, {String? tablePrefix}) { + return ReactionEntity.fromData(data, _db, + prefix: tablePrefix != null ? '$tablePrefix.' : null); } @override @@ -3408,129 +3019,133 @@ class $ReactionsTable extends Reactions return $ReactionsTable(_db, alias); } - static TypeConverter, String> $converter0 = - MapConverter(); + static TypeConverter, String> $converter0 = + MapConverter(); } class UserEntity extends DataClass implements Insertable { + /// User id final String id; - final String role; + + /// User role + final String? role; + + /// Date of user creation final DateTime createdAt; + + /// Date of last user update final DateTime updatedAt; - final DateTime lastActive; + + /// Date of last user connection + final DateTime? lastActive; + + /// True if user is online final bool online; + + /// True if user is banned from the chat final bool banned; - final Map extraData; + + /// Map of custom user extraData + final Map extraData; UserEntity( - {@required this.id, + {required this.id, this.role, - this.createdAt, - this.updatedAt, + required this.createdAt, + required this.updatedAt, this.lastActive, - this.online, - this.banned, - this.extraData}); + required this.online, + required this.banned, + required this.extraData}); factory UserEntity.fromData(Map data, GeneratedDatabase db, - {String prefix}) { + {String? prefix}) { final effectivePrefix = prefix ?? ''; - final stringType = db.typeSystem.forDartType(); - final dateTimeType = db.typeSystem.forDartType(); - final boolType = db.typeSystem.forDartType(); return UserEntity( - id: stringType.mapFromDatabaseResponse(data['${effectivePrefix}id']), - role: stringType.mapFromDatabaseResponse(data['${effectivePrefix}role']), - createdAt: dateTimeType - .mapFromDatabaseResponse(data['${effectivePrefix}created_at']), - updatedAt: dateTimeType - .mapFromDatabaseResponse(data['${effectivePrefix}updated_at']), - lastActive: dateTimeType + id: const StringType() + .mapFromDatabaseResponse(data['${effectivePrefix}id'])!, + role: const StringType() + .mapFromDatabaseResponse(data['${effectivePrefix}role']), + createdAt: const DateTimeType() + .mapFromDatabaseResponse(data['${effectivePrefix}created_at'])!, + updatedAt: const DateTimeType() + .mapFromDatabaseResponse(data['${effectivePrefix}updated_at'])!, + lastActive: const DateTimeType() .mapFromDatabaseResponse(data['${effectivePrefix}last_active']), - online: - boolType.mapFromDatabaseResponse(data['${effectivePrefix}online']), - banned: - boolType.mapFromDatabaseResponse(data['${effectivePrefix}banned']), - extraData: $UsersTable.$converter0.mapToDart(stringType - .mapFromDatabaseResponse(data['${effectivePrefix}extra_data'])), + online: const BoolType() + .mapFromDatabaseResponse(data['${effectivePrefix}online'])!, + banned: const BoolType() + .mapFromDatabaseResponse(data['${effectivePrefix}banned'])!, + extraData: $UsersTable.$converter0.mapToDart(const StringType() + .mapFromDatabaseResponse(data['${effectivePrefix}extra_data']))!, ); } @override Map toColumns(bool nullToAbsent) { final map = {}; - if (!nullToAbsent || id != null) { - map['id'] = Variable(id); - } + map['id'] = Variable(id); if (!nullToAbsent || role != null) { - map['role'] = Variable(role); - } - if (!nullToAbsent || createdAt != null) { - map['created_at'] = Variable(createdAt); - } - if (!nullToAbsent || updatedAt != null) { - map['updated_at'] = Variable(updatedAt); + map['role'] = Variable(role); } + map['created_at'] = Variable(createdAt); + map['updated_at'] = Variable(updatedAt); if (!nullToAbsent || lastActive != null) { - map['last_active'] = Variable(lastActive); + map['last_active'] = Variable(lastActive); } - if (!nullToAbsent || online != null) { - map['online'] = Variable(online); - } - if (!nullToAbsent || banned != null) { - map['banned'] = Variable(banned); - } - if (!nullToAbsent || extraData != null) { + map['online'] = Variable(online); + map['banned'] = Variable(banned); + { final converter = $UsersTable.$converter0; - map['extra_data'] = Variable(converter.mapToSql(extraData)); + map['extra_data'] = Variable(converter.mapToSql(extraData)!); } return map; } factory UserEntity.fromJson(Map json, - {ValueSerializer serializer}) { + {ValueSerializer? serializer}) { serializer ??= moorRuntimeOptions.defaultSerializer; return UserEntity( id: serializer.fromJson(json['id']), - role: serializer.fromJson(json['role']), + role: serializer.fromJson(json['role']), createdAt: serializer.fromJson(json['createdAt']), updatedAt: serializer.fromJson(json['updatedAt']), - lastActive: serializer.fromJson(json['lastActive']), + lastActive: serializer.fromJson(json['lastActive']), online: serializer.fromJson(json['online']), banned: serializer.fromJson(json['banned']), - extraData: serializer.fromJson>(json['extraData']), + extraData: serializer.fromJson>(json['extraData']), ); } @override - Map toJson({ValueSerializer serializer}) { + Map toJson({ValueSerializer? serializer}) { serializer ??= moorRuntimeOptions.defaultSerializer; return { 'id': serializer.toJson(id), - 'role': serializer.toJson(role), + 'role': serializer.toJson(role), 'createdAt': serializer.toJson(createdAt), 'updatedAt': serializer.toJson(updatedAt), - 'lastActive': serializer.toJson(lastActive), + 'lastActive': serializer.toJson(lastActive), 'online': serializer.toJson(online), 'banned': serializer.toJson(banned), - 'extraData': serializer.toJson>(extraData), + 'extraData': serializer.toJson>(extraData), }; } UserEntity copyWith( - {String id, - Value role = const Value.absent(), - Value createdAt = const Value.absent(), - Value updatedAt = const Value.absent(), - Value lastActive = const Value.absent(), - Value online = const Value.absent(), - Value banned = const Value.absent(), - Value> extraData = const Value.absent()}) => + {String? id, + Value role = const Value.absent(), + DateTime? createdAt, + DateTime? updatedAt, + Value lastActive = const Value.absent(), + bool? online, + bool? banned, + Map? extraData}) => UserEntity( id: id ?? this.id, role: role.present ? role.value : this.role, - createdAt: createdAt.present ? createdAt.value : this.createdAt, - updatedAt: updatedAt.present ? updatedAt.value : this.updatedAt, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, lastActive: lastActive.present ? lastActive.value : this.lastActive, - online: online.present ? online.value : this.online, - banned: banned.present ? banned.value : this.banned, - extraData: extraData.present ? extraData.value : this.extraData, + online: online ?? this.online, + banned: banned ?? this.banned, + extraData: extraData ?? this.extraData, ); @override String toString() { @@ -3561,7 +3176,7 @@ class UserEntity extends DataClass implements Insertable { $mrjc(online.hashCode, $mrjc(banned.hashCode, extraData.hashCode)))))))); @override - bool operator ==(dynamic other) => + bool operator ==(Object other) => identical(this, other) || (other is UserEntity && other.id == this.id && @@ -3576,13 +3191,13 @@ class UserEntity extends DataClass implements Insertable { class UsersCompanion extends UpdateCompanion { final Value id; - final Value role; + final Value role; final Value createdAt; final Value updatedAt; - final Value lastActive; + final Value lastActive; final Value online; final Value banned; - final Value> extraData; + final Value> extraData; const UsersCompanion({ this.id = const Value.absent(), this.role = const Value.absent(), @@ -3594,24 +3209,25 @@ class UsersCompanion extends UpdateCompanion { this.extraData = const Value.absent(), }); UsersCompanion.insert({ - @required String id, + required String id, this.role = const Value.absent(), this.createdAt = const Value.absent(), this.updatedAt = const Value.absent(), this.lastActive = const Value.absent(), this.online = const Value.absent(), this.banned = const Value.absent(), - this.extraData = const Value.absent(), - }) : id = Value(id); + required Map extraData, + }) : id = Value(id), + extraData = Value(extraData); static Insertable custom({ - Expression id, - Expression role, - Expression createdAt, - Expression updatedAt, - Expression lastActive, - Expression online, - Expression banned, - Expression extraData, + Expression? id, + Expression? role, + Expression? createdAt, + Expression? updatedAt, + Expression? lastActive, + Expression? online, + Expression? banned, + Expression>? extraData, }) { return RawValuesInsertable({ if (id != null) 'id': id, @@ -3626,14 +3242,14 @@ class UsersCompanion extends UpdateCompanion { } UsersCompanion copyWith( - {Value id, - Value role, - Value createdAt, - Value updatedAt, - Value lastActive, - Value online, - Value banned, - Value> extraData}) { + {Value? id, + Value? role, + Value? createdAt, + Value? updatedAt, + Value? lastActive, + Value? online, + Value? banned, + Value>? extraData}) { return UsersCompanion( id: id ?? this.id, role: role ?? this.role, @@ -3653,7 +3269,7 @@ class UsersCompanion extends UpdateCompanion { map['id'] = Variable(id.value); } if (role.present) { - map['role'] = Variable(role.value); + map['role'] = Variable(role.value); } if (createdAt.present) { map['created_at'] = Variable(createdAt.value); @@ -3662,7 +3278,7 @@ class UsersCompanion extends UpdateCompanion { map['updated_at'] = Variable(updatedAt.value); } if (lastActive.present) { - map['last_active'] = Variable(lastActive.value); + map['last_active'] = Variable(lastActive.value); } if (online.present) { map['online'] = Variable(online.value); @@ -3672,7 +3288,8 @@ class UsersCompanion extends UpdateCompanion { } if (extraData.present) { final converter = $UsersTable.$converter0; - map['extra_data'] = Variable(converter.mapToSql(extraData.value)); + map['extra_data'] = + Variable(converter.mapToSql(extraData.value)!); } return map; } @@ -3695,149 +3312,93 @@ class UsersCompanion extends UpdateCompanion { class $UsersTable extends Users with TableInfo<$UsersTable, UserEntity> { final GeneratedDatabase _db; - final String _alias; + final String? _alias; $UsersTable(this._db, [this._alias]); final VerificationMeta _idMeta = const VerificationMeta('id'); - GeneratedTextColumn _id; - @override - GeneratedTextColumn get id => _id ??= _constructId(); - GeneratedTextColumn _constructId() { - return GeneratedTextColumn( - 'id', - $tableName, - false, - ); - } - + late final GeneratedColumn id = GeneratedColumn( + 'id', aliasedName, false, + typeName: 'TEXT', requiredDuringInsert: true); final VerificationMeta _roleMeta = const VerificationMeta('role'); - GeneratedTextColumn _role; - @override - GeneratedTextColumn get role => _role ??= _constructRole(); - GeneratedTextColumn _constructRole() { - return GeneratedTextColumn( - 'role', - $tableName, - true, - ); - } - + late final GeneratedColumn role = GeneratedColumn( + 'role', aliasedName, true, + typeName: 'TEXT', requiredDuringInsert: false); final VerificationMeta _createdAtMeta = const VerificationMeta('createdAt'); - GeneratedDateTimeColumn _createdAt; - @override - GeneratedDateTimeColumn get createdAt => _createdAt ??= _constructCreatedAt(); - GeneratedDateTimeColumn _constructCreatedAt() { - return GeneratedDateTimeColumn( - 'created_at', - $tableName, - true, - ); - } - + late final GeneratedColumn createdAt = GeneratedColumn( + 'created_at', aliasedName, false, + typeName: 'INTEGER', + requiredDuringInsert: false, + defaultValue: currentDateAndTime); final VerificationMeta _updatedAtMeta = const VerificationMeta('updatedAt'); - GeneratedDateTimeColumn _updatedAt; - @override - GeneratedDateTimeColumn get updatedAt => _updatedAt ??= _constructUpdatedAt(); - GeneratedDateTimeColumn _constructUpdatedAt() { - return GeneratedDateTimeColumn( - 'updated_at', - $tableName, - true, - ); - } - + late final GeneratedColumn updatedAt = GeneratedColumn( + 'updated_at', aliasedName, false, + typeName: 'INTEGER', + requiredDuringInsert: false, + defaultValue: currentDateAndTime); final VerificationMeta _lastActiveMeta = const VerificationMeta('lastActive'); - GeneratedDateTimeColumn _lastActive; - @override - GeneratedDateTimeColumn get lastActive => - _lastActive ??= _constructLastActive(); - GeneratedDateTimeColumn _constructLastActive() { - return GeneratedDateTimeColumn( - 'last_active', - $tableName, - true, - ); - } - + late final GeneratedColumn lastActive = GeneratedColumn( + 'last_active', aliasedName, true, + typeName: 'INTEGER', requiredDuringInsert: false); final VerificationMeta _onlineMeta = const VerificationMeta('online'); - GeneratedBoolColumn _online; - @override - GeneratedBoolColumn get online => _online ??= _constructOnline(); - GeneratedBoolColumn _constructOnline() { - return GeneratedBoolColumn( - 'online', - $tableName, - true, - ); - } - + late final GeneratedColumn online = GeneratedColumn( + 'online', aliasedName, false, + typeName: 'INTEGER', + requiredDuringInsert: false, + defaultConstraints: 'CHECK (online IN (0, 1))', + defaultValue: const Constant(false)); final VerificationMeta _bannedMeta = const VerificationMeta('banned'); - GeneratedBoolColumn _banned; - @override - GeneratedBoolColumn get banned => _banned ??= _constructBanned(); - GeneratedBoolColumn _constructBanned() { - return GeneratedBoolColumn( - 'banned', - $tableName, - true, - ); - } - + late final GeneratedColumn banned = GeneratedColumn( + 'banned', aliasedName, false, + typeName: 'INTEGER', + requiredDuringInsert: false, + defaultConstraints: 'CHECK (banned IN (0, 1))', + defaultValue: const Constant(false)); final VerificationMeta _extraDataMeta = const VerificationMeta('extraData'); - GeneratedTextColumn _extraData; - @override - GeneratedTextColumn get extraData => _extraData ??= _constructExtraData(); - GeneratedTextColumn _constructExtraData() { - return GeneratedTextColumn( - 'extra_data', - $tableName, - true, - ); - } - + late final GeneratedColumnWithTypeConverter, String?> + extraData = GeneratedColumn('extra_data', aliasedName, false, + typeName: 'TEXT', requiredDuringInsert: true) + .withConverter>($UsersTable.$converter0); @override List get $columns => [id, role, createdAt, updatedAt, lastActive, online, banned, extraData]; @override - $UsersTable get asDslTable => this; + String get aliasedName => _alias ?? 'users'; @override - String get $tableName => _alias ?? 'users'; - @override - final String actualTableName = 'users'; + String get actualTableName => 'users'; @override VerificationContext validateIntegrity(Insertable instance, {bool isInserting = false}) { final context = VerificationContext(); final data = instance.toColumns(true); if (data.containsKey('id')) { - context.handle(_idMeta, id.isAcceptableOrUnknown(data['id'], _idMeta)); + context.handle(_idMeta, id.isAcceptableOrUnknown(data['id']!, _idMeta)); } else if (isInserting) { context.missing(_idMeta); } if (data.containsKey('role')) { context.handle( - _roleMeta, role.isAcceptableOrUnknown(data['role'], _roleMeta)); + _roleMeta, role.isAcceptableOrUnknown(data['role']!, _roleMeta)); } if (data.containsKey('created_at')) { context.handle(_createdAtMeta, - createdAt.isAcceptableOrUnknown(data['created_at'], _createdAtMeta)); + createdAt.isAcceptableOrUnknown(data['created_at']!, _createdAtMeta)); } if (data.containsKey('updated_at')) { context.handle(_updatedAtMeta, - updatedAt.isAcceptableOrUnknown(data['updated_at'], _updatedAtMeta)); + updatedAt.isAcceptableOrUnknown(data['updated_at']!, _updatedAtMeta)); } if (data.containsKey('last_active')) { context.handle( _lastActiveMeta, lastActive.isAcceptableOrUnknown( - data['last_active'], _lastActiveMeta)); + data['last_active']!, _lastActiveMeta)); } if (data.containsKey('online')) { context.handle(_onlineMeta, - online.isAcceptableOrUnknown(data['online'], _onlineMeta)); + online.isAcceptableOrUnknown(data['online']!, _onlineMeta)); } if (data.containsKey('banned')) { context.handle(_bannedMeta, - banned.isAcceptableOrUnknown(data['banned'], _bannedMeta)); + banned.isAcceptableOrUnknown(data['banned']!, _bannedMeta)); } context.handle(_extraDataMeta, const VerificationResult.success()); return context; @@ -3846,9 +3407,9 @@ class $UsersTable extends Users with TableInfo<$UsersTable, UserEntity> { @override Set get $primaryKey => {id}; @override - UserEntity map(Map data, {String tablePrefix}) { - final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : null; - return UserEntity.fromData(data, _db, prefix: effectivePrefix); + UserEntity map(Map data, {String? tablePrefix}) { + return UserEntity.fromData(data, _db, + prefix: tablePrefix != null ? '$tablePrefix.' : null); } @override @@ -3856,112 +3417,117 @@ class $UsersTable extends Users with TableInfo<$UsersTable, UserEntity> { return $UsersTable(_db, alias); } - static TypeConverter, String> $converter0 = - MapConverter(); + static TypeConverter, String> $converter0 = + MapConverter(); } class MemberEntity extends DataClass implements Insertable { + /// The interested user id final String userId; + + /// The channel cid of which this user is part of final String channelCid; - final String role; - final DateTime inviteAcceptedAt; - final DateTime inviteRejectedAt; + + /// The role of the user in the channel + final String? role; + + /// The date on which the user accepted the invite to the channel + final DateTime? inviteAcceptedAt; + + /// The date on which the user rejected the invite to the channel + final DateTime? inviteRejectedAt; + + /// True if the user has been invited to the channel final bool invited; + + /// True if the member is banned from the channel final bool banned; + + /// True if the member is shadow banned from the channel final bool shadowBanned; + + /// True if the user is a moderator of the channel final bool isModerator; + + /// The date of creation final DateTime createdAt; + + /// The last date of update final DateTime updatedAt; MemberEntity( - {@required this.userId, - @required this.channelCid, + {required this.userId, + required this.channelCid, this.role, this.inviteAcceptedAt, this.inviteRejectedAt, - this.invited, - this.banned, - this.shadowBanned, - this.isModerator, - @required this.createdAt, - this.updatedAt}); + required this.invited, + required this.banned, + required this.shadowBanned, + required this.isModerator, + required this.createdAt, + required this.updatedAt}); factory MemberEntity.fromData(Map data, GeneratedDatabase db, - {String prefix}) { + {String? prefix}) { final effectivePrefix = prefix ?? ''; - final stringType = db.typeSystem.forDartType(); - final dateTimeType = db.typeSystem.forDartType(); - final boolType = db.typeSystem.forDartType(); return MemberEntity( - userId: - stringType.mapFromDatabaseResponse(data['${effectivePrefix}user_id']), - channelCid: stringType - .mapFromDatabaseResponse(data['${effectivePrefix}channel_cid']), - role: stringType.mapFromDatabaseResponse(data['${effectivePrefix}role']), - inviteAcceptedAt: dateTimeType.mapFromDatabaseResponse( + userId: const StringType() + .mapFromDatabaseResponse(data['${effectivePrefix}user_id'])!, + channelCid: const StringType() + .mapFromDatabaseResponse(data['${effectivePrefix}channel_cid'])!, + role: const StringType() + .mapFromDatabaseResponse(data['${effectivePrefix}role']), + inviteAcceptedAt: const DateTimeType().mapFromDatabaseResponse( data['${effectivePrefix}invite_accepted_at']), - inviteRejectedAt: dateTimeType.mapFromDatabaseResponse( + inviteRejectedAt: const DateTimeType().mapFromDatabaseResponse( data['${effectivePrefix}invite_rejected_at']), - invited: - boolType.mapFromDatabaseResponse(data['${effectivePrefix}invited']), - banned: - boolType.mapFromDatabaseResponse(data['${effectivePrefix}banned']), - shadowBanned: boolType - .mapFromDatabaseResponse(data['${effectivePrefix}shadow_banned']), - isModerator: boolType - .mapFromDatabaseResponse(data['${effectivePrefix}is_moderator']), - createdAt: dateTimeType - .mapFromDatabaseResponse(data['${effectivePrefix}created_at']), - updatedAt: dateTimeType - .mapFromDatabaseResponse(data['${effectivePrefix}updated_at']), + invited: const BoolType() + .mapFromDatabaseResponse(data['${effectivePrefix}invited'])!, + banned: const BoolType() + .mapFromDatabaseResponse(data['${effectivePrefix}banned'])!, + shadowBanned: const BoolType() + .mapFromDatabaseResponse(data['${effectivePrefix}shadow_banned'])!, + isModerator: const BoolType() + .mapFromDatabaseResponse(data['${effectivePrefix}is_moderator'])!, + createdAt: const DateTimeType() + .mapFromDatabaseResponse(data['${effectivePrefix}created_at'])!, + updatedAt: const DateTimeType() + .mapFromDatabaseResponse(data['${effectivePrefix}updated_at'])!, ); } @override Map toColumns(bool nullToAbsent) { final map = {}; - if (!nullToAbsent || userId != null) { - map['user_id'] = Variable(userId); - } - if (!nullToAbsent || channelCid != null) { - map['channel_cid'] = Variable(channelCid); - } + map['user_id'] = Variable(userId); + map['channel_cid'] = Variable(channelCid); if (!nullToAbsent || role != null) { - map['role'] = Variable(role); + map['role'] = Variable(role); } if (!nullToAbsent || inviteAcceptedAt != null) { - map['invite_accepted_at'] = Variable(inviteAcceptedAt); + map['invite_accepted_at'] = Variable(inviteAcceptedAt); } if (!nullToAbsent || inviteRejectedAt != null) { - map['invite_rejected_at'] = Variable(inviteRejectedAt); - } - if (!nullToAbsent || invited != null) { - map['invited'] = Variable(invited); - } - if (!nullToAbsent || banned != null) { - map['banned'] = Variable(banned); - } - if (!nullToAbsent || shadowBanned != null) { - map['shadow_banned'] = Variable(shadowBanned); - } - if (!nullToAbsent || isModerator != null) { - map['is_moderator'] = Variable(isModerator); - } - if (!nullToAbsent || createdAt != null) { - map['created_at'] = Variable(createdAt); - } - if (!nullToAbsent || updatedAt != null) { - map['updated_at'] = Variable(updatedAt); + map['invite_rejected_at'] = Variable(inviteRejectedAt); } + map['invited'] = Variable(invited); + map['banned'] = Variable(banned); + map['shadow_banned'] = Variable(shadowBanned); + map['is_moderator'] = Variable(isModerator); + map['created_at'] = Variable(createdAt); + map['updated_at'] = Variable(updatedAt); return map; } factory MemberEntity.fromJson(Map json, - {ValueSerializer serializer}) { + {ValueSerializer? serializer}) { serializer ??= moorRuntimeOptions.defaultSerializer; return MemberEntity( userId: serializer.fromJson(json['userId']), channelCid: serializer.fromJson(json['channelCid']), - role: serializer.fromJson(json['role']), - inviteAcceptedAt: serializer.fromJson(json['inviteAcceptedAt']), - inviteRejectedAt: serializer.fromJson(json['inviteRejectedAt']), + role: serializer.fromJson(json['role']), + inviteAcceptedAt: + serializer.fromJson(json['inviteAcceptedAt']), + inviteRejectedAt: + serializer.fromJson(json['inviteRejectedAt']), invited: serializer.fromJson(json['invited']), banned: serializer.fromJson(json['banned']), shadowBanned: serializer.fromJson(json['shadowBanned']), @@ -3971,14 +3537,14 @@ class MemberEntity extends DataClass implements Insertable { ); } @override - Map toJson({ValueSerializer serializer}) { + Map toJson({ValueSerializer? serializer}) { serializer ??= moorRuntimeOptions.defaultSerializer; return { 'userId': serializer.toJson(userId), 'channelCid': serializer.toJson(channelCid), - 'role': serializer.toJson(role), - 'inviteAcceptedAt': serializer.toJson(inviteAcceptedAt), - 'inviteRejectedAt': serializer.toJson(inviteRejectedAt), + 'role': serializer.toJson(role), + 'inviteAcceptedAt': serializer.toJson(inviteAcceptedAt), + 'inviteRejectedAt': serializer.toJson(inviteRejectedAt), 'invited': serializer.toJson(invited), 'banned': serializer.toJson(banned), 'shadowBanned': serializer.toJson(shadowBanned), @@ -3989,17 +3555,17 @@ class MemberEntity extends DataClass implements Insertable { } MemberEntity copyWith( - {String userId, - String channelCid, - Value role = const Value.absent(), - Value inviteAcceptedAt = const Value.absent(), - Value inviteRejectedAt = const Value.absent(), - Value invited = const Value.absent(), - Value banned = const Value.absent(), - Value shadowBanned = const Value.absent(), - Value isModerator = const Value.absent(), - DateTime createdAt, - Value updatedAt = const Value.absent()}) => + {String? userId, + String? channelCid, + Value role = const Value.absent(), + Value inviteAcceptedAt = const Value.absent(), + Value inviteRejectedAt = const Value.absent(), + bool? invited, + bool? banned, + bool? shadowBanned, + bool? isModerator, + DateTime? createdAt, + DateTime? updatedAt}) => MemberEntity( userId: userId ?? this.userId, channelCid: channelCid ?? this.channelCid, @@ -4010,13 +3576,12 @@ class MemberEntity extends DataClass implements Insertable { inviteRejectedAt: inviteRejectedAt.present ? inviteRejectedAt.value : this.inviteRejectedAt, - invited: invited.present ? invited.value : this.invited, - banned: banned.present ? banned.value : this.banned, - shadowBanned: - shadowBanned.present ? shadowBanned.value : this.shadowBanned, - isModerator: isModerator.present ? isModerator.value : this.isModerator, + invited: invited ?? this.invited, + banned: banned ?? this.banned, + shadowBanned: shadowBanned ?? this.shadowBanned, + isModerator: isModerator ?? this.isModerator, createdAt: createdAt ?? this.createdAt, - updatedAt: updatedAt.present ? updatedAt.value : this.updatedAt, + updatedAt: updatedAt ?? this.updatedAt, ); @override String toString() { @@ -4058,7 +3623,7 @@ class MemberEntity extends DataClass implements Insertable { $mrjc(createdAt.hashCode, updatedAt.hashCode))))))))))); @override - bool operator ==(dynamic other) => + bool operator ==(Object other) => identical(this, other) || (other is MemberEntity && other.userId == this.userId && @@ -4077,9 +3642,9 @@ class MemberEntity extends DataClass implements Insertable { class MembersCompanion extends UpdateCompanion { final Value userId; final Value channelCid; - final Value role; - final Value inviteAcceptedAt; - final Value inviteRejectedAt; + final Value role; + final Value inviteAcceptedAt; + final Value inviteRejectedAt; final Value invited; final Value banned; final Value shadowBanned; @@ -4100,8 +3665,8 @@ class MembersCompanion extends UpdateCompanion { this.updatedAt = const Value.absent(), }); MembersCompanion.insert({ - @required String userId, - @required String channelCid, + required String userId, + required String channelCid, this.role = const Value.absent(), this.inviteAcceptedAt = const Value.absent(), this.inviteRejectedAt = const Value.absent(), @@ -4109,23 +3674,22 @@ class MembersCompanion extends UpdateCompanion { this.banned = const Value.absent(), this.shadowBanned = const Value.absent(), this.isModerator = const Value.absent(), - @required DateTime createdAt, + this.createdAt = const Value.absent(), this.updatedAt = const Value.absent(), }) : userId = Value(userId), - channelCid = Value(channelCid), - createdAt = Value(createdAt); + channelCid = Value(channelCid); static Insertable custom({ - Expression userId, - Expression channelCid, - Expression role, - Expression inviteAcceptedAt, - Expression inviteRejectedAt, - Expression invited, - Expression banned, - Expression shadowBanned, - Expression isModerator, - Expression createdAt, - Expression updatedAt, + Expression? userId, + Expression? channelCid, + Expression? role, + Expression? inviteAcceptedAt, + Expression? inviteRejectedAt, + Expression? invited, + Expression? banned, + Expression? shadowBanned, + Expression? isModerator, + Expression? createdAt, + Expression? updatedAt, }) { return RawValuesInsertable({ if (userId != null) 'user_id': userId, @@ -4143,17 +3707,17 @@ class MembersCompanion extends UpdateCompanion { } MembersCompanion copyWith( - {Value userId, - Value channelCid, - Value role, - Value inviteAcceptedAt, - Value inviteRejectedAt, - Value invited, - Value banned, - Value shadowBanned, - Value isModerator, - Value createdAt, - Value updatedAt}) { + {Value? userId, + Value? channelCid, + Value? role, + Value? inviteAcceptedAt, + Value? inviteRejectedAt, + Value? invited, + Value? banned, + Value? shadowBanned, + Value? isModerator, + Value? createdAt, + Value? updatedAt}) { return MembersCompanion( userId: userId ?? this.userId, channelCid: channelCid ?? this.channelCid, @@ -4179,13 +3743,13 @@ class MembersCompanion extends UpdateCompanion { map['channel_cid'] = Variable(channelCid.value); } if (role.present) { - map['role'] = Variable(role.value); + map['role'] = Variable(role.value); } if (inviteAcceptedAt.present) { - map['invite_accepted_at'] = Variable(inviteAcceptedAt.value); + map['invite_accepted_at'] = Variable(inviteAcceptedAt.value); } if (inviteRejectedAt.present) { - map['invite_rejected_at'] = Variable(inviteRejectedAt.value); + map['invite_rejected_at'] = Variable(inviteRejectedAt.value); } if (invited.present) { map['invited'] = Variable(invited.value); @@ -4230,145 +3794,74 @@ class MembersCompanion extends UpdateCompanion { class $MembersTable extends Members with TableInfo<$MembersTable, MemberEntity> { final GeneratedDatabase _db; - final String _alias; + final String? _alias; $MembersTable(this._db, [this._alias]); final VerificationMeta _userIdMeta = const VerificationMeta('userId'); - GeneratedTextColumn _userId; - @override - GeneratedTextColumn get userId => _userId ??= _constructUserId(); - GeneratedTextColumn _constructUserId() { - return GeneratedTextColumn( - 'user_id', - $tableName, - false, - ); - } - + late final GeneratedColumn userId = GeneratedColumn( + 'user_id', aliasedName, false, + typeName: 'TEXT', requiredDuringInsert: true); final VerificationMeta _channelCidMeta = const VerificationMeta('channelCid'); - GeneratedTextColumn _channelCid; - @override - GeneratedTextColumn get channelCid => _channelCid ??= _constructChannelCid(); - GeneratedTextColumn _constructChannelCid() { - return GeneratedTextColumn('channel_cid', $tableName, false, - $customConstraints: 'REFERENCES channels(cid) ON DELETE CASCADE'); - } - + late final GeneratedColumn channelCid = GeneratedColumn( + 'channel_cid', aliasedName, false, + typeName: 'TEXT', + requiredDuringInsert: true, + $customConstraints: 'REFERENCES channels(cid) ON DELETE CASCADE'); final VerificationMeta _roleMeta = const VerificationMeta('role'); - GeneratedTextColumn _role; - @override - GeneratedTextColumn get role => _role ??= _constructRole(); - GeneratedTextColumn _constructRole() { - return GeneratedTextColumn( - 'role', - $tableName, - true, - ); - } - + late final GeneratedColumn role = GeneratedColumn( + 'role', aliasedName, true, + typeName: 'TEXT', requiredDuringInsert: false); final VerificationMeta _inviteAcceptedAtMeta = const VerificationMeta('inviteAcceptedAt'); - GeneratedDateTimeColumn _inviteAcceptedAt; - @override - GeneratedDateTimeColumn get inviteAcceptedAt => - _inviteAcceptedAt ??= _constructInviteAcceptedAt(); - GeneratedDateTimeColumn _constructInviteAcceptedAt() { - return GeneratedDateTimeColumn( - 'invite_accepted_at', - $tableName, - true, - ); - } - + late final GeneratedColumn inviteAcceptedAt = + GeneratedColumn('invite_accepted_at', aliasedName, true, + typeName: 'INTEGER', requiredDuringInsert: false); final VerificationMeta _inviteRejectedAtMeta = const VerificationMeta('inviteRejectedAt'); - GeneratedDateTimeColumn _inviteRejectedAt; - @override - GeneratedDateTimeColumn get inviteRejectedAt => - _inviteRejectedAt ??= _constructInviteRejectedAt(); - GeneratedDateTimeColumn _constructInviteRejectedAt() { - return GeneratedDateTimeColumn( - 'invite_rejected_at', - $tableName, - true, - ); - } - + late final GeneratedColumn inviteRejectedAt = + GeneratedColumn('invite_rejected_at', aliasedName, true, + typeName: 'INTEGER', requiredDuringInsert: false); final VerificationMeta _invitedMeta = const VerificationMeta('invited'); - GeneratedBoolColumn _invited; - @override - GeneratedBoolColumn get invited => _invited ??= _constructInvited(); - GeneratedBoolColumn _constructInvited() { - return GeneratedBoolColumn( - 'invited', - $tableName, - true, - ); - } - + late final GeneratedColumn invited = GeneratedColumn( + 'invited', aliasedName, false, + typeName: 'INTEGER', + requiredDuringInsert: false, + defaultConstraints: 'CHECK (invited IN (0, 1))', + defaultValue: const Constant(false)); final VerificationMeta _bannedMeta = const VerificationMeta('banned'); - GeneratedBoolColumn _banned; - @override - GeneratedBoolColumn get banned => _banned ??= _constructBanned(); - GeneratedBoolColumn _constructBanned() { - return GeneratedBoolColumn( - 'banned', - $tableName, - true, - ); - } - + late final GeneratedColumn banned = GeneratedColumn( + 'banned', aliasedName, false, + typeName: 'INTEGER', + requiredDuringInsert: false, + defaultConstraints: 'CHECK (banned IN (0, 1))', + defaultValue: const Constant(false)); final VerificationMeta _shadowBannedMeta = const VerificationMeta('shadowBanned'); - GeneratedBoolColumn _shadowBanned; - @override - GeneratedBoolColumn get shadowBanned => - _shadowBanned ??= _constructShadowBanned(); - GeneratedBoolColumn _constructShadowBanned() { - return GeneratedBoolColumn( - 'shadow_banned', - $tableName, - true, - ); - } - + late final GeneratedColumn shadowBanned = GeneratedColumn( + 'shadow_banned', aliasedName, false, + typeName: 'INTEGER', + requiredDuringInsert: false, + defaultConstraints: 'CHECK (shadow_banned IN (0, 1))', + defaultValue: const Constant(false)); final VerificationMeta _isModeratorMeta = const VerificationMeta('isModerator'); - GeneratedBoolColumn _isModerator; - @override - GeneratedBoolColumn get isModerator => - _isModerator ??= _constructIsModerator(); - GeneratedBoolColumn _constructIsModerator() { - return GeneratedBoolColumn( - 'is_moderator', - $tableName, - true, - ); - } - + late final GeneratedColumn isModerator = GeneratedColumn( + 'is_moderator', aliasedName, false, + typeName: 'INTEGER', + requiredDuringInsert: false, + defaultConstraints: 'CHECK (is_moderator IN (0, 1))', + defaultValue: const Constant(false)); final VerificationMeta _createdAtMeta = const VerificationMeta('createdAt'); - GeneratedDateTimeColumn _createdAt; - @override - GeneratedDateTimeColumn get createdAt => _createdAt ??= _constructCreatedAt(); - GeneratedDateTimeColumn _constructCreatedAt() { - return GeneratedDateTimeColumn( - 'created_at', - $tableName, - false, - ); - } - + late final GeneratedColumn createdAt = GeneratedColumn( + 'created_at', aliasedName, false, + typeName: 'INTEGER', + requiredDuringInsert: false, + defaultValue: currentDateAndTime); final VerificationMeta _updatedAtMeta = const VerificationMeta('updatedAt'); - GeneratedDateTimeColumn _updatedAt; - @override - GeneratedDateTimeColumn get updatedAt => _updatedAt ??= _constructUpdatedAt(); - GeneratedDateTimeColumn _constructUpdatedAt() { - return GeneratedDateTimeColumn( - 'updated_at', - $tableName, - true, - ); - } - + late final GeneratedColumn updatedAt = GeneratedColumn( + 'updated_at', aliasedName, false, + typeName: 'INTEGER', + requiredDuringInsert: false, + defaultValue: currentDateAndTime); @override List get $columns => [ userId, @@ -4384,11 +3877,9 @@ class $MembersTable extends Members updatedAt ]; @override - $MembersTable get asDslTable => this; + String get aliasedName => _alias ?? 'members'; @override - String get $tableName => _alias ?? 'members'; - @override - final String actualTableName = 'members'; + String get actualTableName => 'members'; @override VerificationContext validateIntegrity(Insertable instance, {bool isInserting = false}) { @@ -4396,7 +3887,7 @@ class $MembersTable extends Members final data = instance.toColumns(true); if (data.containsKey('user_id')) { context.handle(_userIdMeta, - userId.isAcceptableOrUnknown(data['user_id'], _userIdMeta)); + userId.isAcceptableOrUnknown(data['user_id']!, _userIdMeta)); } else if (isInserting) { context.missing(_userIdMeta); } @@ -4404,55 +3895,53 @@ class $MembersTable extends Members context.handle( _channelCidMeta, channelCid.isAcceptableOrUnknown( - data['channel_cid'], _channelCidMeta)); + data['channel_cid']!, _channelCidMeta)); } else if (isInserting) { context.missing(_channelCidMeta); } if (data.containsKey('role')) { context.handle( - _roleMeta, role.isAcceptableOrUnknown(data['role'], _roleMeta)); + _roleMeta, role.isAcceptableOrUnknown(data['role']!, _roleMeta)); } if (data.containsKey('invite_accepted_at')) { context.handle( _inviteAcceptedAtMeta, inviteAcceptedAt.isAcceptableOrUnknown( - data['invite_accepted_at'], _inviteAcceptedAtMeta)); + data['invite_accepted_at']!, _inviteAcceptedAtMeta)); } if (data.containsKey('invite_rejected_at')) { context.handle( _inviteRejectedAtMeta, inviteRejectedAt.isAcceptableOrUnknown( - data['invite_rejected_at'], _inviteRejectedAtMeta)); + data['invite_rejected_at']!, _inviteRejectedAtMeta)); } if (data.containsKey('invited')) { context.handle(_invitedMeta, - invited.isAcceptableOrUnknown(data['invited'], _invitedMeta)); + invited.isAcceptableOrUnknown(data['invited']!, _invitedMeta)); } if (data.containsKey('banned')) { context.handle(_bannedMeta, - banned.isAcceptableOrUnknown(data['banned'], _bannedMeta)); + banned.isAcceptableOrUnknown(data['banned']!, _bannedMeta)); } if (data.containsKey('shadow_banned')) { context.handle( _shadowBannedMeta, shadowBanned.isAcceptableOrUnknown( - data['shadow_banned'], _shadowBannedMeta)); + data['shadow_banned']!, _shadowBannedMeta)); } if (data.containsKey('is_moderator')) { context.handle( _isModeratorMeta, isModerator.isAcceptableOrUnknown( - data['is_moderator'], _isModeratorMeta)); + data['is_moderator']!, _isModeratorMeta)); } if (data.containsKey('created_at')) { context.handle(_createdAtMeta, - createdAt.isAcceptableOrUnknown(data['created_at'], _createdAtMeta)); - } else if (isInserting) { - context.missing(_createdAtMeta); + createdAt.isAcceptableOrUnknown(data['created_at']!, _createdAtMeta)); } if (data.containsKey('updated_at')) { context.handle(_updatedAtMeta, - updatedAt.isAcceptableOrUnknown(data['updated_at'], _updatedAtMeta)); + updatedAt.isAcceptableOrUnknown(data['updated_at']!, _updatedAtMeta)); } return context; } @@ -4460,9 +3949,9 @@ class $MembersTable extends Members @override Set get $primaryKey => {userId, channelCid}; @override - MemberEntity map(Map data, {String tablePrefix}) { - final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : null; - return MemberEntity.fromData(data, _db, prefix: effectivePrefix); + MemberEntity map(Map data, {String? tablePrefix}) { + return MemberEntity.fromData(data, _db, + prefix: tablePrefix != null ? '$tablePrefix.' : null); } @override @@ -4472,52 +3961,48 @@ class $MembersTable extends Members } class ReadEntity extends DataClass implements Insertable { + /// Date of the read event final DateTime lastRead; + + /// Id of the User who sent the event final String userId; + + /// The channel cid of which this read belongs final String channelCid; + + /// Number of unread messages final int unreadMessages; ReadEntity( - {@required this.lastRead, - @required this.userId, - @required this.channelCid, - this.unreadMessages}); + {required this.lastRead, + required this.userId, + required this.channelCid, + required this.unreadMessages}); factory ReadEntity.fromData(Map data, GeneratedDatabase db, - {String prefix}) { + {String? prefix}) { final effectivePrefix = prefix ?? ''; - final dateTimeType = db.typeSystem.forDartType(); - final stringType = db.typeSystem.forDartType(); - final intType = db.typeSystem.forDartType(); return ReadEntity( - lastRead: dateTimeType - .mapFromDatabaseResponse(data['${effectivePrefix}last_read']), - userId: - stringType.mapFromDatabaseResponse(data['${effectivePrefix}user_id']), - channelCid: stringType - .mapFromDatabaseResponse(data['${effectivePrefix}channel_cid']), - unreadMessages: intType - .mapFromDatabaseResponse(data['${effectivePrefix}unread_messages']), + lastRead: const DateTimeType() + .mapFromDatabaseResponse(data['${effectivePrefix}last_read'])!, + userId: const StringType() + .mapFromDatabaseResponse(data['${effectivePrefix}user_id'])!, + channelCid: const StringType() + .mapFromDatabaseResponse(data['${effectivePrefix}channel_cid'])!, + unreadMessages: const IntType() + .mapFromDatabaseResponse(data['${effectivePrefix}unread_messages'])!, ); } @override Map toColumns(bool nullToAbsent) { final map = {}; - if (!nullToAbsent || lastRead != null) { - map['last_read'] = Variable(lastRead); - } - if (!nullToAbsent || userId != null) { - map['user_id'] = Variable(userId); - } - if (!nullToAbsent || channelCid != null) { - map['channel_cid'] = Variable(channelCid); - } - if (!nullToAbsent || unreadMessages != null) { - map['unread_messages'] = Variable(unreadMessages); - } + map['last_read'] = Variable(lastRead); + map['user_id'] = Variable(userId); + map['channel_cid'] = Variable(channelCid); + map['unread_messages'] = Variable(unreadMessages); return map; } factory ReadEntity.fromJson(Map json, - {ValueSerializer serializer}) { + {ValueSerializer? serializer}) { serializer ??= moorRuntimeOptions.defaultSerializer; return ReadEntity( lastRead: serializer.fromJson(json['lastRead']), @@ -4527,7 +4012,7 @@ class ReadEntity extends DataClass implements Insertable { ); } @override - Map toJson({ValueSerializer serializer}) { + Map toJson({ValueSerializer? serializer}) { serializer ??= moorRuntimeOptions.defaultSerializer; return { 'lastRead': serializer.toJson(lastRead), @@ -4538,16 +4023,15 @@ class ReadEntity extends DataClass implements Insertable { } ReadEntity copyWith( - {DateTime lastRead, - String userId, - String channelCid, - Value unreadMessages = const Value.absent()}) => + {DateTime? lastRead, + String? userId, + String? channelCid, + int? unreadMessages}) => ReadEntity( lastRead: lastRead ?? this.lastRead, userId: userId ?? this.userId, channelCid: channelCid ?? this.channelCid, - unreadMessages: - unreadMessages.present ? unreadMessages.value : this.unreadMessages, + unreadMessages: unreadMessages ?? this.unreadMessages, ); @override String toString() { @@ -4566,7 +4050,7 @@ class ReadEntity extends DataClass implements Insertable { $mrjc(userId.hashCode, $mrjc(channelCid.hashCode, unreadMessages.hashCode)))); @override - bool operator ==(dynamic other) => + bool operator ==(Object other) => identical(this, other) || (other is ReadEntity && other.lastRead == this.lastRead && @@ -4587,18 +4071,18 @@ class ReadsCompanion extends UpdateCompanion { this.unreadMessages = const Value.absent(), }); ReadsCompanion.insert({ - @required DateTime lastRead, - @required String userId, - @required String channelCid, + required DateTime lastRead, + required String userId, + required String channelCid, this.unreadMessages = const Value.absent(), }) : lastRead = Value(lastRead), userId = Value(userId), channelCid = Value(channelCid); static Insertable custom({ - Expression lastRead, - Expression userId, - Expression channelCid, - Expression unreadMessages, + Expression? lastRead, + Expression? userId, + Expression? channelCid, + Expression? unreadMessages, }) { return RawValuesInsertable({ if (lastRead != null) 'last_read': lastRead, @@ -4609,10 +4093,10 @@ class ReadsCompanion extends UpdateCompanion { } ReadsCompanion copyWith( - {Value lastRead, - Value userId, - Value channelCid, - Value unreadMessages}) { + {Value? lastRead, + Value? userId, + Value? channelCid, + Value? unreadMessages}) { return ReadsCompanion( lastRead: lastRead ?? this.lastRead, userId: userId ?? this.userId, @@ -4653,64 +4137,36 @@ class ReadsCompanion extends UpdateCompanion { class $ReadsTable extends Reads with TableInfo<$ReadsTable, ReadEntity> { final GeneratedDatabase _db; - final String _alias; + final String? _alias; $ReadsTable(this._db, [this._alias]); final VerificationMeta _lastReadMeta = const VerificationMeta('lastRead'); - GeneratedDateTimeColumn _lastRead; - @override - GeneratedDateTimeColumn get lastRead => _lastRead ??= _constructLastRead(); - GeneratedDateTimeColumn _constructLastRead() { - return GeneratedDateTimeColumn( - 'last_read', - $tableName, - false, - ); - } - + late final GeneratedColumn lastRead = GeneratedColumn( + 'last_read', aliasedName, false, + typeName: 'INTEGER', requiredDuringInsert: true); final VerificationMeta _userIdMeta = const VerificationMeta('userId'); - GeneratedTextColumn _userId; - @override - GeneratedTextColumn get userId => _userId ??= _constructUserId(); - GeneratedTextColumn _constructUserId() { - return GeneratedTextColumn( - 'user_id', - $tableName, - false, - ); - } - + late final GeneratedColumn userId = GeneratedColumn( + 'user_id', aliasedName, false, + typeName: 'TEXT', requiredDuringInsert: true); final VerificationMeta _channelCidMeta = const VerificationMeta('channelCid'); - GeneratedTextColumn _channelCid; - @override - GeneratedTextColumn get channelCid => _channelCid ??= _constructChannelCid(); - GeneratedTextColumn _constructChannelCid() { - return GeneratedTextColumn('channel_cid', $tableName, false, - $customConstraints: 'REFERENCES channels(cid) ON DELETE CASCADE'); - } - + late final GeneratedColumn channelCid = GeneratedColumn( + 'channel_cid', aliasedName, false, + typeName: 'TEXT', + requiredDuringInsert: true, + $customConstraints: 'REFERENCES channels(cid) ON DELETE CASCADE'); final VerificationMeta _unreadMessagesMeta = const VerificationMeta('unreadMessages'); - GeneratedIntColumn _unreadMessages; - @override - GeneratedIntColumn get unreadMessages => - _unreadMessages ??= _constructUnreadMessages(); - GeneratedIntColumn _constructUnreadMessages() { - return GeneratedIntColumn( - 'unread_messages', - $tableName, - true, - ); - } - + late final GeneratedColumn unreadMessages = GeneratedColumn( + 'unread_messages', aliasedName, false, + typeName: 'INTEGER', + requiredDuringInsert: false, + defaultValue: const Constant(0)); @override List get $columns => [lastRead, userId, channelCid, unreadMessages]; @override - $ReadsTable get asDslTable => this; + String get aliasedName => _alias ?? 'reads'; @override - String get $tableName => _alias ?? 'reads'; - @override - final String actualTableName = 'reads'; + String get actualTableName => 'reads'; @override VerificationContext validateIntegrity(Insertable instance, {bool isInserting = false}) { @@ -4718,13 +4174,13 @@ class $ReadsTable extends Reads with TableInfo<$ReadsTable, ReadEntity> { final data = instance.toColumns(true); if (data.containsKey('last_read')) { context.handle(_lastReadMeta, - lastRead.isAcceptableOrUnknown(data['last_read'], _lastReadMeta)); + lastRead.isAcceptableOrUnknown(data['last_read']!, _lastReadMeta)); } else if (isInserting) { context.missing(_lastReadMeta); } if (data.containsKey('user_id')) { context.handle(_userIdMeta, - userId.isAcceptableOrUnknown(data['user_id'], _userIdMeta)); + userId.isAcceptableOrUnknown(data['user_id']!, _userIdMeta)); } else if (isInserting) { context.missing(_userIdMeta); } @@ -4732,7 +4188,7 @@ class $ReadsTable extends Reads with TableInfo<$ReadsTable, ReadEntity> { context.handle( _channelCidMeta, channelCid.isAcceptableOrUnknown( - data['channel_cid'], _channelCidMeta)); + data['channel_cid']!, _channelCidMeta)); } else if (isInserting) { context.missing(_channelCidMeta); } @@ -4740,7 +4196,7 @@ class $ReadsTable extends Reads with TableInfo<$ReadsTable, ReadEntity> { context.handle( _unreadMessagesMeta, unreadMessages.isAcceptableOrUnknown( - data['unread_messages'], _unreadMessagesMeta)); + data['unread_messages']!, _unreadMessagesMeta)); } return context; } @@ -4748,9 +4204,9 @@ class $ReadsTable extends Reads with TableInfo<$ReadsTable, ReadEntity> { @override Set get $primaryKey => {userId, channelCid}; @override - ReadEntity map(Map data, {String tablePrefix}) { - final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : null; - return ReadEntity.fromData(data, _db, prefix: effectivePrefix); + ReadEntity map(Map data, {String? tablePrefix}) { + return ReadEntity.fromData(data, _db, + prefix: tablePrefix != null ? '$tablePrefix.' : null); } @override @@ -4761,35 +4217,33 @@ class $ReadsTable extends Reads with TableInfo<$ReadsTable, ReadEntity> { class ChannelQueryEntity extends DataClass implements Insertable { + /// The unique hash of this query final String queryHash; + + /// The channel cid of this query final String channelCid; - ChannelQueryEntity({@required this.queryHash, @required this.channelCid}); + ChannelQueryEntity({required this.queryHash, required this.channelCid}); factory ChannelQueryEntity.fromData( Map data, GeneratedDatabase db, - {String prefix}) { + {String? prefix}) { final effectivePrefix = prefix ?? ''; - final stringType = db.typeSystem.forDartType(); return ChannelQueryEntity( - queryHash: stringType - .mapFromDatabaseResponse(data['${effectivePrefix}query_hash']), - channelCid: stringType - .mapFromDatabaseResponse(data['${effectivePrefix}channel_cid']), + queryHash: const StringType() + .mapFromDatabaseResponse(data['${effectivePrefix}query_hash'])!, + channelCid: const StringType() + .mapFromDatabaseResponse(data['${effectivePrefix}channel_cid'])!, ); } @override Map toColumns(bool nullToAbsent) { final map = {}; - if (!nullToAbsent || queryHash != null) { - map['query_hash'] = Variable(queryHash); - } - if (!nullToAbsent || channelCid != null) { - map['channel_cid'] = Variable(channelCid); - } + map['query_hash'] = Variable(queryHash); + map['channel_cid'] = Variable(channelCid); return map; } factory ChannelQueryEntity.fromJson(Map json, - {ValueSerializer serializer}) { + {ValueSerializer? serializer}) { serializer ??= moorRuntimeOptions.defaultSerializer; return ChannelQueryEntity( queryHash: serializer.fromJson(json['queryHash']), @@ -4797,7 +4251,7 @@ class ChannelQueryEntity extends DataClass ); } @override - Map toJson({ValueSerializer serializer}) { + Map toJson({ValueSerializer? serializer}) { serializer ??= moorRuntimeOptions.defaultSerializer; return { 'queryHash': serializer.toJson(queryHash), @@ -4805,7 +4259,7 @@ class ChannelQueryEntity extends DataClass }; } - ChannelQueryEntity copyWith({String queryHash, String channelCid}) => + ChannelQueryEntity copyWith({String? queryHash, String? channelCid}) => ChannelQueryEntity( queryHash: queryHash ?? this.queryHash, channelCid: channelCid ?? this.channelCid, @@ -4822,7 +4276,7 @@ class ChannelQueryEntity extends DataClass @override int get hashCode => $mrjf($mrjc(queryHash.hashCode, channelCid.hashCode)); @override - bool operator ==(dynamic other) => + bool operator ==(Object other) => identical(this, other) || (other is ChannelQueryEntity && other.queryHash == this.queryHash && @@ -4837,13 +4291,13 @@ class ChannelQueriesCompanion extends UpdateCompanion { this.channelCid = const Value.absent(), }); ChannelQueriesCompanion.insert({ - @required String queryHash, - @required String channelCid, + required String queryHash, + required String channelCid, }) : queryHash = Value(queryHash), channelCid = Value(channelCid); static Insertable custom({ - Expression queryHash, - Expression channelCid, + Expression? queryHash, + Expression? channelCid, }) { return RawValuesInsertable({ if (queryHash != null) 'query_hash': queryHash, @@ -4852,7 +4306,7 @@ class ChannelQueriesCompanion extends UpdateCompanion { } ChannelQueriesCompanion copyWith( - {Value queryHash, Value channelCid}) { + {Value? queryHash, Value? channelCid}) { return ChannelQueriesCompanion( queryHash: queryHash ?? this.queryHash, channelCid: channelCid ?? this.channelCid, @@ -4884,40 +4338,22 @@ class ChannelQueriesCompanion extends UpdateCompanion { class $ChannelQueriesTable extends ChannelQueries with TableInfo<$ChannelQueriesTable, ChannelQueryEntity> { final GeneratedDatabase _db; - final String _alias; + final String? _alias; $ChannelQueriesTable(this._db, [this._alias]); final VerificationMeta _queryHashMeta = const VerificationMeta('queryHash'); - GeneratedTextColumn _queryHash; - @override - GeneratedTextColumn get queryHash => _queryHash ??= _constructQueryHash(); - GeneratedTextColumn _constructQueryHash() { - return GeneratedTextColumn( - 'query_hash', - $tableName, - false, - ); - } - + late final GeneratedColumn queryHash = GeneratedColumn( + 'query_hash', aliasedName, false, + typeName: 'TEXT', requiredDuringInsert: true); final VerificationMeta _channelCidMeta = const VerificationMeta('channelCid'); - GeneratedTextColumn _channelCid; - @override - GeneratedTextColumn get channelCid => _channelCid ??= _constructChannelCid(); - GeneratedTextColumn _constructChannelCid() { - return GeneratedTextColumn( - 'channel_cid', - $tableName, - false, - ); - } - + late final GeneratedColumn channelCid = GeneratedColumn( + 'channel_cid', aliasedName, false, + typeName: 'TEXT', requiredDuringInsert: true); @override List get $columns => [queryHash, channelCid]; @override - $ChannelQueriesTable get asDslTable => this; + String get aliasedName => _alias ?? 'channel_queries'; @override - String get $tableName => _alias ?? 'channel_queries'; - @override - final String actualTableName = 'channel_queries'; + String get actualTableName => 'channel_queries'; @override VerificationContext validateIntegrity(Insertable instance, {bool isInserting = false}) { @@ -4925,7 +4361,7 @@ class $ChannelQueriesTable extends ChannelQueries final data = instance.toColumns(true); if (data.containsKey('query_hash')) { context.handle(_queryHashMeta, - queryHash.isAcceptableOrUnknown(data['query_hash'], _queryHashMeta)); + queryHash.isAcceptableOrUnknown(data['query_hash']!, _queryHashMeta)); } else if (isInserting) { context.missing(_queryHashMeta); } @@ -4933,7 +4369,7 @@ class $ChannelQueriesTable extends ChannelQueries context.handle( _channelCidMeta, channelCid.isAcceptableOrUnknown( - data['channel_cid'], _channelCidMeta)); + data['channel_cid']!, _channelCidMeta)); } else if (isInserting) { context.missing(_channelCidMeta); } @@ -4943,9 +4379,9 @@ class $ChannelQueriesTable extends ChannelQueries @override Set get $primaryKey => {queryHash, channelCid}; @override - ChannelQueryEntity map(Map data, {String tablePrefix}) { - final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : null; - return ChannelQueryEntity.fromData(data, _db, prefix: effectivePrefix); + ChannelQueryEntity map(Map data, {String? tablePrefix}) { + return ChannelQueryEntity.fromData(data, _db, + prefix: tablePrefix != null ? '$tablePrefix.' : null); } @override @@ -4956,14 +4392,29 @@ class $ChannelQueriesTable extends ChannelQueries class ConnectionEventEntity extends DataClass implements Insertable { + /// event id final int id; - final Map ownUser; - final int totalUnreadCount; - final int unreadChannels; - final DateTime lastEventAt; - final DateTime lastSyncAt; + + /// event type + final String type; + + /// User object of the current user + final Map? ownUser; + + /// The number of unread messages for current user + final int? totalUnreadCount; + + /// User total unread channels for current user + final int? unreadChannels; + + /// DateTime of the last event + final DateTime? lastEventAt; + + /// DateTime of the last sync + final DateTime? lastSyncAt; ConnectionEventEntity( - {@required this.id, + {required this.id, + required this.type, this.ownUser, this.totalUnreadCount, this.unreadChannels, @@ -4971,84 +4422,87 @@ class ConnectionEventEntity extends DataClass this.lastSyncAt}); factory ConnectionEventEntity.fromData( Map data, GeneratedDatabase db, - {String prefix}) { + {String? prefix}) { final effectivePrefix = prefix ?? ''; - final intType = db.typeSystem.forDartType(); - final stringType = db.typeSystem.forDartType(); - final dateTimeType = db.typeSystem.forDartType(); return ConnectionEventEntity( - id: intType.mapFromDatabaseResponse(data['${effectivePrefix}id']), - ownUser: $ConnectionEventsTable.$converter0.mapToDart(stringType + id: const IntType() + .mapFromDatabaseResponse(data['${effectivePrefix}id'])!, + type: const StringType() + .mapFromDatabaseResponse(data['${effectivePrefix}type'])!, + ownUser: $ConnectionEventsTable.$converter0.mapToDart(const StringType() .mapFromDatabaseResponse(data['${effectivePrefix}own_user'])), - totalUnreadCount: intType.mapFromDatabaseResponse( + totalUnreadCount: const IntType().mapFromDatabaseResponse( data['${effectivePrefix}total_unread_count']), - unreadChannels: intType + unreadChannels: const IntType() .mapFromDatabaseResponse(data['${effectivePrefix}unread_channels']), - lastEventAt: dateTimeType + lastEventAt: const DateTimeType() .mapFromDatabaseResponse(data['${effectivePrefix}last_event_at']), - lastSyncAt: dateTimeType + lastSyncAt: const DateTimeType() .mapFromDatabaseResponse(data['${effectivePrefix}last_sync_at']), ); } @override Map toColumns(bool nullToAbsent) { final map = {}; - if (!nullToAbsent || id != null) { - map['id'] = Variable(id); - } + map['id'] = Variable(id); + map['type'] = Variable(type); if (!nullToAbsent || ownUser != null) { final converter = $ConnectionEventsTable.$converter0; - map['own_user'] = Variable(converter.mapToSql(ownUser)); + map['own_user'] = Variable(converter.mapToSql(ownUser)); } if (!nullToAbsent || totalUnreadCount != null) { - map['total_unread_count'] = Variable(totalUnreadCount); + map['total_unread_count'] = Variable(totalUnreadCount); } if (!nullToAbsent || unreadChannels != null) { - map['unread_channels'] = Variable(unreadChannels); + map['unread_channels'] = Variable(unreadChannels); } if (!nullToAbsent || lastEventAt != null) { - map['last_event_at'] = Variable(lastEventAt); + map['last_event_at'] = Variable(lastEventAt); } if (!nullToAbsent || lastSyncAt != null) { - map['last_sync_at'] = Variable(lastSyncAt); + map['last_sync_at'] = Variable(lastSyncAt); } return map; } factory ConnectionEventEntity.fromJson(Map json, - {ValueSerializer serializer}) { + {ValueSerializer? serializer}) { serializer ??= moorRuntimeOptions.defaultSerializer; return ConnectionEventEntity( id: serializer.fromJson(json['id']), - ownUser: serializer.fromJson>(json['ownUser']), - totalUnreadCount: serializer.fromJson(json['totalUnreadCount']), - unreadChannels: serializer.fromJson(json['unreadChannels']), - lastEventAt: serializer.fromJson(json['lastEventAt']), - lastSyncAt: serializer.fromJson(json['lastSyncAt']), + type: serializer.fromJson(json['type']), + ownUser: serializer.fromJson?>(json['ownUser']), + totalUnreadCount: serializer.fromJson(json['totalUnreadCount']), + unreadChannels: serializer.fromJson(json['unreadChannels']), + lastEventAt: serializer.fromJson(json['lastEventAt']), + lastSyncAt: serializer.fromJson(json['lastSyncAt']), ); } @override - Map toJson({ValueSerializer serializer}) { + Map toJson({ValueSerializer? serializer}) { serializer ??= moorRuntimeOptions.defaultSerializer; return { 'id': serializer.toJson(id), - 'ownUser': serializer.toJson>(ownUser), - 'totalUnreadCount': serializer.toJson(totalUnreadCount), - 'unreadChannels': serializer.toJson(unreadChannels), - 'lastEventAt': serializer.toJson(lastEventAt), - 'lastSyncAt': serializer.toJson(lastSyncAt), + 'type': serializer.toJson(type), + 'ownUser': serializer.toJson?>(ownUser), + 'totalUnreadCount': serializer.toJson(totalUnreadCount), + 'unreadChannels': serializer.toJson(unreadChannels), + 'lastEventAt': serializer.toJson(lastEventAt), + 'lastSyncAt': serializer.toJson(lastSyncAt), }; } ConnectionEventEntity copyWith( - {int id, - Value> ownUser = const Value.absent(), - Value totalUnreadCount = const Value.absent(), - Value unreadChannels = const Value.absent(), - Value lastEventAt = const Value.absent(), - Value lastSyncAt = const Value.absent()}) => + {int? id, + String? type, + Value?> ownUser = const Value.absent(), + Value totalUnreadCount = const Value.absent(), + Value unreadChannels = const Value.absent(), + Value lastEventAt = const Value.absent(), + Value lastSyncAt = const Value.absent()}) => ConnectionEventEntity( id: id ?? this.id, + type: type ?? this.type, ownUser: ownUser.present ? ownUser.value : this.ownUser, totalUnreadCount: totalUnreadCount.present ? totalUnreadCount.value @@ -5062,6 +4516,7 @@ class ConnectionEventEntity extends DataClass String toString() { return (StringBuffer('ConnectionEventEntity(') ..write('id: $id, ') + ..write('type: $type, ') ..write('ownUser: $ownUser, ') ..write('totalUnreadCount: $totalUnreadCount, ') ..write('unreadChannels: $unreadChannels, ') @@ -5075,16 +4530,19 @@ class ConnectionEventEntity extends DataClass int get hashCode => $mrjf($mrjc( id.hashCode, $mrjc( - ownUser.hashCode, + type.hashCode, $mrjc( - totalUnreadCount.hashCode, - $mrjc(unreadChannels.hashCode, - $mrjc(lastEventAt.hashCode, lastSyncAt.hashCode)))))); + ownUser.hashCode, + $mrjc( + totalUnreadCount.hashCode, + $mrjc(unreadChannels.hashCode, + $mrjc(lastEventAt.hashCode, lastSyncAt.hashCode))))))); @override - bool operator ==(dynamic other) => + bool operator ==(Object other) => identical(this, other) || (other is ConnectionEventEntity && other.id == this.id && + other.type == this.type && other.ownUser == this.ownUser && other.totalUnreadCount == this.totalUnreadCount && other.unreadChannels == this.unreadChannels && @@ -5094,13 +4552,15 @@ class ConnectionEventEntity extends DataClass class ConnectionEventsCompanion extends UpdateCompanion { final Value id; - final Value> ownUser; - final Value totalUnreadCount; - final Value unreadChannels; - final Value lastEventAt; - final Value lastSyncAt; + final Value type; + final Value?> ownUser; + final Value totalUnreadCount; + final Value unreadChannels; + final Value lastEventAt; + final Value lastSyncAt; const ConnectionEventsCompanion({ this.id = const Value.absent(), + this.type = const Value.absent(), this.ownUser = const Value.absent(), this.totalUnreadCount = const Value.absent(), this.unreadChannels = const Value.absent(), @@ -5109,22 +4569,25 @@ class ConnectionEventsCompanion extends UpdateCompanion { }); ConnectionEventsCompanion.insert({ this.id = const Value.absent(), + required String type, this.ownUser = const Value.absent(), this.totalUnreadCount = const Value.absent(), this.unreadChannels = const Value.absent(), this.lastEventAt = const Value.absent(), this.lastSyncAt = const Value.absent(), - }); + }) : type = Value(type); static Insertable custom({ - Expression id, - Expression ownUser, - Expression totalUnreadCount, - Expression unreadChannels, - Expression lastEventAt, - Expression lastSyncAt, + Expression? id, + Expression? type, + Expression?>? ownUser, + Expression? totalUnreadCount, + Expression? unreadChannels, + Expression? lastEventAt, + Expression? lastSyncAt, }) { return RawValuesInsertable({ if (id != null) 'id': id, + if (type != null) 'type': type, if (ownUser != null) 'own_user': ownUser, if (totalUnreadCount != null) 'total_unread_count': totalUnreadCount, if (unreadChannels != null) 'unread_channels': unreadChannels, @@ -5134,14 +4597,16 @@ class ConnectionEventsCompanion extends UpdateCompanion { } ConnectionEventsCompanion copyWith( - {Value id, - Value> ownUser, - Value totalUnreadCount, - Value unreadChannels, - Value lastEventAt, - Value lastSyncAt}) { + {Value? id, + Value? type, + Value?>? ownUser, + Value? totalUnreadCount, + Value? unreadChannels, + Value? lastEventAt, + Value? lastSyncAt}) { return ConnectionEventsCompanion( id: id ?? this.id, + type: type ?? this.type, ownUser: ownUser ?? this.ownUser, totalUnreadCount: totalUnreadCount ?? this.totalUnreadCount, unreadChannels: unreadChannels ?? this.unreadChannels, @@ -5156,21 +4621,24 @@ class ConnectionEventsCompanion extends UpdateCompanion { if (id.present) { map['id'] = Variable(id.value); } + if (type.present) { + map['type'] = Variable(type.value); + } if (ownUser.present) { final converter = $ConnectionEventsTable.$converter0; - map['own_user'] = Variable(converter.mapToSql(ownUser.value)); + map['own_user'] = Variable(converter.mapToSql(ownUser.value)); } if (totalUnreadCount.present) { - map['total_unread_count'] = Variable(totalUnreadCount.value); + map['total_unread_count'] = Variable(totalUnreadCount.value); } if (unreadChannels.present) { - map['unread_channels'] = Variable(unreadChannels.value); + map['unread_channels'] = Variable(unreadChannels.value); } if (lastEventAt.present) { - map['last_event_at'] = Variable(lastEventAt.value); + map['last_event_at'] = Variable(lastEventAt.value); } if (lastSyncAt.present) { - map['last_sync_at'] = Variable(lastSyncAt.value); + map['last_sync_at'] = Variable(lastSyncAt.value); } return map; } @@ -5179,6 +4647,7 @@ class ConnectionEventsCompanion extends UpdateCompanion { String toString() { return (StringBuffer('ConnectionEventsCompanion(') ..write('id: $id, ') + ..write('type: $type, ') ..write('ownUser: $ownUser, ') ..write('totalUnreadCount: $totalUnreadCount, ') ..write('unreadChannels: $unreadChannels, ') @@ -5192,96 +4661,55 @@ class ConnectionEventsCompanion extends UpdateCompanion { class $ConnectionEventsTable extends ConnectionEvents with TableInfo<$ConnectionEventsTable, ConnectionEventEntity> { final GeneratedDatabase _db; - final String _alias; + final String? _alias; $ConnectionEventsTable(this._db, [this._alias]); final VerificationMeta _idMeta = const VerificationMeta('id'); - GeneratedIntColumn _id; - @override - GeneratedIntColumn get id => _id ??= _constructId(); - GeneratedIntColumn _constructId() { - return GeneratedIntColumn( - 'id', - $tableName, - false, - ); - } - + late final GeneratedColumn id = GeneratedColumn( + 'id', aliasedName, false, + typeName: 'INTEGER', requiredDuringInsert: false); + final VerificationMeta _typeMeta = const VerificationMeta('type'); + late final GeneratedColumn type = GeneratedColumn( + 'type', aliasedName, false, + typeName: 'TEXT', requiredDuringInsert: true); final VerificationMeta _ownUserMeta = const VerificationMeta('ownUser'); - GeneratedTextColumn _ownUser; - @override - GeneratedTextColumn get ownUser => _ownUser ??= _constructOwnUser(); - GeneratedTextColumn _constructOwnUser() { - return GeneratedTextColumn( - 'own_user', - $tableName, - true, - ); - } - + late final GeneratedColumnWithTypeConverter, String?> + ownUser = GeneratedColumn('own_user', aliasedName, true, + typeName: 'TEXT', requiredDuringInsert: false) + .withConverter>( + $ConnectionEventsTable.$converter0); final VerificationMeta _totalUnreadCountMeta = const VerificationMeta('totalUnreadCount'); - GeneratedIntColumn _totalUnreadCount; - @override - GeneratedIntColumn get totalUnreadCount => - _totalUnreadCount ??= _constructTotalUnreadCount(); - GeneratedIntColumn _constructTotalUnreadCount() { - return GeneratedIntColumn( - 'total_unread_count', - $tableName, - true, - ); - } - + late final GeneratedColumn totalUnreadCount = GeneratedColumn( + 'total_unread_count', aliasedName, true, + typeName: 'INTEGER', requiredDuringInsert: false); final VerificationMeta _unreadChannelsMeta = const VerificationMeta('unreadChannels'); - GeneratedIntColumn _unreadChannels; - @override - GeneratedIntColumn get unreadChannels => - _unreadChannels ??= _constructUnreadChannels(); - GeneratedIntColumn _constructUnreadChannels() { - return GeneratedIntColumn( - 'unread_channels', - $tableName, - true, - ); - } - + late final GeneratedColumn unreadChannels = GeneratedColumn( + 'unread_channels', aliasedName, true, + typeName: 'INTEGER', requiredDuringInsert: false); final VerificationMeta _lastEventAtMeta = const VerificationMeta('lastEventAt'); - GeneratedDateTimeColumn _lastEventAt; - @override - GeneratedDateTimeColumn get lastEventAt => - _lastEventAt ??= _constructLastEventAt(); - GeneratedDateTimeColumn _constructLastEventAt() { - return GeneratedDateTimeColumn( - 'last_event_at', - $tableName, - true, - ); - } - + late final GeneratedColumn lastEventAt = + GeneratedColumn('last_event_at', aliasedName, true, + typeName: 'INTEGER', requiredDuringInsert: false); final VerificationMeta _lastSyncAtMeta = const VerificationMeta('lastSyncAt'); - GeneratedDateTimeColumn _lastSyncAt; + late final GeneratedColumn lastSyncAt = GeneratedColumn( + 'last_sync_at', aliasedName, true, + typeName: 'INTEGER', requiredDuringInsert: false); @override - GeneratedDateTimeColumn get lastSyncAt => - _lastSyncAt ??= _constructLastSyncAt(); - GeneratedDateTimeColumn _constructLastSyncAt() { - return GeneratedDateTimeColumn( - 'last_sync_at', - $tableName, - true, - ); - } - + List get $columns => [ + id, + type, + ownUser, + totalUnreadCount, + unreadChannels, + lastEventAt, + lastSyncAt + ]; @override - List get $columns => - [id, ownUser, totalUnreadCount, unreadChannels, lastEventAt, lastSyncAt]; + String get aliasedName => _alias ?? 'connection_events'; @override - $ConnectionEventsTable get asDslTable => this; - @override - String get $tableName => _alias ?? 'connection_events'; - @override - final String actualTableName = 'connection_events'; + String get actualTableName => 'connection_events'; @override VerificationContext validateIntegrity( Insertable instance, @@ -5289,32 +4717,38 @@ class $ConnectionEventsTable extends ConnectionEvents final context = VerificationContext(); final data = instance.toColumns(true); if (data.containsKey('id')) { - context.handle(_idMeta, id.isAcceptableOrUnknown(data['id'], _idMeta)); + context.handle(_idMeta, id.isAcceptableOrUnknown(data['id']!, _idMeta)); + } + if (data.containsKey('type')) { + context.handle( + _typeMeta, type.isAcceptableOrUnknown(data['type']!, _typeMeta)); + } else if (isInserting) { + context.missing(_typeMeta); } context.handle(_ownUserMeta, const VerificationResult.success()); if (data.containsKey('total_unread_count')) { context.handle( _totalUnreadCountMeta, totalUnreadCount.isAcceptableOrUnknown( - data['total_unread_count'], _totalUnreadCountMeta)); + data['total_unread_count']!, _totalUnreadCountMeta)); } if (data.containsKey('unread_channels')) { context.handle( _unreadChannelsMeta, unreadChannels.isAcceptableOrUnknown( - data['unread_channels'], _unreadChannelsMeta)); + data['unread_channels']!, _unreadChannelsMeta)); } if (data.containsKey('last_event_at')) { context.handle( _lastEventAtMeta, lastEventAt.isAcceptableOrUnknown( - data['last_event_at'], _lastEventAtMeta)); + data['last_event_at']!, _lastEventAtMeta)); } if (data.containsKey('last_sync_at')) { context.handle( _lastSyncAtMeta, lastSyncAt.isAcceptableOrUnknown( - data['last_sync_at'], _lastSyncAtMeta)); + data['last_sync_at']!, _lastSyncAtMeta)); } return context; } @@ -5322,9 +4756,9 @@ class $ConnectionEventsTable extends ConnectionEvents @override Set get $primaryKey => {id}; @override - ConnectionEventEntity map(Map data, {String tablePrefix}) { - final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : null; - return ConnectionEventEntity.fromData(data, _db, prefix: effectivePrefix); + ConnectionEventEntity map(Map data, {String? tablePrefix}) { + return ConnectionEventEntity.fromData(data, _db, + prefix: tablePrefix != null ? '$tablePrefix.' : null); } @override @@ -5332,58 +4766,35 @@ class $ConnectionEventsTable extends ConnectionEvents return $ConnectionEventsTable(_db, alias); } - static TypeConverter, String> $converter0 = - MapConverter(); + static TypeConverter, String> $converter0 = + MapConverter(); } abstract class _$MoorChatDatabase extends GeneratedDatabase { _$MoorChatDatabase(QueryExecutor e) : super(SqlTypeSystem.defaultInstance, e); _$MoorChatDatabase.connect(DatabaseConnection c) : super.connect(c); - $ChannelsTable _channels; - $ChannelsTable get channels => _channels ??= $ChannelsTable(this); - $MessagesTable _messages; - $MessagesTable get messages => _messages ??= $MessagesTable(this); - $PinnedMessagesTable _pinnedMessages; - $PinnedMessagesTable get pinnedMessages => - _pinnedMessages ??= $PinnedMessagesTable(this); - $ReactionsTable _reactions; - $ReactionsTable get reactions => _reactions ??= $ReactionsTable(this); - $UsersTable _users; - $UsersTable get users => _users ??= $UsersTable(this); - $MembersTable _members; - $MembersTable get members => _members ??= $MembersTable(this); - $ReadsTable _reads; - $ReadsTable get reads => _reads ??= $ReadsTable(this); - $ChannelQueriesTable _channelQueries; - $ChannelQueriesTable get channelQueries => - _channelQueries ??= $ChannelQueriesTable(this); - $ConnectionEventsTable _connectionEvents; - $ConnectionEventsTable get connectionEvents => - _connectionEvents ??= $ConnectionEventsTable(this); - UserDao _userDao; - UserDao get userDao => _userDao ??= UserDao(this as MoorChatDatabase); - ChannelDao _channelDao; - ChannelDao get channelDao => - _channelDao ??= ChannelDao(this as MoorChatDatabase); - MessageDao _messageDao; - MessageDao get messageDao => - _messageDao ??= MessageDao(this as MoorChatDatabase); - PinnedMessageDao _pinnedMessageDao; - PinnedMessageDao get pinnedMessageDao => - _pinnedMessageDao ??= PinnedMessageDao(this as MoorChatDatabase); - MemberDao _memberDao; - MemberDao get memberDao => _memberDao ??= MemberDao(this as MoorChatDatabase); - ReactionDao _reactionDao; - ReactionDao get reactionDao => - _reactionDao ??= ReactionDao(this as MoorChatDatabase); - ReadDao _readDao; - ReadDao get readDao => _readDao ??= ReadDao(this as MoorChatDatabase); - ChannelQueryDao _channelQueryDao; - ChannelQueryDao get channelQueryDao => - _channelQueryDao ??= ChannelQueryDao(this as MoorChatDatabase); - ConnectionEventDao _connectionEventDao; - ConnectionEventDao get connectionEventDao => - _connectionEventDao ??= ConnectionEventDao(this as MoorChatDatabase); + late final $ChannelsTable channels = $ChannelsTable(this); + late final $MessagesTable messages = $MessagesTable(this); + late final $PinnedMessagesTable pinnedMessages = $PinnedMessagesTable(this); + late final $ReactionsTable reactions = $ReactionsTable(this); + late final $UsersTable users = $UsersTable(this); + late final $MembersTable members = $MembersTable(this); + late final $ReadsTable reads = $ReadsTable(this); + late final $ChannelQueriesTable channelQueries = $ChannelQueriesTable(this); + late final $ConnectionEventsTable connectionEvents = + $ConnectionEventsTable(this); + late final UserDao userDao = UserDao(this as MoorChatDatabase); + late final ChannelDao channelDao = ChannelDao(this as MoorChatDatabase); + late final MessageDao messageDao = MessageDao(this as MoorChatDatabase); + late final PinnedMessageDao pinnedMessageDao = + PinnedMessageDao(this as MoorChatDatabase); + late final MemberDao memberDao = MemberDao(this as MoorChatDatabase); + late final ReactionDao reactionDao = ReactionDao(this as MoorChatDatabase); + late final ReadDao readDao = ReadDao(this as MoorChatDatabase); + late final ChannelQueryDao channelQueryDao = + ChannelQueryDao(this as MoorChatDatabase); + late final ConnectionEventDao connectionEventDao = + ConnectionEventDao(this as MoorChatDatabase); @override Iterable get allTables => allSchemaEntities.whereType(); @override diff --git a/packages/stream_chat_persistence/lib/src/entity/channels.dart b/packages/stream_chat_persistence/lib/src/entity/channels.dart index 6f60fac3..2c65719b 100644 --- a/packages/stream_chat_persistence/lib/src/entity/channels.dart +++ b/packages/stream_chat_persistence/lib/src/entity/channels.dart @@ -15,7 +15,7 @@ class Channels extends Table { TextColumn get cid => text()(); /// The channel configuration data - TextColumn get config => text().map(MapConverter())(); + TextColumn get config => text().map(MapConverter())(); /// True if this channel entity is frozen BoolColumn get frozen => boolean().withDefault(const Constant(false))(); @@ -24,22 +24,22 @@ class Channels extends Table { DateTimeColumn get lastMessageAt => dateTime().nullable()(); /// The date of channel creation - DateTimeColumn get createdAt => dateTime().nullable()(); + DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)(); /// The date of the last channel update - DateTimeColumn get updatedAt => dateTime().nullable()(); + DateTimeColumn get updatedAt => dateTime().withDefault(currentDateAndTime)(); /// The date of channel deletion DateTimeColumn get deletedAt => dateTime().nullable()(); /// The count of this channel members - IntColumn get memberCount => integer().nullable()(); + IntColumn get memberCount => integer().withDefault(const Constant(0))(); /// The id of the user that created this channel TextColumn get createdById => text().nullable()(); /// Map of custom channel extraData - TextColumn get extraData => text().nullable().map(MapConverter())(); + TextColumn get extraData => text().nullable().map(MapConverter())(); @override Set get primaryKey => {cid}; diff --git a/packages/stream_chat_persistence/lib/src/entity/connection_events.dart b/packages/stream_chat_persistence/lib/src/entity/connection_events.dart index 69538edc..9e91a9cb 100644 --- a/packages/stream_chat_persistence/lib/src/entity/connection_events.dart +++ b/packages/stream_chat_persistence/lib/src/entity/connection_events.dart @@ -8,8 +8,11 @@ class ConnectionEvents extends Table { /// event id IntColumn get id => integer()(); + /// event type + TextColumn get type => text()(); + /// User object of the current user - TextColumn get ownUser => text().nullable().map(MapConverter())(); + TextColumn get ownUser => text().nullable().map(MapConverter())(); /// The number of unread messages for current user IntColumn get totalUnreadCount => integer().nullable()(); diff --git a/packages/stream_chat_persistence/lib/src/entity/members.dart b/packages/stream_chat_persistence/lib/src/entity/members.dart index c7316640..8d3d4a57 100644 --- a/packages/stream_chat_persistence/lib/src/entity/members.dart +++ b/packages/stream_chat_persistence/lib/src/entity/members.dart @@ -21,22 +21,22 @@ class Members extends Table { DateTimeColumn get inviteRejectedAt => dateTime().nullable()(); /// True if the user has been invited to the channel - BoolColumn get invited => boolean().nullable()(); + BoolColumn get invited => boolean().withDefault(const Constant(false))(); /// True if the member is banned from the channel - BoolColumn get banned => boolean().nullable()(); + BoolColumn get banned => boolean().withDefault(const Constant(false))(); /// True if the member is shadow banned from the channel - BoolColumn get shadowBanned => boolean().nullable()(); + BoolColumn get shadowBanned => boolean().withDefault(const Constant(false))(); /// True if the user is a moderator of the channel - BoolColumn get isModerator => boolean().nullable()(); + BoolColumn get isModerator => boolean().withDefault(const Constant(false))(); /// The date of creation - DateTimeColumn get createdAt => dateTime()(); + DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)(); /// The last date of update - DateTimeColumn get updatedAt => dateTime().nullable()(); + DateTimeColumn get updatedAt => dateTime().withDefault(currentDateAndTime)(); @override Set get primaryKey => { diff --git a/packages/stream_chat_persistence/lib/src/entity/messages.dart b/packages/stream_chat_persistence/lib/src/entity/messages.dart index 8840d85f..4006d6ce 100644 --- a/packages/stream_chat_persistence/lib/src/entity/messages.dart +++ b/packages/stream_chat_persistence/lib/src/entity/messages.dart @@ -15,19 +15,18 @@ class Messages extends Table { /// The list of attachments, either provided by the user /// or generated from a command or as a result of URL scraping. - TextColumn get attachments => - text().nullable().map(ListConverter())(); + TextColumn get attachments => text().map(ListConverter())(); /// The status of a sending message - IntColumn get status => - integer().nullable().map(MessageSendingStatusConverter())(); + IntColumn get status => integer() + .withDefault(const Constant(1)) + .map(MessageSendingStatusConverter())(); /// The message type - TextColumn get type => text().nullable()(); + TextColumn get type => text().withDefault(const Constant('regular'))(); /// The list of user mentioned in the message - TextColumn get mentionedUsers => - text().nullable().map(ListConverter())(); + TextColumn get mentionedUsers => text().map(ListConverter())(); /// A map describing the count of number of every reaction TextColumn get reactionCounts => text().nullable().map(MapConverter())(); @@ -48,16 +47,16 @@ class Messages extends Table { BoolColumn get showInChannel => boolean().nullable()(); /// If true the message is shadowed - BoolColumn get shadowed => boolean().nullable()(); + BoolColumn get shadowed => boolean().withDefault(const Constant(false))(); /// A used command name. TextColumn get command => text().nullable()(); /// The DateTime when the message was created. - DateTimeColumn get createdAt => dateTime()(); + DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)(); /// The DateTime when the message was updated last time. - DateTimeColumn get updatedAt => dateTime().nullable()(); + DateTimeColumn get updatedAt => dateTime().withDefault(currentDateAndTime)(); /// The DateTime when the message was deleted. DateTimeColumn get deletedAt => dateTime().nullable()(); @@ -82,7 +81,7 @@ class Messages extends Table { 'NULLABLE REFERENCES channels(cid) ON DELETE CASCADE')(); /// Message custom extraData - TextColumn get extraData => text().nullable().map(MapConverter())(); + TextColumn get extraData => text().nullable().map(MapConverter())(); @override Set get primaryKey => {id}; diff --git a/packages/stream_chat_persistence/lib/src/entity/reactions.dart b/packages/stream_chat_persistence/lib/src/entity/reactions.dart index 53cbdd11..62380408 100644 --- a/packages/stream_chat_persistence/lib/src/entity/reactions.dart +++ b/packages/stream_chat_persistence/lib/src/entity/reactions.dart @@ -16,13 +16,13 @@ class Reactions extends Table { TextColumn get type => text()(); /// The DateTime on which the reaction is created - DateTimeColumn get createdAt => dateTime()(); + DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)(); /// The score of the reaction (ie. number of reactions sent) - IntColumn get score => integer().nullable()(); + IntColumn get score => integer().withDefault(const Constant(0))(); /// Reaction custom extraData - TextColumn get extraData => text().nullable().map(MapConverter())(); + TextColumn get extraData => text().nullable().map(MapConverter())(); @override Set get primaryKey => { diff --git a/packages/stream_chat_persistence/lib/src/entity/reads.dart b/packages/stream_chat_persistence/lib/src/entity/reads.dart index 7943ed9e..1f59b3c8 100644 --- a/packages/stream_chat_persistence/lib/src/entity/reads.dart +++ b/packages/stream_chat_persistence/lib/src/entity/reads.dart @@ -15,7 +15,7 @@ class Reads extends Table { text().customConstraint('REFERENCES channels(cid) ON DELETE CASCADE')(); /// Number of unread messages - IntColumn get unreadMessages => integer().nullable()(); + IntColumn get unreadMessages => integer().withDefault(const Constant(0))(); @override Set get primaryKey => { diff --git a/packages/stream_chat_persistence/lib/src/entity/users.dart b/packages/stream_chat_persistence/lib/src/entity/users.dart index 7ece9600..303092c9 100644 --- a/packages/stream_chat_persistence/lib/src/entity/users.dart +++ b/packages/stream_chat_persistence/lib/src/entity/users.dart @@ -12,22 +12,22 @@ class Users extends Table { TextColumn get role => text().nullable()(); /// Date of user creation - DateTimeColumn get createdAt => dateTime().nullable()(); + DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)(); /// Date of last user update - DateTimeColumn get updatedAt => dateTime().nullable()(); + DateTimeColumn get updatedAt => dateTime().withDefault(currentDateAndTime)(); /// Date of last user connection DateTimeColumn get lastActive => dateTime().nullable()(); /// True if user is online - BoolColumn get online => boolean().nullable()(); + BoolColumn get online => boolean().withDefault(const Constant(false))(); /// True if user is banned from the chat - BoolColumn get banned => boolean().nullable()(); + BoolColumn get banned => boolean().withDefault(const Constant(false))(); /// Map of custom user extraData - TextColumn get extraData => text().nullable().map(MapConverter())(); + TextColumn get extraData => text().map(MapConverter())(); @override Set get primaryKey => {id}; diff --git a/packages/stream_chat_persistence/lib/src/mapper/channel_mapper.dart b/packages/stream_chat_persistence/lib/src/mapper/channel_mapper.dart index 65315519..71f5b708 100644 --- a/packages/stream_chat_persistence/lib/src/mapper/channel_mapper.dart +++ b/packages/stream_chat_persistence/lib/src/mapper/channel_mapper.dart @@ -4,8 +4,8 @@ import 'package:stream_chat_persistence/src/db/moor_chat_database.dart'; /// Useful mapping functions for [ChannelEntity] extension ChannelEntityX on ChannelEntity { /// Maps a [ChannelEntity] into [ChannelModel] - ChannelModel toChannelModel({User createdBy}) { - final config = ChannelConfig.fromJson(this.config ?? {}); + ChannelModel toChannelModel({User? createdBy}) { + final config = ChannelConfig.fromJson(this.config); return ChannelModel( id: id, config: config, @@ -17,18 +17,18 @@ extension ChannelEntityX on ChannelEntity { cid: cid, lastMessageAt: lastMessageAt, deletedAt: deletedAt, - extraData: extraData, + extraData: extraData ?? {}, createdBy: createdBy, ); } /// Maps a [ChannelEntity] into [ChannelState] ChannelState toChannelState({ - User createdBy, - List members, - List reads, - List messages, - List pinnedMessages, + User? createdBy, + List members = const [], + List reads = const [], + List messages = const [], + List pinnedMessages = const [], }) => ChannelState( members: members, @@ -46,7 +46,7 @@ extension ChannelModelX on ChannelModel { id: id, type: type, cid: cid, - config: config?.toJson(), + config: config.toJson(), frozen: frozen, lastMessageAt: lastMessageAt, createdAt: createdAt, diff --git a/packages/stream_chat_persistence/lib/src/mapper/event_mapper.dart b/packages/stream_chat_persistence/lib/src/mapper/event_mapper.dart index fc2a57ba..f0350797 100644 --- a/packages/stream_chat_persistence/lib/src/mapper/event_mapper.dart +++ b/packages/stream_chat_persistence/lib/src/mapper/event_mapper.dart @@ -5,7 +5,9 @@ import 'package:stream_chat_persistence/src/db/moor_chat_database.dart'; extension ConnectionEventX on ConnectionEventEntity { /// Maps a [ConnectionEventEntity] into [Event] Event toEvent() => Event( - me: ownUser != null ? OwnUser.fromJson(ownUser) : null, + type: type, + createdAt: lastEventAt, + me: ownUser != null ? OwnUser.fromJson(ownUser!) : null, totalUnreadCount: totalUnreadCount, unreadChannels: unreadChannels, ); diff --git a/packages/stream_chat_persistence/lib/src/mapper/member_mapper.dart b/packages/stream_chat_persistence/lib/src/mapper/member_mapper.dart index cbdcc297..414cab16 100644 --- a/packages/stream_chat_persistence/lib/src/mapper/member_mapper.dart +++ b/packages/stream_chat_persistence/lib/src/mapper/member_mapper.dart @@ -4,7 +4,7 @@ import 'package:stream_chat_persistence/src/db/moor_chat_database.dart'; /// Useful mapping functions for [MemberEntity] extension MemberEntityX on MemberEntity { /// Maps a [MemberEntity] into [Member] - Member toMember({User user}) => Member( + Member toMember({User? user}) => Member( user: user, userId: userId, banned: banned, @@ -22,8 +22,8 @@ extension MemberEntityX on MemberEntity { /// Useful mapping functions for [Member] extension MemberX on Member { /// Maps a [Member] into [MemberEntity] - MemberEntity toEntity({String cid}) => MemberEntity( - userId: user?.id, + MemberEntity toEntity({required String cid}) => MemberEntity( + userId: user!.id, banned: banned, shadowBanned: shadowBanned, channelCid: cid, diff --git a/packages/stream_chat_persistence/lib/src/mapper/message_mapper.dart b/packages/stream_chat_persistence/lib/src/mapper/message_mapper.dart index fdf93cc5..558c6e45 100644 --- a/packages/stream_chat_persistence/lib/src/mapper/message_mapper.dart +++ b/packages/stream_chat_persistence/lib/src/mapper/message_mapper.dart @@ -7,22 +7,22 @@ import 'package:stream_chat_persistence/src/db/moor_chat_database.dart'; extension MessageEntityX on MessageEntity { /// Maps a [MessageEntity] into [Message] Message toMessage({ - User user, - User pinnedBy, - List latestReactions, - List ownReactions, - Message quotedMessage, + User? user, + User? pinnedBy, + List? latestReactions, + List? ownReactions, + Message? quotedMessage, }) => Message( shadowed: shadowed, latestReactions: latestReactions, ownReactions: ownReactions, - attachments: attachments?.map((it) { + attachments: attachments.map((it) { final json = jsonDecode(it); return Attachment.fromData(json); - })?.toList(), + }).toList(), createdAt: createdAt, - extraData: extraData, + extraData: extraData ?? {}, updatedAt: updatedAt, id: id, type: type, @@ -42,16 +42,17 @@ extension MessageEntityX on MessageEntity { pinnedAt: pinnedAt, pinExpires: pinExpires, pinnedBy: pinnedBy, + mentionedUsers: + mentionedUsers.map((e) => User.fromJson(jsonDecode(e))).toList(), ); } /// Useful mapping functions for [Message] extension MessageX on Message { /// Maps a [Message] into [MessageEntity] - MessageEntity toEntity({String cid}) => MessageEntity( + MessageEntity toEntity({String? cid}) => MessageEntity( id: id, - attachments: - attachments?.map((it) => jsonEncode(it.toData()))?.toList(), + attachments: attachments.map((it) => jsonEncode(it.toData())).toList(), channelCid: cid, type: type, parentId: parentId, @@ -63,6 +64,7 @@ extension MessageX on Message { replyCount: replyCount, reactionScores: reactionScores, reactionCounts: reactionCounts, + mentionedUsers: mentionedUsers.map(jsonEncode).toList(), status: status, updatedAt: updatedAt, extraData: extraData, diff --git a/packages/stream_chat_persistence/lib/src/mapper/pinned_message_mapper.dart b/packages/stream_chat_persistence/lib/src/mapper/pinned_message_mapper.dart index ec2a0e91..1083e239 100644 --- a/packages/stream_chat_persistence/lib/src/mapper/pinned_message_mapper.dart +++ b/packages/stream_chat_persistence/lib/src/mapper/pinned_message_mapper.dart @@ -7,22 +7,22 @@ import 'package:stream_chat_persistence/src/db/moor_chat_database.dart'; extension PinnedMessageEntityX on PinnedMessageEntity { /// Maps a [PinnedMessageEntity] into [Message] Message toMessage({ - User user, - User pinnedBy, - List latestReactions, - List ownReactions, - Message quotedMessage, + User? user, + User? pinnedBy, + List? latestReactions, + List? ownReactions, + Message? quotedMessage, }) => Message( shadowed: shadowed, latestReactions: latestReactions, ownReactions: ownReactions, - attachments: attachments?.map((it) { + attachments: attachments.map((it) { final json = jsonDecode(it); return Attachment.fromData(json); - })?.toList(), + }).toList(), createdAt: createdAt, - extraData: extraData, + extraData: extraData ?? {}, updatedAt: updatedAt, id: id, type: type, @@ -48,10 +48,9 @@ extension PinnedMessageEntityX on PinnedMessageEntity { /// Useful mapping functions for [Message] extension PMessageX on Message { /// Maps a [Message] into [PinnedMessageEntity] - PinnedMessageEntity toPinnedEntity({String cid}) => PinnedMessageEntity( + PinnedMessageEntity toPinnedEntity({String? cid}) => PinnedMessageEntity( id: id, - attachments: - attachments?.map((it) => jsonEncode(it.toData()))?.toList(), + attachments: attachments.map((it) => jsonEncode(it.toData())).toList(), channelCid: cid, type: type, parentId: parentId, @@ -63,6 +62,7 @@ extension PMessageX on Message { replyCount: replyCount, reactionScores: reactionScores, reactionCounts: reactionCounts, + mentionedUsers: mentionedUsers.map(jsonEncode).toList(), status: status, updatedAt: updatedAt, extraData: extraData, diff --git a/packages/stream_chat_persistence/lib/src/mapper/reaction_mapper.dart b/packages/stream_chat_persistence/lib/src/mapper/reaction_mapper.dart index 20842307..62a0c427 100644 --- a/packages/stream_chat_persistence/lib/src/mapper/reaction_mapper.dart +++ b/packages/stream_chat_persistence/lib/src/mapper/reaction_mapper.dart @@ -4,8 +4,8 @@ import 'package:stream_chat_persistence/src/db/moor_chat_database.dart'; /// Useful mapping functions for [ReactionEntity] extension ReactionEntityX on ReactionEntity { /// Maps a [ReactionEntity] into [Reaction] - Reaction toReaction({User user}) => Reaction( - extraData: extraData, + Reaction toReaction({User? user}) => Reaction( + extraData: extraData ?? {}, type: type, createdAt: createdAt, userId: userId, @@ -22,8 +22,8 @@ extension ReactionX on Reaction { extraData: extraData, type: type, createdAt: createdAt, - userId: userId, - messageId: messageId, + userId: userId!, + messageId: messageId!, score: score, ); } diff --git a/packages/stream_chat_persistence/lib/src/mapper/read_mapper.dart b/packages/stream_chat_persistence/lib/src/mapper/read_mapper.dart index 9664030e..889776e2 100644 --- a/packages/stream_chat_persistence/lib/src/mapper/read_mapper.dart +++ b/packages/stream_chat_persistence/lib/src/mapper/read_mapper.dart @@ -4,7 +4,7 @@ import 'package:stream_chat_persistence/src/db/moor_chat_database.dart'; /// Useful mapping functions for [ReadEntity] extension ReadEntityX on ReadEntity { /// Maps a [ReadEntity] into [Read] - Read toRead({User user}) => Read( + Read toRead({required User user}) => Read( user: user, lastRead: lastRead, unreadMessages: unreadMessages, @@ -14,9 +14,9 @@ extension ReadEntityX on ReadEntity { /// Useful mapping functions for [Read] extension ReadX on Read { /// Maps a [Read] into [ReadEntity] - ReadEntity toEntity({String cid}) => ReadEntity( + ReadEntity toEntity({required String cid}) => ReadEntity( lastRead: lastRead, - userId: user?.id, + userId: user.id, channelCid: cid, unreadMessages: unreadMessages, ); diff --git a/packages/stream_chat_persistence/lib/src/stream_chat_persistence_client.dart b/packages/stream_chat_persistence/lib/src/stream_chat_persistence_client.dart index 2c9d28fb..20c4e327 100644 --- a/packages/stream_chat_persistence/lib/src/stream_chat_persistence_client.dart +++ b/packages/stream_chat_persistence/lib/src/stream_chat_persistence_client.dart @@ -1,3 +1,4 @@ +import 'package:flutter/foundation.dart'; import 'package:logging/logging.dart' show LogRecord; import 'package:meta/meta.dart'; import 'package:mutex/mutex.dart'; @@ -30,17 +31,15 @@ class StreamChatPersistenceClient extends ChatPersistenceClient { /// Connection mode on which the client will work ConnectionMode connectionMode = ConnectionMode.regular, Level logLevel = Level.WARNING, - LogHandlerFunction logHandlerFunction, - }) : assert(connectionMode != null, 'ConnectionMode cannot be null'), - assert(logLevel != null, 'LogLevel cannot be null'), - _connectionMode = connectionMode, + LogHandlerFunction? logHandlerFunction, + }) : _connectionMode = connectionMode, _logger = Logger.detached('💽')..level = logLevel { _logger.onRecord.listen(logHandlerFunction ?? _defaultLogHandler); } /// [MoorChatDatabase] instance used by this client. @visibleForTesting - MoorChatDatabase db; + MoorChatDatabase? db; final Logger _logger; final ConnectionMode _connectionMode; @@ -55,15 +54,20 @@ class StreamChatPersistenceClient extends ChatPersistenceClient { if (record.stackTrace != null) print(record.stackTrace); } - Future _readProtected(Future Function() f) async { - T ret; - await _mutex.protectRead(() async { + Future _readProtected(AsyncValueGetter func) => + _mutex.protectRead(func); + + bool get _debugIsConnected { + assert(() { if (db == null) { - return; + throw StateError(''' + $runtimeType hasn't been connected yet or used after `disconnect` + was called. Consider calling `connect` to create a connection. + '''); } - ret = await f(); - }); - return ret; + return true; + }(), ''); + return true; } MoorChatDatabase _defaultDatabaseProvider( @@ -75,7 +79,7 @@ class StreamChatPersistenceClient extends ChatPersistenceClient { @override Future connect( String userId, { - DatabaseProvider databaseProvider, // Used only for testing + DatabaseProvider? databaseProvider, // Used only for testing }) async { if (db != null) { throw Exception( @@ -88,239 +92,281 @@ class StreamChatPersistenceClient extends ChatPersistenceClient { } @override - Future getConnectionInfo() => _readProtected(() { - _logger.info('getConnectionInfo'); - return db.connectionEventDao.connectionEvent; - }); + Future getConnectionInfo() { + assert(_debugIsConnected, ''); + _logger.info('getConnectionInfo'); + return _readProtected(() => db!.connectionEventDao.connectionEvent); + } @override - Future updateConnectionInfo(Event event) => _readProtected(() { - _logger.info('updateConnectionInfo'); - return db.connectionEventDao.updateConnectionEvent(event); - }); + Future updateConnectionInfo(Event event) { + assert(_debugIsConnected, ''); + _logger.info('updateConnectionInfo'); + return _readProtected( + () => db!.connectionEventDao.updateConnectionEvent(event), + ); + } @override - Future updateLastSyncAt(DateTime lastSyncAt) => _readProtected(() { - _logger.info('updateLastSyncAt'); - return db.connectionEventDao.updateLastSyncAt(lastSyncAt); - }); + Future updateLastSyncAt(DateTime lastSyncAt) { + assert(_debugIsConnected, ''); + _logger.info('updateLastSyncAt'); + return _readProtected( + () => db!.connectionEventDao.updateLastSyncAt(lastSyncAt), + ); + } @override - Future getLastSyncAt() => _readProtected(() { - _logger.info('getLastSyncAt'); - return db.connectionEventDao.lastSyncAt; - }); + Future getLastSyncAt() { + assert(_debugIsConnected, ''); + _logger.info('getLastSyncAt'); + return _readProtected(() => db!.connectionEventDao.lastSyncAt); + } @override - Future deleteChannels(List cids) => _readProtected(() { - _logger.info('deleteChannels'); - return db.channelDao.deleteChannelByCids(cids); - }); + Future deleteChannels(List cids) { + assert(_debugIsConnected, ''); + _logger.info('deleteChannels'); + return _readProtected(() => db!.channelDao.deleteChannelByCids(cids)); + } @override - Future> getChannelCids() => _readProtected(() { - _logger.info('getChannelCids'); - return db.channelDao.cids; - }); + Future> getChannelCids() { + assert(_debugIsConnected, ''); + _logger.info('getChannelCids'); + return _readProtected(() => db!.channelDao.cids); + } @override - Future deleteMessageByIds(List messageIds) => - _readProtected(() { - _logger.info('deleteMessageByIds'); - return db.messageDao.deleteMessageByIds(messageIds); - }); + Future deleteMessageByIds(List messageIds) { + assert(_debugIsConnected, ''); + _logger.info('deleteMessageByIds'); + return _readProtected(() => db!.messageDao.deleteMessageByIds(messageIds)); + } @override - Future deletePinnedMessageByIds(List messageIds) => - _readProtected(() { - _logger.info('deletePinnedMessageByIds'); - return db.pinnedMessageDao.deleteMessageByIds(messageIds); - }); + Future deletePinnedMessageByIds(List messageIds) { + assert(_debugIsConnected, ''); + _logger.info('deletePinnedMessageByIds'); + return _readProtected( + () => db!.pinnedMessageDao.deleteMessageByIds(messageIds), + ); + } @override - Future deleteMessageByCids(List cids) => _readProtected(() { - _logger.info('deleteMessageByCids'); - return db.messageDao.deleteMessageByCids(cids); - }); + Future deleteMessageByCids(List cids) { + assert(_debugIsConnected, ''); + _logger.info('deleteMessageByCids'); + return _readProtected(() => db!.messageDao.deleteMessageByCids(cids)); + } @override - Future deletePinnedMessageByCids(List cids) => - _readProtected(() { - _logger.info('deletePinnedMessageByCids'); - return db.pinnedMessageDao.deleteMessageByCids(cids); - }); + Future deletePinnedMessageByCids(List cids) { + assert(_debugIsConnected, ''); + _logger.info('deletePinnedMessageByCids'); + return _readProtected(() => db!.pinnedMessageDao.deleteMessageByCids(cids)); + } @override - Future> getMembersByCid(String cid) => _readProtected(() { - _logger.info('getMembersByCid'); - return db.memberDao.getMembersByCid(cid); - }); + Future> getMembersByCid(String cid) { + assert(_debugIsConnected, ''); + _logger.info('getMembersByCid'); + return _readProtected(() => db!.memberDao.getMembersByCid(cid)); + } @override - Future getChannelByCid(String cid) => _readProtected(() { - _logger.info('getChannelByCid'); - return db.channelDao.getChannelByCid(cid); - }); + Future getChannelByCid(String cid) { + assert(_debugIsConnected, ''); + _logger.info('getChannelByCid'); + return _readProtected(() => db!.channelDao.getChannelByCid(cid)); + } @override Future> getMessagesByCid( String cid, { - PaginationParams messagePagination, - }) => - _readProtected(() { - _logger.info('getMessagesByCid'); - return db.messageDao.getMessagesByCid( - cid, - messagePagination: messagePagination, - ); - }); + PaginationParams? messagePagination, + }) { + assert(_debugIsConnected, ''); + _logger.info('getMessagesByCid'); + return _readProtected( + () => db!.messageDao.getMessagesByCid( + cid, + messagePagination: messagePagination, + ), + ); + } @override Future> getPinnedMessagesByCid( String cid, { - PaginationParams messagePagination, - }) => - _readProtected(() { - _logger.info('getPinnedMessagesByCid'); - return db.pinnedMessageDao.getMessagesByCid( - cid, - messagePagination: messagePagination, - ); - }); + PaginationParams? messagePagination, + }) { + assert(_debugIsConnected, ''); + _logger.info('getPinnedMessagesByCid'); + return _readProtected( + () => db!.pinnedMessageDao.getMessagesByCid( + cid, + messagePagination: messagePagination, + ), + ); + } @override - Future> getReadsByCid(String cid) => _readProtected(() { - _logger.info('getReadsByCid'); - return db.readDao.getReadsByCid(cid); - }); + Future> getReadsByCid(String cid) { + assert(_debugIsConnected, ''); + _logger.info('getReadsByCid'); + return _readProtected(() => db!.readDao.getReadsByCid(cid)); + } @override - Future>> getChannelThreads(String cid) async => - _readProtected(() async { - _logger.info('getChannelThreads'); - final messages = await db.messageDao.getThreadMessages(cid); - final messageByParentIdDictionary = >{}; - for (final message in messages) { - final parentId = message.parentId; - messageByParentIdDictionary[parentId] = [ - ...messageByParentIdDictionary[parentId] ?? [], - message - ]; - } - return messageByParentIdDictionary; - }); + Future>> getChannelThreads(String cid) { + assert(_debugIsConnected, ''); + _logger.info('getChannelThreads'); + return _readProtected(() async { + final messages = await db!.messageDao.getThreadMessages(cid); + final messageByParentIdDictionary = >{}; + for (final message in messages) { + final parentId = message.parentId!; + messageByParentIdDictionary[parentId] = [ + ...messageByParentIdDictionary[parentId] ?? [], + message + ]; + } + return messageByParentIdDictionary; + }); + } @override Future> getReplies( String parentId, { - PaginationParams options, - }) => - _readProtected(() async { - _logger.info('getReplies'); - return db.messageDao.getThreadMessagesByParentId( - parentId, - options: options, - ); - }); + PaginationParams? options, + }) { + assert(_debugIsConnected, ''); + _logger.info('getReplies'); + return _readProtected( + () => db!.messageDao.getThreadMessagesByParentId( + parentId, + options: options, + ), + ); + } @override Future> getChannelStates({ - Map filter, - List> sort = const [], - PaginationParams paginationParams, - }) async => - _readProtected(() async { - _logger.info('getChannelStates'); - final channels = await db.channelQueryDao.getChannels( + Filter? filter, + List>? sort, + PaginationParams? paginationParams, + }) { + assert(_debugIsConnected, ''); + _logger.info('getChannelStates'); + return _readProtected( + () async { + final channels = await db!.channelQueryDao.getChannels( filter: filter, sort: sort, paginationParams: paginationParams, ); return Future.wait(channels.map((e) => getChannelStateByCid(e.cid))); - }); + }, + ); + } @override Future updateChannelQueries( - Map filter, + Filter? filter, List cids, { bool clearQueryCache = false, - }) => - _readProtected(() async { - _logger.info('updateChannelQueries'); - return db.channelQueryDao.updateChannelQueries( - filter, - cids, - clearQueryCache: clearQueryCache, - ); - }); + }) { + assert(_debugIsConnected, ''); + _logger.info('updateChannelQueries'); + return _readProtected( + () => db!.channelQueryDao.updateChannelQueries( + filter, + cids, + clearQueryCache: clearQueryCache, + ), + ); + } @override - Future updateChannels(List channels) => - _readProtected(() async { - _logger.info('updateChannels'); - return db.channelDao.updateChannels(channels); - }); + Future updateChannels(List channels) { + assert(_debugIsConnected, ''); + _logger.info('updateChannels'); + return _readProtected(() => db!.channelDao.updateChannels(channels)); + } @override - Future updateMembers(String cid, List members) => - _readProtected(() async { - _logger.info('updateMembers'); - return db.memberDao.updateMembers(cid, members); - }); + Future updateMembers(String cid, List members) { + assert(_debugIsConnected, ''); + _logger.info('updateMembers'); + return _readProtected(() => db!.memberDao.updateMembers(cid, members)); + } @override - Future updateMessages(String cid, List messages) => - _readProtected(() async { - _logger.info('updateMessages'); - return db.messageDao.updateMessages(cid, messages); - }); + Future updateMessages(String cid, List messages) { + assert(_debugIsConnected, ''); + _logger.info('updateMessages'); + return _readProtected(() => db!.messageDao.updateMessages(cid, messages)); + } @override - Future updatePinnedMessages(String cid, List messages) => - _readProtected(() async { - _logger.info('updatePinnedMessages'); - return db.pinnedMessageDao.updateMessages(cid, messages); - }); + Future updatePinnedMessages(String cid, List messages) { + assert(_debugIsConnected, ''); + _logger.info('updatePinnedMessages'); + return _readProtected( + () => db!.pinnedMessageDao.updateMessages(cid, messages), + ); + } @override - Future updateReactions(List reactions) => - _readProtected(() async { - _logger.info('updateReactions'); - return db.reactionDao.updateReactions(reactions); - }); + Future updateReactions(List reactions) { + assert(_debugIsConnected, ''); + _logger.info('updateReactions'); + return _readProtected(() => db!.reactionDao.updateReactions(reactions)); + } @override - Future updateReads(String cid, List reads) => - _readProtected(() async { - _logger.info('updateReads'); - return db.readDao.updateReads(cid, reads); - }); + Future updateReads(String cid, List reads) { + assert(_debugIsConnected, ''); + _logger.info('updateReads'); + return _readProtected(() => db!.readDao.updateReads(cid, reads)); + } @override - Future updateUsers(List users) => _readProtected(() async { - _logger.info('updateUsers'); - return db.userDao.updateUsers(users); - }); + Future updateUsers(List users) { + assert(_debugIsConnected, ''); + _logger.info('updateUsers'); + return _readProtected(() => db!.userDao.updateUsers(users)); + } @override - Future deleteReactionsByMessageId(List messageIds) => - _readProtected(() async { - _logger.info('deleteReactionsByMessageId'); - return db.reactionDao.deleteReactionsByMessageIds(messageIds); - }); + Future deleteReactionsByMessageId(List messageIds) { + assert(_debugIsConnected, ''); + _logger.info('deleteReactionsByMessageId'); + return _readProtected( + () => db!.reactionDao.deleteReactionsByMessageIds(messageIds), + ); + } @override - Future deleteMembersByCids(List cids) => - _readProtected(() async { - _logger.info('deleteMembersByCids'); - return db.memberDao.deleteMemberByCids(cids); - }); + Future deleteMembersByCids(List cids) { + assert(_debugIsConnected, ''); + _logger.info('deleteMembersByCids'); + return _readProtected(() => db!.memberDao.deleteMemberByCids(cids)); + } @override - Future updateChannelStates(List channelStates) => - _readProtected(() async => db.transaction(() async { - await super.updateChannelStates(channelStates); - })); + Future updateChannelStates(List channelStates) { + assert(_debugIsConnected, ''); + _logger.info('updateChannelStates'); + return _readProtected( + () async => db!.transaction( + () async { + await super.updateChannelStates(channelStates); + }, + ), + ); + } @override Future disconnect({bool flush = false}) async => @@ -330,13 +376,9 @@ class StreamChatPersistenceClient extends ChatPersistenceClient { _logger.info('Disconnecting'); if (flush) { _logger.info('Flushing'); - await db.batch((batch) { - db.allTables.forEach((table) { - db.delete(table).go(); - }); - }); + await db!.flush(); } - await db.disconnect(); + await db!.disconnect(); db = null; } }); diff --git a/packages/stream_chat_persistence/pubspec.yaml b/packages/stream_chat_persistence/pubspec.yaml index 47e44a9e..08029e91 100644 --- a/packages/stream_chat_persistence/pubspec.yaml +++ b/packages/stream_chat_persistence/pubspec.yaml @@ -1,27 +1,28 @@ name: stream_chat_persistence homepage: https://github.com/GetStream/stream-chat-flutter description: Official Stream Chat Persistence library. Build your own chat experience using Dart and Flutter. -version: 1.5.2 +version: 2.0.0 repository: https://github.com/GetStream/stream-chat-flutter issue_tracker: https://github.com/GetStream/stream-chat-flutter/issues environment: - sdk: ">=2.7.0 <3.0.0" + sdk: ">=2.12.0 <3.0.0" dependencies: flutter: sdk: flutter - logging: ^0.11.4 - meta: ^1.2.4 - moor: ^3.4.0 - mutex: ^2.0.0 - path: ^1.7.0 - path_provider: ^1.6.27 - sqlite3_flutter_libs: ^0.4.0+1 - stream_chat: ^1.5.3 + logging: ^1.0.1 + meta: ^1.3.0 + moor: ^4.4.0 + mutex: ^3.0.0 + path: ^1.8.0 + path_provider: ^2.0.1 + sqlite3_flutter_libs: ^0.5.0 + stream_chat: ^2.0.0 dev_dependencies: - build_runner: ^1.11.0 - mockito: ^4.1.3 - moor_generator: ^3.4.1 - test: ^1.15.7 + build_runner: ^2.0.1 + mocktail: ^0.1.1 + moor_generator: ^4.2.1 + pedantic: ^1.11.0 + test: ^1.17.7 diff --git a/packages/stream_chat_persistence/test/mock_chat_database.dart b/packages/stream_chat_persistence/test/mock_chat_database.dart index a60bd39b..b421fad1 100644 --- a/packages/stream_chat_persistence/test/mock_chat_database.dart +++ b/packages/stream_chat_persistence/test/mock_chat_database.dart @@ -1,55 +1,61 @@ -import 'package:mockito/mockito.dart'; +import 'package:mocktail/mocktail.dart'; import 'package:stream_chat_persistence/src/dao/dao.dart'; import 'package:stream_chat_persistence/src/db/moor_chat_database.dart'; class MockChatDatabase extends Mock implements MoorChatDatabase { - UserDao _userDao; + UserDao? _userDao; @override UserDao get userDao => _userDao ??= MockUserDao(); - ChannelDao _channelDao; + ChannelDao? _channelDao; @override ChannelDao get channelDao => _channelDao ??= MockChannelDao(); - MessageDao _messageDao; + MessageDao? _messageDao; @override MessageDao get messageDao => _messageDao ??= MockMessageDao(); - PinnedMessageDao _pinnedMessageDao; + PinnedMessageDao? _pinnedMessageDao; @override PinnedMessageDao get pinnedMessageDao => _pinnedMessageDao ??= MockPinnedMessageDao(); - MemberDao _memberDao; + MemberDao? _memberDao; @override MemberDao get memberDao => _memberDao ??= MockMemberDao(); - ReactionDao _reactionDao; + ReactionDao? _reactionDao; @override ReactionDao get reactionDao => _reactionDao ??= MockReactionDao(); - ReadDao _readDao; + ReadDao? _readDao; @override ReadDao get readDao => _readDao ??= MockReadDao(); - ChannelQueryDao _channelQueryDao; + ChannelQueryDao? _channelQueryDao; @override ChannelQueryDao get channelQueryDao => _channelQueryDao ??= MockChannelQueryDao(); - ConnectionEventDao _connectionEventDao; + ConnectionEventDao? _connectionEventDao; @override ConnectionEventDao get connectionEventDao => _connectionEventDao ??= MockConnectionEventDao(); + + @override + Future flush() => Future.value(); + + @override + Future disconnect() => Future.value(); } class MockUserDao extends Mock implements UserDao {} diff --git a/packages/stream_chat_persistence/test/src/converter/list_coverter_test.dart b/packages/stream_chat_persistence/test/src/converter/list_coverter_test.dart index a5bedd40..73ce2452 100644 --- a/packages/stream_chat_persistence/test/src/converter/list_coverter_test.dart +++ b/packages/stream_chat_persistence/test/src/converter/list_coverter_test.dart @@ -32,7 +32,7 @@ void main() { test('should return list of String if json data list is provided', () { final data = ['data1', 'data2', 'data3']; final res = listConverter.mapToDart(jsonEncode(data)); - expect(res.length, data.length); + expect(res!.length, data.length); }); }); diff --git a/packages/stream_chat_persistence/test/src/dao/channel_dao_test.dart b/packages/stream_chat_persistence/test/src/dao/channel_dao_test.dart index 47c655a8..9a458574 100644 --- a/packages/stream_chat_persistence/test/src/dao/channel_dao_test.dart +++ b/packages/stream_chat_persistence/test/src/dao/channel_dao_test.dart @@ -3,12 +3,14 @@ import 'package:stream_chat_persistence/src/dao/channel_dao.dart'; import 'package:stream_chat_persistence/src/db/moor_chat_database.dart'; import 'package:test/test.dart'; +import '../../stream_chat_persistence_client_test.dart'; + void main() { - ChannelDao channelDao; - MoorChatDatabase database; + late ChannelDao channelDao; + late MoorChatDatabase database; setUp(() { - database = MoorChatDatabase.testable('testUserId'); + database = testDatabaseProvider('testUserId'); channelDao = database.channelDao; }); @@ -32,7 +34,8 @@ void main() { // Should match the dummy channel final updatedChannel = await channelDao.getChannelByCid(cid); - expect(updatedChannel.id, id); + expect(updatedChannel, isNotNull); + expect(updatedChannel!.id, id); expect(updatedChannel.cid, cid); expect(updatedChannel.type, type); }); @@ -53,7 +56,8 @@ void main() { // Should match the dummy channel final updatedChannel = await channelDao.getChannelByCid(cid); - expect(updatedChannel.id, id); + expect(updatedChannel, isNotNull); + expect(updatedChannel!.id, id); expect(updatedChannel.cid, cid); expect(updatedChannel.type, type); @@ -108,7 +112,8 @@ void main() { // Should match the dummy channel final updatedChannel = await channelDao.getChannelByCid(cid); - expect(updatedChannel.id, id); + expect(updatedChannel, isNotNull); + expect(updatedChannel!.id, id); expect(updatedChannel.cid, cid); expect(updatedChannel.type, type); @@ -119,7 +124,8 @@ void main() { // Should match the new channel final newUpdatedChannel = await channelDao.getChannelByCid(cid); - expect(newUpdatedChannel.id, id); + expect(newUpdatedChannel, isNotNull); + expect(newUpdatedChannel!.id, id); expect(newUpdatedChannel.cid, cid); expect(newUpdatedChannel.type, newType); }); diff --git a/packages/stream_chat_persistence/test/src/dao/channel_query_dao_test.dart b/packages/stream_chat_persistence/test/src/dao/channel_query_dao_test.dart index 4566ca94..62a3df15 100644 --- a/packages/stream_chat_persistence/test/src/dao/channel_query_dao_test.dart +++ b/packages/stream_chat_persistence/test/src/dao/channel_query_dao_test.dart @@ -5,23 +5,20 @@ import 'package:stream_chat_persistence/src/dao/channel_query_dao.dart'; import 'package:stream_chat_persistence/src/db/moor_chat_database.dart'; import 'package:test/test.dart'; +import '../../stream_chat_persistence_client_test.dart'; import '../utils/date_matcher.dart'; void main() { - MoorChatDatabase database; - ChannelQueryDao channelQueryDao; + late MoorChatDatabase database; + late ChannelQueryDao channelQueryDao; setUp(() { - database = MoorChatDatabase.testable('testUserId'); + database = testDatabaseProvider('testUserId'); channelQueryDao = database.channelQueryDao; }); test('updateChannelQueries', () async { - const filter = { - 'members': { - r'$in': ['testUserId'], - }, - }; + final filter = Filter.in_('members', const ['testUserId']); const cids = ['testCid1', 'testCid2', 'testCid3']; @@ -36,11 +33,7 @@ void main() { }); test('clear queryCache before updateChannelQueries', () async { - const filter = { - 'members': { - r'$in': ['testUserId'], - }, - }; + final filter = Filter.in_('members', const ['testUserId']); const cids = ['testCid1', 'testCid2', 'testCid3']; @@ -59,11 +52,7 @@ void main() { }); test('getCachedChannelCids', () async { - const filter = { - 'members': { - r'$in': ['testUserId'], - }, - }; + final filter = Filter.in_('members', const ['testUserId']); const cids = ['testCid1', 'testCid2', 'testCid3']; @@ -78,7 +67,7 @@ void main() { }); Future> _insertTestDataForGetChannel( - Map filter, { + Filter filter, { int count = 3, }) async { final now = DateTime.now(); @@ -110,11 +99,7 @@ void main() { } group('getChannels', () { - const filter = { - 'members': { - r'$in': ['testUserId'], - }, - }; + final filter = Filter.in_('members', const ['testUserId']); test('should return empty list of channels', () async { final channels = await channelQueryDao.getChannels(filter: filter); @@ -147,7 +132,7 @@ void main() { // Should match lastMessageAt date expect( updatedChannel.lastMessageAt, - isSameDateAs(insertedChannel.lastMessageAt), + isSameDateAs(insertedChannel.lastMessageAt!), ); } }); @@ -160,10 +145,7 @@ void main() { const pagination = PaginationParams(offset: offset, limit: limit); // Inserting test data for get channels - final insertedChannels = await _insertTestDataForGetChannel( - filter, - count: 30, - ); + await _insertTestDataForGetChannel(filter, count: 30); // Should match with the inserted channels final updatedChannels = await channelQueryDao.getChannels( @@ -187,7 +169,12 @@ void main() { // Should match with the inserted channels final updatedChannels = await channelQueryDao.getChannels( filter: filter, - sort: [SortOption('member_count', comparator: sortComparator)], + sort: [ + SortOption( + 'member_count', + comparator: sortComparator, + ) + ], ); expect(updatedChannels.length, insertedChannels.length); @@ -210,7 +197,7 @@ void main() { // Should match lastMessageAt date expect( updatedChannel.lastMessageAt, - isSameDateAs(insertedChannel.lastMessageAt), + isSameDateAs(insertedChannel.lastMessageAt!), ); } }); @@ -226,8 +213,8 @@ void main() { test('should return sorted channels using custom field', () async { int sortComparator(ChannelModel a, ChannelModel b) { - final aData = a.extraData['test_custom_field'] as int; - final bData = b.extraData['test_custom_field'] as int; + final aData = int.parse(a.extraData['test_custom_field'].toString()); + final bData = int.parse(b.extraData['test_custom_field'].toString()); return bData.compareTo(aData); } @@ -261,7 +248,7 @@ void main() { // Should match lastMessageAt date expect( updatedChannel.lastMessageAt, - isSameDateAs(insertedChannel.lastMessageAt), + isSameDateAs(insertedChannel.lastMessageAt!), ); } }); diff --git a/packages/stream_chat_persistence/test/src/dao/connection_event_dao_test.dart b/packages/stream_chat_persistence/test/src/dao/connection_event_dao_test.dart index 2969751b..40c385ba 100644 --- a/packages/stream_chat_persistence/test/src/dao/connection_event_dao_test.dart +++ b/packages/stream_chat_persistence/test/src/dao/connection_event_dao_test.dart @@ -3,14 +3,15 @@ import 'package:stream_chat_persistence/src/dao/connection_event_dao.dart'; import 'package:stream_chat_persistence/src/db/moor_chat_database.dart'; import 'package:test/test.dart'; +import '../../stream_chat_persistence_client_test.dart'; import '../utils/date_matcher.dart'; void main() { - ConnectionEventDao eventDao; - MoorChatDatabase database; + late ConnectionEventDao eventDao; + late MoorChatDatabase database; setUp(() { - database = MoorChatDatabase.testable('testUserId'); + database = testDatabaseProvider('testUserId'); eventDao = database.connectionEventDao; }); @@ -30,7 +31,8 @@ void main() { // Should match the added event final updatedEvent = await eventDao.connectionEvent; - expect(updatedEvent.me.id, newEvent.me.id); + expect(updatedEvent, isNotNull); + expect(updatedEvent!.me!.id, newEvent.me!.id); expect(updatedEvent.totalUnreadCount, newEvent.totalUnreadCount); expect(updatedEvent.unreadChannels, newEvent.unreadChannels); }); @@ -70,7 +72,8 @@ void main() { // Should match the previously added event final fetchedEvent = await eventDao.connectionEvent; - expect(fetchedEvent.me.id, event.me.id); + expect(fetchedEvent, isNotNull); + expect(fetchedEvent!.me!.id, event.me!.id); expect(fetchedEvent.totalUnreadCount, event.totalUnreadCount); expect(fetchedEvent.unreadChannels, event.unreadChannels); @@ -80,7 +83,8 @@ void main() { // Should match the updated event final fetchedNewEvent = await eventDao.connectionEvent; - expect(fetchedNewEvent.me.id, event.me.id); + expect(fetchedNewEvent, isNotNull); + expect(fetchedNewEvent!.me!.id, event.me!.id); expect(fetchedNewEvent.totalUnreadCount, event.totalUnreadCount); expect(fetchedNewEvent.unreadChannels, newEvent.unreadChannels); }); diff --git a/packages/stream_chat_persistence/test/src/dao/member_dao_test.dart b/packages/stream_chat_persistence/test/src/dao/member_dao_test.dart index dbb8c73c..8ce704a4 100644 --- a/packages/stream_chat_persistence/test/src/dao/member_dao_test.dart +++ b/packages/stream_chat_persistence/test/src/dao/member_dao_test.dart @@ -5,14 +5,15 @@ import 'package:stream_chat_persistence/src/dao/dao.dart'; import 'package:stream_chat_persistence/src/db/moor_chat_database.dart'; import 'package:test/test.dart'; +import '../../stream_chat_persistence_client_test.dart'; import '../utils/date_matcher.dart'; void main() { - MemberDao memberDao; - MoorChatDatabase database; + late MemberDao memberDao; + late MoorChatDatabase database; setUp(() { - database = MoorChatDatabase.testable('testUserId'); + database = testDatabaseProvider('testUserId'); memberDao = database.memberDao; }); @@ -53,7 +54,7 @@ void main() { for (var i = 0; i < fetchedMembers.length; i++) { final member = memberList[i]; final fetchedMember = fetchedMembers[i]; - expect(fetchedMember.user.id, member.user.id); + expect(fetchedMember.user!.id, member.user!.id); expect(fetchedMember.banned, member.banned); expect(fetchedMember.shadowBanned, member.shadowBanned); expect(fetchedMember.createdAt, isSameDateAs(member.createdAt)); @@ -63,7 +64,7 @@ void main() { expect(fetchedMember.updatedAt, isSameDateAs(member.updatedAt)); expect( fetchedMember.inviteAcceptedAt, - isSameDateAs(member.inviteAcceptedAt), + isSameDateAs(member.inviteAcceptedAt!), ); } }); @@ -80,7 +81,7 @@ void main() { for (var i = 0; i < fetchedMembers.length; i++) { final member = memberList[i]; final fetchedMember = fetchedMembers[i]; - expect(fetchedMember.user.id, member.user.id); + expect(fetchedMember.user!.id, member.user!.id); expect(fetchedMember.banned, member.banned); expect(fetchedMember.shadowBanned, member.shadowBanned); expect(fetchedMember.createdAt, isSameDateAs(member.createdAt)); @@ -90,7 +91,7 @@ void main() { expect(fetchedMember.updatedAt, isSameDateAs(member.updatedAt)); expect( fetchedMember.inviteAcceptedAt, - isSameDateAs(member.inviteAcceptedAt), + isSameDateAs(member.inviteAcceptedAt!), ); } @@ -118,13 +119,13 @@ void main() { expect(newFetchedMembers.length, fetchedMembers.length + 1); expect( newFetchedMembers - .firstWhere((it) => it.user.id == copyMember.user.id) + .firstWhere((it) => it.user!.id == copyMember.user!.id) .banned, true, ); expect( newFetchedMembers - .where((it) => it.user.id == newMember.user.id) + .where((it) => it.user!.id == newMember.user!.id) .isNotEmpty, true, ); diff --git a/packages/stream_chat_persistence/test/src/dao/message_dao_test.dart b/packages/stream_chat_persistence/test/src/dao/message_dao_test.dart index f2440ae1..54cc219a 100644 --- a/packages/stream_chat_persistence/test/src/dao/message_dao_test.dart +++ b/packages/stream_chat_persistence/test/src/dao/message_dao_test.dart @@ -5,12 +5,14 @@ import 'package:stream_chat_persistence/src/dao/dao.dart'; import 'package:stream_chat_persistence/src/db/moor_chat_database.dart'; import 'package:test/test.dart'; +import '../../stream_chat_persistence_client_test.dart'; + void main() { - MessageDao messageDao; - MoorChatDatabase database; + late MessageDao messageDao; + late MoorChatDatabase database; setUp(() { - database = MoorChatDatabase.testable('testUserId'); + database = testDatabaseProvider('testUserId'); messageDao = database.messageDao; }); @@ -32,7 +34,7 @@ void main() { shadowed: math.Random().nextBool(), replyCount: index, updatedAt: DateTime.now(), - extraData: {'extra_test_field': 'extraTestData'}, + extraData: const {'extra_test_field': 'extraTestData'}, text: 'Dummy text #$index', pinned: math.Random().nextBool(), pinnedAt: DateTime.now(), @@ -49,7 +51,7 @@ void main() { shadowed: math.Random().nextBool(), replyCount: index, updatedAt: DateTime.now(), - extraData: {'extra_test_field': 'extraTestData'}, + extraData: const {'extra_test_field': 'extraTestData'}, text: 'Dummy text #$index', quotedMessageId: messages[index].id, pinned: math.Random().nextBool(), @@ -69,7 +71,7 @@ void main() { shadowed: math.Random().nextBool(), replyCount: index, updatedAt: DateTime.now(), - extraData: {'extra_test_field': 'extraTestData'}, + extraData: const {'extra_test_field': 'extraTestData'}, text: 'Dummy text #$index', pinned: math.Random().nextBool(), pinnedAt: DateTime.now(), @@ -168,7 +170,8 @@ void main() { // Fetched message id should match the inserted message id final fetchedMessage = await messageDao.getMessageById(id); - expect(fetchedMessage.id, insertedMessages.first.id); + expect(fetchedMessage, isNotNull); + expect(fetchedMessage!.id, insertedMessages.first.id); }); test('getThreadMessages', () async { @@ -332,7 +335,7 @@ void main() { showInChannel: math.Random().nextBool(), replyCount: 4, updatedAt: DateTime.now(), - extraData: {'extra_test_field': 'extraTestData'}, + extraData: const {'extra_test_field': 'extraTestData'}, text: 'Dummy text #4', pinned: math.Random().nextBool(), pinnedAt: DateTime.now(), diff --git a/packages/stream_chat_persistence/test/src/dao/pinned_message_dao_test.dart b/packages/stream_chat_persistence/test/src/dao/pinned_message_dao_test.dart index de7c8976..42a54107 100644 --- a/packages/stream_chat_persistence/test/src/dao/pinned_message_dao_test.dart +++ b/packages/stream_chat_persistence/test/src/dao/pinned_message_dao_test.dart @@ -5,12 +5,14 @@ import 'package:stream_chat_persistence/src/dao/dao.dart'; import 'package:stream_chat_persistence/src/db/moor_chat_database.dart'; import 'package:test/test.dart'; +import '../../stream_chat_persistence_client_test.dart'; + void main() { - PinnedMessageDao pinnedMessageDao; - MoorChatDatabase database; + late PinnedMessageDao pinnedMessageDao; + late MoorChatDatabase database; setUp(() { - database = MoorChatDatabase.testable('testUserId'); + database = testDatabaseProvider('testUserId'); pinnedMessageDao = database.pinnedMessageDao; }); @@ -32,7 +34,7 @@ void main() { shadowed: math.Random().nextBool(), replyCount: index, updatedAt: DateTime.now(), - extraData: {'extra_test_field': 'extraTestData'}, + extraData: const {'extra_test_field': 'extraTestData'}, text: 'Dummy text #$index', pinned: math.Random().nextBool(), pinnedAt: DateTime.now(), @@ -49,7 +51,7 @@ void main() { shadowed: math.Random().nextBool(), replyCount: index, updatedAt: DateTime.now(), - extraData: {'extra_test_field': 'extraTestData'}, + extraData: const {'extra_test_field': 'extraTestData'}, text: 'Dummy text #$index', quotedMessageId: messages[index].id, pinned: math.Random().nextBool(), @@ -69,7 +71,7 @@ void main() { shadowed: math.Random().nextBool(), replyCount: index, updatedAt: DateTime.now(), - extraData: {'extra_test_field': 'extraTestData'}, + extraData: const {'extra_test_field': 'extraTestData'}, text: 'Dummy text #$index', pinned: math.Random().nextBool(), pinnedAt: DateTime.now(), @@ -168,7 +170,8 @@ void main() { // Fetched message id should match the inserted message id final fetchedMessage = await pinnedMessageDao.getMessageById(id); - expect(fetchedMessage.id, insertedMessages.first.id); + expect(fetchedMessage, isNotNull); + expect(fetchedMessage!.id, insertedMessages.first.id); }); test('getThreadMessages', () async { @@ -333,7 +336,7 @@ void main() { showInChannel: math.Random().nextBool(), replyCount: 4, updatedAt: DateTime.now(), - extraData: {'extra_test_field': 'extraTestData'}, + extraData: const {'extra_test_field': 'extraTestData'}, text: 'Dummy text #4', pinned: math.Random().nextBool(), pinnedAt: DateTime.now(), diff --git a/packages/stream_chat_persistence/test/src/dao/reaction_dao_test.dart b/packages/stream_chat_persistence/test/src/dao/reaction_dao_test.dart index 94f8ebdd..4edda680 100644 --- a/packages/stream_chat_persistence/test/src/dao/reaction_dao_test.dart +++ b/packages/stream_chat_persistence/test/src/dao/reaction_dao_test.dart @@ -5,18 +5,20 @@ import 'package:stream_chat_persistence/src/dao/reaction_dao.dart'; import 'package:stream_chat_persistence/src/db/moor_chat_database.dart'; import 'package:test/test.dart'; +import '../../stream_chat_persistence_client_test.dart'; + void main() { - ReactionDao reactionDao; - MoorChatDatabase database; + late ReactionDao reactionDao; + late MoorChatDatabase database; setUp(() { - database = MoorChatDatabase.testable('testUserId'); + database = testDatabaseProvider('testUserId'); reactionDao = database.reactionDao; }); Future> _prepareReactionData( String messageId, { - String userId, + String? userId, int count = 3, }) async { final users = List.generate(count, (index) => User(id: 'testUserId$index')); @@ -29,7 +31,7 @@ void main() { showInChannel: math.Random().nextBool(), replyCount: 3, updatedAt: DateTime.now(), - extraData: {'extra_test_field': 'extraTestData'}, + extraData: const {'extra_test_field': 'extraTestData'}, text: 'Dummy text', pinned: math.Random().nextBool(), pinnedAt: DateTime.now(), diff --git a/packages/stream_chat_persistence/test/src/dao/read_dao_test.dart b/packages/stream_chat_persistence/test/src/dao/read_dao_test.dart index 52207f68..287dcad4 100644 --- a/packages/stream_chat_persistence/test/src/dao/read_dao_test.dart +++ b/packages/stream_chat_persistence/test/src/dao/read_dao_test.dart @@ -3,14 +3,15 @@ import 'package:stream_chat_persistence/src/dao/dao.dart'; import 'package:stream_chat_persistence/src/db/moor_chat_database.dart'; import 'package:test/test.dart'; +import '../../stream_chat_persistence_client_test.dart'; import '../utils/date_matcher.dart'; void main() { - ReadDao readDao; - MoorChatDatabase database; + late ReadDao readDao; + late MoorChatDatabase database; setUp(() { - database = MoorChatDatabase.testable('testUserId'); + database = testDatabaseProvider('testUserId'); readDao = database.readDao; }); diff --git a/packages/stream_chat_persistence/test/src/dao/user_dao_test.dart b/packages/stream_chat_persistence/test/src/dao/user_dao_test.dart index 8c5898a0..ffb50edd 100644 --- a/packages/stream_chat_persistence/test/src/dao/user_dao_test.dart +++ b/packages/stream_chat_persistence/test/src/dao/user_dao_test.dart @@ -5,12 +5,14 @@ import 'package:stream_chat_persistence/src/db/moor_chat_database.dart'; import 'package:test/test.dart'; import 'package:stream_chat/stream_chat.dart'; +import '../../stream_chat_persistence_client_test.dart'; + void main() { - UserDao userDao; - MoorChatDatabase database; + late UserDao userDao; + late MoorChatDatabase database; setUp(() { - database = MoorChatDatabase.testable('testUserId'); + database = testDatabaseProvider('testUserId'); userDao = database.userDao; }); diff --git a/packages/stream_chat_persistence/test/src/mapper/channel_mapper_test.dart b/packages/stream_chat_persistence/test/src/mapper/channel_mapper_test.dart index dc02ee4b..93789cc7 100644 --- a/packages/stream_chat_persistence/test/src/mapper/channel_mapper_test.dart +++ b/packages/stream_chat_persistence/test/src/mapper/channel_mapper_test.dart @@ -34,15 +34,21 @@ void main() { expect(channelModel.updatedAt, isSameDateAs(entity.updatedAt)); expect(channelModel.memberCount, entity.memberCount); expect(channelModel.cid, entity.cid); - expect(channelModel.lastMessageAt, isSameDateAs(entity.lastMessageAt)); - expect(channelModel.deletedAt, isSameDateAs(entity.deletedAt)); + expect(channelModel.lastMessageAt, isSameDateAs(entity.lastMessageAt!)); + expect(channelModel.deletedAt, isSameDateAs(entity.deletedAt!)); expect(channelModel.extraData, entity.extraData); - expect(channelModel.createdBy.id, entity.createdById); + expect(channelModel.createdBy!.id, entity.createdById); }); test('toChannelState should map entity into ChannelState ', () { final members = List.generate(3, (index) => Member()); - final reads = List.generate(3, (index) => Read()); + final reads = List.generate( + 3, + (index) => Read( + user: User(id: 'testUserId$index'), + lastRead: DateTime.now(), + ), + ); final messages = List.generate(3, (index) => Message()); final channelState = entity.toChannelState( @@ -59,7 +65,7 @@ void main() { expect(channelState.messages.length, messages.length); expect(channelState.pinnedMessages.length, messages.length); - final channelModel = channelState.channel; + final channelModel = channelState.channel!; expect(channelModel.id, entity.id); expect(channelModel.config.toJson()['max_message_length'], 33); expect(channelModel.frozen, entity.frozen); @@ -67,10 +73,10 @@ void main() { expect(channelModel.updatedAt, isSameDateAs(entity.updatedAt)); expect(channelModel.memberCount, entity.memberCount); expect(channelModel.cid, entity.cid); - expect(channelModel.lastMessageAt, isSameDateAs(entity.lastMessageAt)); - expect(channelModel.deletedAt, isSameDateAs(entity.deletedAt)); + expect(channelModel.lastMessageAt, isSameDateAs(entity.lastMessageAt!)); + expect(channelModel.deletedAt, isSameDateAs(entity.deletedAt!)); expect(channelModel.extraData, entity.extraData); - expect(channelModel.createdBy.id, entity.createdById); + expect(channelModel.createdBy!.id, entity.createdById); }); }); @@ -103,9 +109,9 @@ void main() { expect(channelEntity.updatedAt, isSameDateAs(model.updatedAt)); expect(channelEntity.memberCount, model.memberCount); expect(channelEntity.cid, model.cid); - expect(channelEntity.lastMessageAt, isSameDateAs(model.lastMessageAt)); - expect(channelEntity.deletedAt, isSameDateAs(model.deletedAt)); + expect(channelEntity.lastMessageAt, isSameDateAs(model.lastMessageAt!)); + expect(channelEntity.deletedAt, isSameDateAs(model.deletedAt!)); expect(channelEntity.extraData, model.extraData); - expect(channelEntity.createdById, model.createdBy.id); + expect(channelEntity.createdById, model.createdBy!.id); }); } diff --git a/packages/stream_chat_persistence/test/src/mapper/event_mapper_test.dart b/packages/stream_chat_persistence/test/src/mapper/event_mapper_test.dart index 240ce1a7..3182162b 100644 --- a/packages/stream_chat_persistence/test/src/mapper/event_mapper_test.dart +++ b/packages/stream_chat_persistence/test/src/mapper/event_mapper_test.dart @@ -1,22 +1,29 @@ -import 'package:test/test.dart'; import 'package:stream_chat/stream_chat.dart'; import 'package:stream_chat_persistence/src/db/moor_chat_database.dart'; import 'package:stream_chat_persistence/src/mapper/event_mapper.dart'; +import 'package:test/test.dart'; + +import '../utils/date_matcher.dart'; void main() { test('toEvent should map entity into Event', () { + const type = 'dummy.type'; + final now = DateTime.now(); final ownUser = OwnUser(id: 'testUserId'); final entity = ConnectionEventEntity( id: 3, + type: type, ownUser: ownUser.toJson(), totalUnreadCount: 33, unreadChannels: 33, - lastSyncAt: DateTime.now(), - lastEventAt: DateTime.now(), + lastSyncAt: now, + lastEventAt: now, ); final event = entity.toEvent(); expect(event, isA()); - expect(event.me.id, ownUser.id); + expect(event.type, type); + expect(event.createdAt.toUtc(), isSameDateAs(now.toUtc())); + expect(event.me!.id, ownUser.id); expect(event.totalUnreadCount, entity.totalUnreadCount); expect(event.unreadChannels, entity.unreadChannels); }); diff --git a/packages/stream_chat_persistence/test/src/mapper/member_mapper_test.dart b/packages/stream_chat_persistence/test/src/mapper/member_mapper_test.dart index 0f7d4f11..feca753b 100644 --- a/packages/stream_chat_persistence/test/src/mapper/member_mapper_test.dart +++ b/packages/stream_chat_persistence/test/src/mapper/member_mapper_test.dart @@ -25,12 +25,12 @@ void main() { ); final member = entity.toMember(user: user); expect(member, isA()); - expect(member.user.id, entity.userId); + expect(member.user!.id, entity.userId); expect(member.createdAt, isSameDateAs(entity.createdAt)); expect(member.updatedAt, isSameDateAs(entity.updatedAt)); expect(member.role, entity.role); - expect(member.inviteAcceptedAt, isSameDateAs(entity.inviteAcceptedAt)); - expect(member.inviteRejectedAt, isSameDateAs(entity.inviteRejectedAt)); + expect(member.inviteAcceptedAt, isSameDateAs(entity.inviteAcceptedAt!)); + expect(member.inviteRejectedAt, isSameDateAs(entity.inviteRejectedAt!)); expect(member.invited, entity.invited); expect(member.banned, entity.banned); expect(member.shadowBanned, entity.shadowBanned); @@ -55,12 +55,12 @@ void main() { final entity = member.toEntity(cid: cid); expect(entity, isA()); expect(entity.channelCid, cid); - expect(entity.userId, member.user.id); + expect(entity.userId, member.user!.id); expect(entity.createdAt, isSameDateAs(member.createdAt)); expect(entity.updatedAt, isSameDateAs(member.updatedAt)); expect(entity.role, member.role); - expect(entity.inviteAcceptedAt, isSameDateAs(member.inviteAcceptedAt)); - expect(entity.inviteRejectedAt, isSameDateAs(member.inviteRejectedAt)); + expect(entity.inviteAcceptedAt, isSameDateAs(member.inviteAcceptedAt!)); + expect(entity.inviteRejectedAt, isSameDateAs(member.inviteRejectedAt!)); expect(entity.invited, member.invited); expect(entity.banned, member.banned); expect(entity.shadowBanned, member.shadowBanned); diff --git a/packages/stream_chat_persistence/test/src/mapper/message_mapper_test.dart b/packages/stream_chat_persistence/test/src/mapper/message_mapper_test.dart index 21a05da8..7d867deb 100644 --- a/packages/stream_chat_persistence/test/src/mapper/message_mapper_test.dart +++ b/packages/stream_chat_persistence/test/src/mapper/message_mapper_test.dart @@ -32,7 +32,7 @@ void main() { ); final entity = MessageEntity( id: 'testMessageId', - attachments: attachments?.map((it) => jsonEncode(it.toData()))?.toList(), + attachments: attachments.map((it) => jsonEncode(it.toData())).toList(), channelCid: 'testCid', type: 'testType', parentId: 'testParentId', @@ -46,8 +46,11 @@ void main() { reactionCounts: reactions.fold( {}, (prev, curr) => - prev..update(curr.type, (value) => value + 1, ifAbsent: () => 1), + prev?..update(curr.type, (value) => value + 1, ifAbsent: () => 1), ), + mentionedUsers: [ + jsonEncode(User(id: 'testuser')), + ], status: MessageSendingStatus.sent, updatedAt: DateTime.now(), extraData: {'extra_test_data': 'extraData'}, @@ -76,19 +79,24 @@ void main() { expect(message.createdAt, isSameDateAs(entity.createdAt)); expect(message.shadowed, entity.shadowed); expect(message.showInChannel, entity.showInChannel); + for (var i = 0; i < message.mentionedUsers.length; i++) { + final entityMentionedUser = + User.fromJson(jsonDecode(entity.mentionedUsers[i])); + expect(message.mentionedUsers[i].id, entityMentionedUser.id); + } expect(message.replyCount, entity.replyCount); expect(message.reactionScores, entity.reactionScores); expect(message.reactionCounts, entity.reactionCounts); expect(message.status, entity.status); expect(message.updatedAt, isSameDateAs(entity.updatedAt)); expect(message.extraData, entity.extraData); - expect(message.user.id, entity.userId); - expect(message.deletedAt, isSameDateAs(entity.deletedAt)); + expect(message.user!.id, entity.userId); + expect(message.deletedAt, isSameDateAs(entity.deletedAt!)); expect(message.text, entity.messageText); expect(message.pinned, entity.pinned); - expect(message.pinExpires, isSameDateAs(entity.pinExpires)); - expect(message.pinnedAt, isSameDateAs(entity.pinnedAt)); - expect(message.pinnedBy.id, entity.pinnedByUserId); + expect(message.pinExpires, isSameDateAs(entity.pinExpires!)); + expect(message.pinnedAt, isSameDateAs(entity.pinnedAt!)); + expect(message.pinnedBy!.id, entity.pinnedByUserId); expect(message.reactionCounts, entity.reactionCounts); expect(message.reactionScores, entity.reactionScores); for (var i = 0; i < message.attachments.length; i++) { @@ -134,15 +142,18 @@ void main() { shadowed: math.Random().nextBool(), showInChannel: math.Random().nextBool(), replyCount: 33, + mentionedUsers: [ + User(id: 'testuser'), + ], reactionScores: {for (final r in reactions) r.type: r.score}, reactionCounts: reactions.fold( {}, (prev, curr) => - prev..update(curr.type, (value) => value + 1, ifAbsent: () => 1), + prev?..update(curr.type, (value) => value + 1, ifAbsent: () => 1), ), status: MessageSendingStatus.sending, updatedAt: DateTime.now(), - extraData: {'extra_test_data': 'extraData'}, + extraData: const {'extra_test_data': 'extraData'}, user: user, deletedAt: DateTime.now(), text: 'dummy text', @@ -162,23 +173,25 @@ void main() { expect(entity.shadowed, message.shadowed); expect(entity.showInChannel, message.showInChannel); expect(entity.replyCount, message.replyCount); + expect( + entity.mentionedUsers, message.mentionedUsers.map(jsonEncode).toList()); expect(entity.reactionScores, message.reactionScores); expect(entity.reactionCounts, message.reactionCounts); expect(entity.status, message.status); expect(entity.updatedAt, isSameDateAs(message.updatedAt)); expect(entity.extraData, message.extraData); - expect(entity.userId, message.user.id); - expect(entity.deletedAt, isSameDateAs(message.deletedAt)); + expect(entity.userId, message.user!.id); + expect(entity.deletedAt, isSameDateAs(message.deletedAt!)); expect(entity.messageText, message.text); expect(entity.pinned, message.pinned); - expect(entity.pinExpires, isSameDateAs(message.pinExpires)); - expect(entity.pinnedAt, isSameDateAs(message.pinnedAt)); - expect(entity.pinnedByUserId, message.pinnedBy.id); + expect(entity.pinExpires, isSameDateAs(message.pinExpires!)); + expect(entity.pinnedAt, isSameDateAs(message.pinnedAt!)); + expect(entity.pinnedByUserId, message.pinnedBy!.id); expect(entity.reactionCounts, message.reactionCounts); expect(entity.reactionScores, message.reactionScores); expect( entity.attachments, - message.attachments?.map((it) => jsonEncode(it.toData()))?.toList(), + message.attachments.map((it) => jsonEncode(it.toData())).toList(), ); }); } diff --git a/packages/stream_chat_persistence/test/src/mapper/pinned_message_mapper_test.dart b/packages/stream_chat_persistence/test/src/mapper/pinned_message_mapper_test.dart index 0aadf08a..8b5f4db4 100644 --- a/packages/stream_chat_persistence/test/src/mapper/pinned_message_mapper_test.dart +++ b/packages/stream_chat_persistence/test/src/mapper/pinned_message_mapper_test.dart @@ -32,7 +32,7 @@ void main() { ); final entity = PinnedMessageEntity( id: 'testMessageId', - attachments: attachments?.map((it) => jsonEncode(it.toData()))?.toList(), + attachments: attachments.map((it) => jsonEncode(it.toData())).toList(), channelCid: 'testCid', type: 'testType', parentId: 'testParentId', @@ -46,8 +46,9 @@ void main() { reactionCounts: reactions.fold( {}, (prev, curr) => - prev..update(curr.type, (value) => value + 1, ifAbsent: () => 1), + prev?..update(curr.type, (value) => value + 1, ifAbsent: () => 1), ), + mentionedUsers: [], status: MessageSendingStatus.sent, updatedAt: DateTime.now(), extraData: {'extra_test_data': 'extraData'}, @@ -82,13 +83,13 @@ void main() { expect(message.status, entity.status); expect(message.updatedAt, isSameDateAs(entity.updatedAt)); expect(message.extraData, entity.extraData); - expect(message.user.id, entity.userId); - expect(message.deletedAt, isSameDateAs(entity.deletedAt)); + expect(message.user!.id, entity.userId); + expect(message.deletedAt, isSameDateAs(entity.deletedAt!)); expect(message.text, entity.messageText); expect(message.pinned, entity.pinned); - expect(message.pinExpires, isSameDateAs(entity.pinExpires)); - expect(message.pinnedAt, isSameDateAs(entity.pinnedAt)); - expect(message.pinnedBy.id, entity.pinnedByUserId); + expect(message.pinExpires, isSameDateAs(entity.pinExpires!)); + expect(message.pinnedAt, isSameDateAs(entity.pinnedAt!)); + expect(message.pinnedBy!.id, entity.pinnedByUserId); expect(message.reactionCounts, entity.reactionCounts); expect(message.reactionScores, entity.reactionScores); for (var i = 0; i < message.attachments.length; i++) { @@ -138,11 +139,11 @@ void main() { reactionCounts: reactions.fold( {}, (prev, curr) => - prev..update(curr.type, (value) => value + 1, ifAbsent: () => 1), + prev?..update(curr.type, (value) => value + 1, ifAbsent: () => 1), ), status: MessageSendingStatus.sending, updatedAt: DateTime.now(), - extraData: {'extra_test_data': 'extraData'}, + extraData: const {'extra_test_data': 'extraData'}, user: user, deletedAt: DateTime.now(), text: 'dummy text', @@ -167,18 +168,18 @@ void main() { expect(entity.status, message.status); expect(entity.updatedAt, isSameDateAs(message.updatedAt)); expect(entity.extraData, message.extraData); - expect(entity.userId, message.user.id); - expect(entity.deletedAt, isSameDateAs(message.deletedAt)); + expect(entity.userId, message.user!.id); + expect(entity.deletedAt, isSameDateAs(message.deletedAt!)); expect(entity.messageText, message.text); expect(entity.pinned, message.pinned); - expect(entity.pinExpires, isSameDateAs(message.pinExpires)); - expect(entity.pinnedAt, isSameDateAs(message.pinnedAt)); - expect(entity.pinnedByUserId, message.pinnedBy.id); + expect(entity.pinExpires, isSameDateAs(message.pinExpires!)); + expect(entity.pinnedAt, isSameDateAs(message.pinnedAt!)); + expect(entity.pinnedByUserId, message.pinnedBy!.id); expect(entity.reactionCounts, message.reactionCounts); expect(entity.reactionScores, message.reactionScores); expect( entity.attachments, - message.attachments?.map((it) => jsonEncode(it.toData()))?.toList(), + message.attachments.map((it) => jsonEncode(it.toData())).toList(), ); }); } diff --git a/packages/stream_chat_persistence/test/src/mapper/user_mapper_test.dart b/packages/stream_chat_persistence/test/src/mapper/user_mapper_test.dart index 5e40f237..4454ad73 100644 --- a/packages/stream_chat_persistence/test/src/mapper/user_mapper_test.dart +++ b/packages/stream_chat_persistence/test/src/mapper/user_mapper_test.dart @@ -24,7 +24,7 @@ void main() { expect(user.role, entity.role); expect(user.createdAt, isSameDateAs(entity.createdAt)); expect(user.updatedAt, isSameDateAs(entity.updatedAt)); - expect(user.lastActive, isSameDateAs(entity.lastActive)); + expect(user.lastActive, isSameDateAs(entity.lastActive!)); expect(user.online, entity.online); expect(user.banned, entity.banned); expect(user.extraData, entity.extraData); @@ -39,7 +39,7 @@ void main() { lastActive: DateTime.now(), online: math.Random().nextBool(), banned: math.Random().nextBool(), - extraData: {'test_extra_data': 'extraData'}, + extraData: const {'test_extra_data': 'extraData'}, ); final entity = user.toEntity(); expect(entity, isA()); @@ -47,7 +47,7 @@ void main() { expect(entity.role, user.role); expect(entity.createdAt, isSameDateAs(user.createdAt)); expect(entity.updatedAt, isSameDateAs(user.updatedAt)); - expect(entity.lastActive, isSameDateAs(user.lastActive)); + expect(entity.lastActive, isSameDateAs(user.lastActive!)); expect(entity.online, user.online); expect(entity.banned, user.banned); expect(entity.extraData, user.extraData); diff --git a/packages/stream_chat_persistence/test/src/utils/date_matcher.dart b/packages/stream_chat_persistence/test/src/utils/date_matcher.dart index 909e58b0..dec68db0 100644 --- a/packages/stream_chat_persistence/test/src/utils/date_matcher.dart +++ b/packages/stream_chat_persistence/test/src/utils/date_matcher.dart @@ -1,13 +1,10 @@ import 'package:test/test.dart'; -import 'package:meta/meta.dart'; Matcher isSameDateAs(DateTime targetDate) => _IsSameDateAs(targetDate: targetDate); class _IsSameDateAs extends Matcher { - const _IsSameDateAs({ - @required this.targetDate, - }) : assert(targetDate != null, ''); + const _IsSameDateAs({required this.targetDate}); final DateTime targetDate; diff --git a/packages/stream_chat_persistence/test/stream_chat_persistence_client_test.dart b/packages/stream_chat_persistence/test/stream_chat_persistence_client_test.dart index 55a5113a..daaebe61 100644 --- a/packages/stream_chat_persistence/test/stream_chat_persistence_client_test.dart +++ b/packages/stream_chat_persistence/test/stream_chat_persistence_client_test.dart @@ -1,4 +1,5 @@ -import 'package:mockito/mockito.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:moor/ffi.dart'; import 'package:stream_chat/stream_chat.dart'; import 'package:stream_chat_persistence/src/db/moor_chat_database.dart'; import 'package:stream_chat_persistence/src/stream_chat_persistence_client.dart'; @@ -6,35 +7,19 @@ import 'package:test/test.dart'; import 'mock_chat_database.dart'; import 'src/utils/date_matcher.dart'; -MoorChatDatabase _testDatabaseProvider(String userId, ConnectionMode mode) => - MoorChatDatabase.testable(userId); +MoorChatDatabase testDatabaseProvider(String userId, [ConnectionMode? mode]) => + MoorChatDatabase(userId, VmDatabase.memory()); void main() { - group('client constructor', () { - test('throws assertion error if null connectionMode is provided', () { - expect( - () => StreamChatPersistenceClient(connectionMode: null), - throwsA(isA()), - ); - }); - - test('throws assertion error if null logLevel is provided', () { - expect( - () => StreamChatPersistenceClient(logLevel: null), - throwsA(isA()), - ); - }); - }); - group('connect', () { const userId = 'testUserId'; test('successfully connects with the Database', () async { final client = StreamChatPersistenceClient(logLevel: Level.ALL); expect(client.db, isNull); - await client.connect(userId, databaseProvider: _testDatabaseProvider); + await client.connect(userId, databaseProvider: testDatabaseProvider); expect(client.db, isNotNull); expect(client.db, isA()); - expect(client.db.userId, userId); + expect(client.db!.userId, userId); addTearDown(() async { await client.disconnect(); @@ -44,13 +29,13 @@ void main() { test('throws if already connected', () async { final client = StreamChatPersistenceClient(logLevel: Level.ALL); expect(client.db, isNull); - await client.connect(userId, databaseProvider: _testDatabaseProvider); + await client.connect(userId, databaseProvider: testDatabaseProvider); expect(client.db, isNotNull); expect(client.db, isNotNull); expect(client.db, isA()); - expect(client.db.userId, userId); + expect(client.db!.userId, userId); expect( - () => client.connect(userId, databaseProvider: _testDatabaseProvider), + () => client.connect(userId, databaseProvider: testDatabaseProvider), throwsException, ); @@ -63,7 +48,7 @@ void main() { test('disconnect', () async { const userId = 'testUserId'; final client = StreamChatPersistenceClient(logLevel: Level.ALL); - await client.connect(userId, databaseProvider: _testDatabaseProvider); + await client.connect(userId, databaseProvider: testDatabaseProvider); expect(client.db, isNotNull); await client.disconnect(flush: true); expect(client.db, isNull); @@ -73,7 +58,7 @@ void main() { const userId = 'testUserId'; final mockDatabase = MockChatDatabase(); MoorChatDatabase _mockDatabaseProvider(_, __) => mockDatabase; - StreamChatPersistenceClient client; + late StreamChatPersistenceClient client; setUp(() async { client = StreamChatPersistenceClient(logLevel: Level.ALL); @@ -84,139 +69,152 @@ void main() { const parentId = 'testParentId'; final replies = List.generate(3, (index) => Message(id: 'testId$index')); - when(mockDatabase.messageDao.getThreadMessagesByParentId(parentId)) + when(() => mockDatabase.messageDao.getThreadMessagesByParentId(parentId)) .thenAnswer((_) async => replies); final fetchedReplies = await client.getReplies(parentId); expect(fetchedReplies.length, replies.length); - verify(mockDatabase.messageDao.getThreadMessagesByParentId(parentId)) + verify(() => + mockDatabase.messageDao.getThreadMessagesByParentId(parentId)) .called(1); }); test('getConnectionInfo', () async { - final event = Event(type: 'testEvent'); - when(mockDatabase.connectionEventDao.connectionEvent) + final event = Event(); + when(() => mockDatabase.connectionEventDao.connectionEvent) .thenAnswer((_) async => event); final fetchedEvent = await client.getConnectionInfo(); - expect(fetchedEvent.type, event.type); - verify(mockDatabase.connectionEventDao.connectionEvent).called(1); + expect(fetchedEvent, isNotNull); + expect(fetchedEvent!.type, event.type); + verify(() => mockDatabase.connectionEventDao.connectionEvent).called(1); }); test('getLastSyncAt', () async { final lastSync = DateTime.now(); - when(mockDatabase.connectionEventDao.lastSyncAt) + when(() => mockDatabase.connectionEventDao.lastSyncAt) .thenAnswer((_) async => lastSync); final fetchedLastSync = await client.getLastSyncAt(); expect(fetchedLastSync, isSameDateAs(lastSync)); - verify(mockDatabase.connectionEventDao.lastSyncAt).called(1); + verify(() => mockDatabase.connectionEventDao.lastSyncAt).called(1); }); test('updateConnectionInfo', () async { - final event = Event(type: 'testEvent'); - when(mockDatabase.connectionEventDao.updateConnectionEvent(event)) - .thenAnswer((_) async { - return; - }); + final event = Event(); + when(() => mockDatabase.connectionEventDao.updateConnectionEvent(event)) + .thenAnswer((_) async => 1); await client.updateConnectionInfo(event); - verify(mockDatabase.connectionEventDao.updateConnectionEvent(event)) + verify(() => mockDatabase.connectionEventDao.updateConnectionEvent(event)) .called(1); }); test('updateLastSyncAt', () async { final lastSync = DateTime.now(); - when(mockDatabase.connectionEventDao.updateLastSyncAt(lastSync)) - .thenAnswer((_) { - return; - }); + when(() => mockDatabase.connectionEventDao.updateLastSyncAt(lastSync)) + .thenAnswer((_) async => 1); await client.updateLastSyncAt(lastSync); - verify(mockDatabase.connectionEventDao.updateLastSyncAt(lastSync)) + verify(() => mockDatabase.connectionEventDao.updateLastSyncAt(lastSync)) .called(1); }); test('getChannelCids', () async { final channelCids = List.generate(3, (index) => 'testCid$index'); - when(mockDatabase.channelDao.cids).thenAnswer((_) async => channelCids); + when(() => mockDatabase.channelDao.cids) + .thenAnswer((_) async => channelCids); final fetchedChannelCids = await client.getChannelCids(); expect(fetchedChannelCids.length, channelCids.length); - verify(mockDatabase.channelDao.cids).called(1); + verify(() => mockDatabase.channelDao.cids).called(1); }); test('getChannelByCid', () async { - const cid = 'testCid'; + const cid = 'testType:testId'; final channelModel = ChannelModel(cid: cid); - when(mockDatabase.channelDao.getChannelByCid(cid)) + when(() => mockDatabase.channelDao.getChannelByCid(cid)) .thenAnswer((_) async => channelModel); final fetchedChannelModel = await client.getChannelByCid(cid); - expect(fetchedChannelModel.cid, channelModel.cid); - verify(mockDatabase.channelDao.getChannelByCid(cid)).called(1); + expect(fetchedChannelModel, isNotNull); + expect(fetchedChannelModel!.cid, channelModel.cid); + verify(() => mockDatabase.channelDao.getChannelByCid(cid)).called(1); }); test('getMembersByCid', () async { const cid = 'testCid'; final members = List.generate(3, (index) => Member()); - when(mockDatabase.memberDao.getMembersByCid(cid)) + when(() => mockDatabase.memberDao.getMembersByCid(cid)) .thenAnswer((_) async => members); final fetchedMembers = await client.getMembersByCid(cid); expect(fetchedMembers.length, members.length); - verify(mockDatabase.memberDao.getMembersByCid(cid)).called(1); + verify(() => mockDatabase.memberDao.getMembersByCid(cid)).called(1); }); test('getReadsByCid', () async { const cid = 'testCid'; - final reads = List.generate(3, (index) => Read()); - when(mockDatabase.readDao.getReadsByCid(cid)) + final reads = List.generate( + 3, + (index) => Read( + user: User(id: 'testUserId$index'), + lastRead: DateTime.now(), + ), + ); + when(() => mockDatabase.readDao.getReadsByCid(cid)) .thenAnswer((_) async => reads); final fetchedReads = await client.getReadsByCid(cid); expect(fetchedReads.length, reads.length); - verify(mockDatabase.readDao.getReadsByCid(cid)).called(1); + verify(() => mockDatabase.readDao.getReadsByCid(cid)).called(1); }); test('getMessagesByCid', () async { const cid = 'testCid'; final messages = List.generate(3, (index) => Message()); - when(mockDatabase.messageDao.getMessagesByCid(cid)) + when(() => mockDatabase.messageDao.getMessagesByCid(cid)) .thenAnswer((_) async => messages); final fetchedMessages = await client.getMessagesByCid(cid); expect(fetchedMessages.length, messages.length); - verify(mockDatabase.messageDao.getMessagesByCid(cid)).called(1); + verify(() => mockDatabase.messageDao.getMessagesByCid(cid)).called(1); }); test('getPinnedMessagesByCid', () async { const cid = 'testCid'; final messages = List.generate(3, (index) => Message()); - when(mockDatabase.pinnedMessageDao.getMessagesByCid(cid)) + when(() => mockDatabase.pinnedMessageDao.getMessagesByCid(cid)) .thenAnswer((_) async => messages); final fetchedMessages = await client.getPinnedMessagesByCid(cid); expect(fetchedMessages.length, messages.length); - verify(mockDatabase.pinnedMessageDao.getMessagesByCid(cid)).called(1); + verify(() => mockDatabase.pinnedMessageDao.getMessagesByCid(cid)) + .called(1); }); test('getChannelStateByCid', () async { - const cid = 'testCid'; + const cid = 'testType:testId'; final messages = List.generate(3, (index) => Message()); final members = List.generate(3, (index) => Member()); - final reads = List.generate(3, (index) => Read()); + final reads = List.generate( + 3, + (index) => Read( + user: User(id: 'testUserId$index'), + lastRead: DateTime.now(), + ), + ); final channel = ChannelModel(cid: cid); - when(mockDatabase.memberDao.getMembersByCid(cid)) + when(() => mockDatabase.memberDao.getMembersByCid(cid)) .thenAnswer((_) async => members); - when(mockDatabase.readDao.getReadsByCid(cid)) + when(() => mockDatabase.readDao.getReadsByCid(cid)) .thenAnswer((_) async => reads); - when(mockDatabase.channelDao.getChannelByCid(cid)) + when(() => mockDatabase.channelDao.getChannelByCid(cid)) .thenAnswer((_) async => channel); - when(mockDatabase.messageDao.getMessagesByCid(cid)) + when(() => mockDatabase.messageDao.getMessagesByCid(cid)) .thenAnswer((_) async => messages); - when(mockDatabase.pinnedMessageDao.getMessagesByCid(cid)) + when(() => mockDatabase.pinnedMessageDao.getMessagesByCid(cid)) .thenAnswer((_) async => messages); final fetchedChannelState = await client.getChannelStateByCid(cid); @@ -224,21 +222,28 @@ void main() { expect(fetchedChannelState.pinnedMessages.length, messages.length); expect(fetchedChannelState.members.length, members.length); expect(fetchedChannelState.read.length, reads.length); - expect(fetchedChannelState.channel.cid, channel.cid); + expect(fetchedChannelState.channel!.cid, channel.cid); - verify(mockDatabase.memberDao.getMembersByCid(cid)).called(1); - verify(mockDatabase.readDao.getReadsByCid(cid)).called(1); - verify(mockDatabase.channelDao.getChannelByCid(cid)).called(1); - verify(mockDatabase.messageDao.getMessagesByCid(cid)).called(1); - verify(mockDatabase.pinnedMessageDao.getMessagesByCid(cid)).called(1); + verify(() => mockDatabase.memberDao.getMembersByCid(cid)).called(1); + verify(() => mockDatabase.readDao.getReadsByCid(cid)).called(1); + verify(() => mockDatabase.channelDao.getChannelByCid(cid)).called(1); + verify(() => mockDatabase.messageDao.getMessagesByCid(cid)).called(1); + verify(() => mockDatabase.pinnedMessageDao.getMessagesByCid(cid)) + .called(1); }); test('getChannelStates', () async { - const cid = 'testCid'; + const cid = 'testType:testId'; final channels = List.generate(3, (index) => ChannelModel(cid: cid)); final messages = List.generate(3, (index) => Message()); final members = List.generate(3, (index) => Member()); - final reads = List.generate(3, (index) => Read()); + final reads = List.generate( + 3, + (index) => Read( + user: User(id: 'testUserId$index'), + lastRead: DateTime.now(), + ), + ); final channel = ChannelModel(cid: cid); final channelStates = channels .map( @@ -252,17 +257,17 @@ void main() { ) .toList(growable: false); - when(mockDatabase.channelQueryDao.getChannels()) + when(() => mockDatabase.channelQueryDao.getChannels()) .thenAnswer((_) async => channels); - when(mockDatabase.memberDao.getMembersByCid(cid)) + when(() => mockDatabase.memberDao.getMembersByCid(cid)) .thenAnswer((_) async => members); - when(mockDatabase.readDao.getReadsByCid(cid)) + when(() => mockDatabase.readDao.getReadsByCid(cid)) .thenAnswer((_) async => reads); - when(mockDatabase.channelDao.getChannelByCid(cid)) + when(() => mockDatabase.channelDao.getChannelByCid(cid)) .thenAnswer((_) async => channel); - when(mockDatabase.messageDao.getMessagesByCid(cid)) + when(() => mockDatabase.messageDao.getMessagesByCid(cid)) .thenAnswer((_) async => messages); - when(mockDatabase.pinnedMessageDao.getMessagesByCid(cid)) + when(() => mockDatabase.pinnedMessageDao.getMessagesByCid(cid)) .thenAnswer((_) async => messages); final fetchedChannelStates = await client.getChannelStates(); @@ -275,154 +280,139 @@ void main() { expect(fetched.messages.length, original.messages.length); expect(fetched.pinnedMessages.length, original.pinnedMessages.length); expect(fetched.read.length, original.read.length); - expect(fetched.channel.cid, original.channel.cid); + expect(fetched.channel!.cid, original.channel!.cid); } - verify(mockDatabase.channelQueryDao.getChannels()).called(1); - verify(mockDatabase.memberDao.getMembersByCid(cid)).called(3); - verify(mockDatabase.readDao.getReadsByCid(cid)).called(3); - verify(mockDatabase.channelDao.getChannelByCid(cid)).called(3); - verify(mockDatabase.messageDao.getMessagesByCid(cid)).called(3); - verify(mockDatabase.pinnedMessageDao.getMessagesByCid(cid)).called(3); + verify(() => mockDatabase.channelQueryDao.getChannels()).called(1); + verify(() => mockDatabase.memberDao.getMembersByCid(cid)).called(3); + verify(() => mockDatabase.readDao.getReadsByCid(cid)).called(3); + verify(() => mockDatabase.channelDao.getChannelByCid(cid)).called(3); + verify(() => mockDatabase.messageDao.getMessagesByCid(cid)).called(3); + verify(() => mockDatabase.pinnedMessageDao.getMessagesByCid(cid)) + .called(3); }); test('updateChannelQueries', () async { - const filter = {}; + final filter = Filter.in_('members', const ['testUserId']); const cids = []; - when(mockDatabase.channelQueryDao.updateChannelQueries(filter, cids)) - .thenAnswer((realInvocation) async { - return; - }); + when(() => + mockDatabase.channelQueryDao.updateChannelQueries(filter, cids)) + .thenAnswer((_) => Future.value()); await client.updateChannelQueries(filter, cids); - verify(mockDatabase.channelQueryDao.updateChannelQueries(filter, cids)) + verify(() => + mockDatabase.channelQueryDao.updateChannelQueries(filter, cids)) .called(1); }); test('deleteMessageById', () async { const messageId = 'testMessageId'; - when(mockDatabase.messageDao.deleteMessageByIds([messageId])) - .thenAnswer((_) async { - return; - }); + when(() => mockDatabase.messageDao.deleteMessageByIds([messageId])) + .thenAnswer((_) async => 1); await client.deleteMessageById(messageId); - verify(mockDatabase.messageDao.deleteMessageByIds([messageId])).called(1); + verify(() => mockDatabase.messageDao.deleteMessageByIds([messageId])) + .called(1); }); test('deletePinnedMessageById', () async { const messageId = 'testMessageId'; - when(mockDatabase.pinnedMessageDao.deleteMessageByIds([messageId])) - .thenAnswer((_) async { - return; - }); + when(() => mockDatabase.pinnedMessageDao.deleteMessageByIds([messageId])) + .thenAnswer((_) async => 1); await client.deletePinnedMessageById(messageId); - verify(mockDatabase.pinnedMessageDao.deleteMessageByIds([messageId])) + verify(() => + mockDatabase.pinnedMessageDao.deleteMessageByIds([messageId])) .called(1); }); test('deleteMessageByIds', () async { const messageIds = []; - when(mockDatabase.messageDao.deleteMessageByIds(messageIds)) - .thenAnswer((_) async { - return; - }); + when(() => mockDatabase.messageDao.deleteMessageByIds(messageIds)) + .thenAnswer((_) async => 1); await client.deleteMessageByIds(messageIds); - verify(mockDatabase.messageDao.deleteMessageByIds(messageIds)).called(1); + verify(() => mockDatabase.messageDao.deleteMessageByIds(messageIds)) + .called(1); }); test('deletePinnedMessageByIds', () async { const messageIds = []; - when(mockDatabase.pinnedMessageDao.deleteMessageByIds(messageIds)) - .thenAnswer((_) async { - return; - }); + when(() => mockDatabase.pinnedMessageDao.deleteMessageByIds(messageIds)) + .thenAnswer((_) async => 1); await client.deletePinnedMessageByIds(messageIds); - verify(mockDatabase.pinnedMessageDao.deleteMessageByIds(messageIds)) + verify(() => mockDatabase.pinnedMessageDao.deleteMessageByIds(messageIds)) .called(1); }); test('deleteMessageByCid', () async { const cid = 'testCid'; - when(mockDatabase.messageDao.deleteMessageByCids([cid])) - .thenAnswer((_) async { - return; - }); + when(() => mockDatabase.messageDao.deleteMessageByCids([cid])) + .thenAnswer((_) async => 1); await client.deleteMessageByCid(cid); - verify(mockDatabase.messageDao.deleteMessageByCids([cid])).called(1); + verify(() => mockDatabase.messageDao.deleteMessageByCids([cid])) + .called(1); }); test('deletePinnedMessageByCid', () async { const cid = 'testCid'; - when(mockDatabase.pinnedMessageDao.deleteMessageByCids([cid])) - .thenAnswer((_) async { - return; - }); + when(() => mockDatabase.pinnedMessageDao.deleteMessageByCids([cid])) + .thenAnswer((_) async => 1); await client.deletePinnedMessageByCid(cid); - verify(mockDatabase.pinnedMessageDao.deleteMessageByCids([cid])) + verify(() => mockDatabase.pinnedMessageDao.deleteMessageByCids([cid])) .called(1); }); test('deleteMessageByCids', () async { const cids = []; - when(mockDatabase.messageDao.deleteMessageByCids(cids)) - .thenAnswer((_) async { - return; - }); + when(() => mockDatabase.messageDao.deleteMessageByCids(cids)) + .thenAnswer((_) async => 1); await client.deleteMessageByCids(cids); - verify(mockDatabase.messageDao.deleteMessageByCids(cids)).called(1); + verify(() => mockDatabase.messageDao.deleteMessageByCids(cids)).called(1); }); test('deletePinnedMessageByCids', () async { const cids = []; - when(mockDatabase.pinnedMessageDao.deleteMessageByCids(cids)) - .thenAnswer((_) async { - return; - }); + when(() => mockDatabase.pinnedMessageDao.deleteMessageByCids(cids)) + .thenAnswer((_) async => 1); await client.deletePinnedMessageByCids(cids); - verify(mockDatabase.pinnedMessageDao.deleteMessageByCids(cids)).called(1); + verify(() => mockDatabase.pinnedMessageDao.deleteMessageByCids(cids)) + .called(1); }); test('deleteChannels', () async { const cids = []; - when(mockDatabase.channelDao.deleteChannelByCids(cids)) - .thenAnswer((_) async { - return; - }); + when(() => mockDatabase.channelDao.deleteChannelByCids(cids)) + .thenAnswer((_) async => 1); await client.deleteChannels(cids); - verify(mockDatabase.channelDao.deleteChannelByCids(cids)).called(1); + verify(() => mockDatabase.channelDao.deleteChannelByCids(cids)).called(1); }); test('updateMessages', () async { const cid = 'testCid'; final messages = List.generate(3, (index) => Message()); - when(mockDatabase.messageDao.updateMessages(cid, messages)) - .thenAnswer((_) async { - return; - }); + when(() => mockDatabase.messageDao.updateMessages(cid, messages)) + .thenAnswer((_) => Future.value()); await client.updateMessages(cid, messages); - verify(mockDatabase.messageDao.updateMessages(cid, messages)).called(1); + verify(() => mockDatabase.messageDao.updateMessages(cid, messages)) + .called(1); }); test('updatePinnedMessages', () async { const cid = 'testCid'; final messages = List.generate(3, (index) => Message()); - when(mockDatabase.pinnedMessageDao.updateMessages(cid, messages)) - .thenAnswer((_) async { - return; - }); + when(() => mockDatabase.pinnedMessageDao.updateMessages(cid, messages)) + .thenAnswer((_) => Future.value()); await client.updatePinnedMessages(cid, messages); - verify(mockDatabase.pinnedMessageDao.updateMessages(cid, messages)) + verify(() => mockDatabase.pinnedMessageDao.updateMessages(cid, messages)) .called(1); }); @@ -432,16 +422,14 @@ void main() { List.generate(3, (index) => Message(parentId: 'testParentId$index')); final threads = messages.fold>>( {}, - (prev, curr) { - return prev - ..update( - curr.parentId, - (value) => [...value, curr], - ifAbsent: () => [], - ); - }, + (prev, curr) => prev + ..update( + curr.parentId!, + (value) => [...value, curr], + ifAbsent: () => [], + ), ); - when(mockDatabase.messageDao.getThreadMessages(cid)) + when(() => mockDatabase.messageDao.getThreadMessages(cid)) .thenAnswer((realInvocation) async => messages); final fetchedThreads = await client.getChannelThreads(cid); @@ -452,85 +440,87 @@ void main() { expect(fetched.key, original.key); } - verify(mockDatabase.messageDao.getThreadMessages(cid)).called(1); + verify(() => mockDatabase.messageDao.getThreadMessages(cid)).called(1); }); test('updateChannels', () async { - final channels = List.generate(3, (index) => ChannelModel()); - when(mockDatabase.channelDao.updateChannels(channels)) - .thenAnswer((_) async { - return; - }); + const cid = 'testType:testId'; + final channels = List.generate(3, (index) => ChannelModel(cid: cid)); + when(() => mockDatabase.channelDao.updateChannels(channels)) + .thenAnswer((_) => Future.value()); await client.updateChannels(channels); - verify(mockDatabase.channelDao.updateChannels(channels)).called(1); + verify(() => mockDatabase.channelDao.updateChannels(channels)).called(1); }); test('updateMembers', () async { const cid = 'testCid'; final members = List.generate(3, (index) => Member()); - when(mockDatabase.memberDao.updateMembers(cid, members)) - .thenAnswer((_) async { - return; - }); + when(() => mockDatabase.memberDao.updateMembers(cid, members)) + .thenAnswer((_) => Future.value()); await client.updateMembers(cid, members); - verify(mockDatabase.memberDao.updateMembers(cid, members)).called(1); + verify(() => mockDatabase.memberDao.updateMembers(cid, members)) + .called(1); }); test('updateReads', () async { const cid = 'testCid'; - final reads = List.generate(3, (index) => Read()); - when(mockDatabase.readDao.updateReads(cid, reads)).thenAnswer((_) async { - return; - }); + final reads = List.generate( + 3, + (index) => Read( + user: User(id: 'testUserId$index'), + lastRead: DateTime.now(), + ), + ); + when(() => mockDatabase.readDao.updateReads(cid, reads)) + .thenAnswer((_) => Future.value()); await client.updateReads(cid, reads); - verify(mockDatabase.readDao.updateReads(cid, reads)).called(1); + verify(() => mockDatabase.readDao.updateReads(cid, reads)).called(1); }); test('updateUsers', () async { - final users = List.generate(3, (index) => User()); - when(mockDatabase.userDao.updateUsers(users)).thenAnswer((_) async { - return; - }); + final users = List.generate(3, (index) => User(id: 'testUserId$index')); + when(() => mockDatabase.userDao.updateUsers(users)) + .thenAnswer((_) => Future.value()); await client.updateUsers(users); - verify(mockDatabase.userDao.updateUsers(users)).called(1); + verify(() => mockDatabase.userDao.updateUsers(users)).called(1); }); test('updateReactions', () async { - final reactions = List.generate(3, (index) => Reaction()); - when(mockDatabase.reactionDao.updateReactions(reactions)) - .thenAnswer((_) async { - return; - }); + final reactions = List.generate( + 3, + (index) => Reaction(type: 'testType$index'), + ); + when(() => mockDatabase.reactionDao.updateReactions(reactions)) + .thenAnswer((_) => Future.value()); await client.updateReactions(reactions); - verify(mockDatabase.reactionDao.updateReactions(reactions)).called(1); + verify(() => mockDatabase.reactionDao.updateReactions(reactions)) + .called(1); }); test('deleteReactionsByMessageId', () async { final messageIds = []; - when(mockDatabase.reactionDao.deleteReactionsByMessageIds(messageIds)) - .thenAnswer((_) async { - return; - }); + when(() => + mockDatabase.reactionDao.deleteReactionsByMessageIds(messageIds)) + .thenAnswer((_) => Future.value()); await client.deleteReactionsByMessageId(messageIds); - verify(mockDatabase.reactionDao.deleteReactionsByMessageIds(messageIds)) + verify(() => + mockDatabase.reactionDao.deleteReactionsByMessageIds(messageIds)) .called(1); }); test('deleteMembersByCids', () async { final cids = []; - when(mockDatabase.memberDao.deleteMemberByCids(cids)) - .thenAnswer((_) async { - return; - }); + when(() => mockDatabase.memberDao.deleteMemberByCids(cids)) + .thenAnswer((_) => Future.value()); await client.deleteMembersByCids(cids); - verify(mockDatabase.memberDao.deleteMemberByCids(cids)).called(1); + verify(() => mockDatabase.memberDao.deleteMemberByCids(cids)).called(1); }); tearDown(() async {