├── .eslintignore ├── .eslintrc.js ├── .github ├── ISSUE_TEMPLATE │ ├── ---bug-report.md │ ├── ---feature-request.md │ ├── ---questions---help.md │ └── config.yml ├── pull_request_template.md └── workflows │ └── build.yml ├── .gitignore ├── .prettierrc.js ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── __mocks__ └── react-native-safe-area-context.ts ├── babel.config.js ├── docs ├── advanced-usage.md ├── basic-usage.md ├── installation.md ├── localization.md ├── overview.md ├── themes.md └── types.md ├── example ├── .buckconfig ├── .editorconfig ├── .eslintrc.js ├── .gitattributes ├── .gitignore ├── .prettierrc.js ├── .watchmanconfig ├── README.md ├── android │ ├── app │ │ ├── _BUCK │ │ ├── build.gradle │ │ ├── build_defs.bzl │ │ ├── debug.keystore │ │ ├── proguard-rules.pro │ │ └── src │ │ │ ├── debug │ │ │ ├── AndroidManifest.xml │ │ │ └── java │ │ │ │ └── com │ │ │ │ └── example │ │ │ │ └── ReactNativeFlipper.kt │ │ │ └── main │ │ │ ├── AndroidManifest.xml │ │ │ ├── java │ │ │ └── com │ │ │ │ └── example │ │ │ │ ├── MainActivity.kt │ │ │ │ └── MainApplication.kt │ │ │ └── res │ │ │ ├── mipmap-hdpi │ │ │ ├── ic_launcher.png │ │ │ └── ic_launcher_round.png │ │ │ ├── mipmap-mdpi │ │ │ ├── ic_launcher.png │ │ │ └── ic_launcher_round.png │ │ │ ├── mipmap-xhdpi │ │ │ ├── ic_launcher.png │ │ │ └── ic_launcher_round.png │ │ │ ├── mipmap-xxhdpi │ │ │ ├── ic_launcher.png │ │ │ └── ic_launcher_round.png │ │ │ ├── mipmap-xxxhdpi │ │ │ ├── ic_launcher.png │ │ │ └── ic_launcher_round.png │ │ │ └── values │ │ │ ├── strings.xml │ │ │ └── styles.xml │ ├── build.gradle │ ├── gradle.properties │ ├── gradle │ │ └── wrapper │ │ │ ├── gradle-wrapper.jar │ │ │ └── gradle-wrapper.properties │ ├── gradlew │ ├── gradlew.bat │ └── settings.gradle ├── app.json ├── babel.config.js ├── index.js ├── ios │ ├── Podfile │ ├── Podfile.lock │ ├── example.xcodeproj │ │ ├── project.pbxproj │ │ ├── project.xcworkspace │ │ │ ├── contents.xcworkspacedata │ │ │ └── xcshareddata │ │ │ │ └── IDEWorkspaceChecks.plist │ │ └── xcshareddata │ │ │ └── xcschemes │ │ │ └── example.xcscheme │ ├── example.xcworkspace │ │ ├── contents.xcworkspacedata │ │ └── xcshareddata │ │ │ └── IDEWorkspaceChecks.plist │ └── example │ │ ├── AppDelegate.swift │ │ ├── Images.xcassets │ │ ├── AppIcon.appiconset │ │ │ └── Contents.json │ │ └── Contents.json │ │ ├── Info.plist │ │ ├── LaunchScreen.storyboard │ │ └── example-Bridging-Header.h ├── metro.config.js ├── package.json ├── scripts │ └── generateMessages.js ├── src │ ├── App.tsx │ ├── AppContainer.tsx │ └── global.d.ts ├── tsconfig.json └── yarn.lock ├── jest ├── fixtures.ts └── setup.ts ├── package.json ├── src ├── assets │ ├── icon-attachment.png │ ├── icon-attachment@2x.png │ ├── icon-attachment@3x.png │ ├── icon-delivered.png │ ├── icon-delivered@2x.png │ ├── icon-delivered@3x.png │ ├── icon-document.png │ ├── icon-document@2x.png │ ├── icon-document@3x.png │ ├── icon-error.png │ ├── icon-error@2x.png │ ├── icon-error@3x.png │ ├── icon-reply.png │ ├── icon-reply@2x.png │ ├── icon-reply@3x.png │ ├── icon-seen.png │ ├── icon-seen@2x.png │ ├── icon-seen@3x.png │ ├── icon-send.png │ ├── icon-send@2x.png │ ├── icon-send@3x.png │ ├── icon-x.png │ ├── icon-x@2x.png │ └── icon-x@3x.png ├── components │ ├── AttachmentButton │ │ ├── AttachmentButton.tsx │ │ └── index.ts │ ├── Avatar │ │ ├── Avatar.tsx │ │ ├── __tests__ │ │ │ └── Avatar.test.tsx │ │ └── index.ts │ ├── Chat │ │ ├── Chat.tsx │ │ ├── ImageView.android.ts │ │ ├── ImageView.ios.ts │ │ ├── ImageView.tsx │ │ ├── __tests__ │ │ │ └── Chat.test.tsx │ │ ├── index.ts │ │ └── styles.ts │ ├── CircularActivityIndicator │ │ ├── CircularActivityIndicator.tsx │ │ ├── index.ts │ │ └── styles.ts │ ├── FileMessage │ │ ├── FileMessage.tsx │ │ ├── index.ts │ │ └── styles.ts │ ├── ImageMessage │ │ ├── ImageMessage.tsx │ │ ├── __tests__ │ │ │ └── ImageMessage.test.tsx │ │ ├── index.ts │ │ └── styles.ts │ ├── Input │ │ ├── Input.tsx │ │ ├── __tests__ │ │ │ └── Input.test.tsx │ │ ├── index.ts │ │ └── styles.ts │ ├── Message │ │ ├── Message.tsx │ │ ├── __tests__ │ │ │ └── Message.test.tsx │ │ ├── index.ts │ │ └── styles.ts │ ├── SendButton │ │ ├── SendButton.tsx │ │ └── index.ts │ ├── StatusIcon │ │ ├── StatusIcon.tsx │ │ ├── __tests__ │ │ │ └── StatusIcon.test.tsx │ │ └── index.ts │ ├── TextMessage │ │ ├── TextMessage.tsx │ │ ├── __tests__ │ │ │ └── TextMessage.test.tsx │ │ ├── index.ts │ │ └── styles.ts │ └── index.ts ├── global.d.ts ├── hooks │ ├── index.ts │ └── usePrevious.ts ├── index.ts ├── l10n.ts ├── theme.ts ├── types.ts └── utils │ ├── __tests__ │ └── utils.test.ts │ └── index.ts ├── tsconfig.json └── yarn.lock /.eslintignore: -------------------------------------------------------------------------------- 1 | **/node_modules 2 | coverage/ 3 | example/ 4 | lib/ 5 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | jest: true, 4 | }, 5 | extends: [ 6 | '@react-native-community', 7 | 'plugin:jest/all', 8 | 'plugin:prettier/recommended', 9 | ], 10 | plugins: ['simple-import-sort', 'jest'], 11 | root: true, 12 | rules: { 13 | 'import/order': 'off', 14 | 'jest/no-hooks': ['error', { allow: ['beforeEach'] }], 15 | 'simple-import-sort/exports': 'error', 16 | 'simple-import-sort/imports': 'error', 17 | 'sort-imports': 'off', 18 | }, 19 | } 20 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/---bug-report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: "\U0001F41B Bug report" 3 | about: Something isn't working? Create a report to help us improve Flyer Chat. 4 | title: '' 5 | labels: bug 6 | assignees: '' 7 | 8 | --- 9 | 10 | 22 | 23 | # General 24 | 25 | ## What bug do you experience? 🐞 26 | Provide a clear and concise description of what the bug is. 27 | 28 | ## How can it be reproduced? 🤔 29 | A few steps to define where does the bug occur. 30 | Step 1. ... 31 | Step 2. ... etc. 32 | 33 | ## What behavior is expected? 💡 34 | A clear and concise description of what you expected to happen. 35 | 36 |
37 | 38 | # Extras 39 | 40 | ## Screenshots or videos 📸 41 | If applicable, add screenshots or videos to help explain your problem. 42 | 43 | ## Code snippets 📝 44 | If applicable, add code samples to help explain your problem. 45 | 46 | ```ts 47 | // Your snippet here... 48 | ``` 49 | 50 |
51 | 52 | # Environment info 53 | 54 | Please specify the react, react-native, react-native-chat-ui versions. 55 | 56 | react: ... 57 | react-native: ... 58 | react-native-chat-ui: ... 59 | 60 | `npx react-native info` output 👇 61 | 62 | ```sh 63 | ``` 64 | 65 |
66 | 67 | # Platform 68 | 69 | Device (e.g. Android emulator, iOS simulator, Samsung Galaxy S21): ... 70 | 71 | OS version (e.g. iOS 14.5, Android 11): ... 72 | 73 |
74 | 75 | # Additional context 76 | 77 | Add any other context about the problem here. 78 | 79 | 80 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/---feature-request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: "⚡️ Feature request" 3 | about: Suggest an idea to help make Flyer Chat even better! 4 | title: '' 5 | labels: feature 6 | assignees: '' 7 | 8 | --- 9 | 10 | 15 | 16 | ## Is your feature request related to a problem? 17 | A clear and concise description of what the problem is or why we should process it. 18 | 19 | ## What solution would you suggest? 20 | A clear and concise description of what you want to happen. How would it solve the problem of yours? 21 | 22 | ## Is there any additional solution to that? 23 | A clear and concise description of any alternative solutions or features you've considered. 24 | 25 | ## Extras 26 | **Screenshots or videos 📸** 27 | If applicable, add screenshots or videos to help explain your feature. 28 | 29 | **Code snippets 📝** 30 | If applicable, add code samples to help explain your feature. 31 | 32 | ```ts 33 | // Your snippet here... 34 | ``` 35 | 36 | ## Related issues/PRs 37 | Let us know if this is related to any issue/pull request. 38 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/---questions---help.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: "\U0001F914 Questions & help" 3 | about: " Use this if there is something not clear about the code or documentation." 4 | title: '' 5 | labels: question 6 | assignees: '' 7 | 8 | --- 9 | 10 | 17 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | contact_links: 3 | - name: Link preview 4 | url: https://github.com/flyerhq/react-native-link-preview/issues/new 5 | about: Use this to report link preview issues. 6 | - name: Firebase chat core 7 | url: https://github.com/flyerhq/react-native-firebase-chat-core/issues/new/choose 8 | about: Use this to report Firebase chat core issues. 9 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | 13 | 14 | ### What does it do? 15 | 16 | Describe the technical changes you did. 17 | 18 | ### Why is it needed? 19 | 20 | Describe the issue you are solving. 21 | 22 | ### How to test it? 23 | 24 | Provide information about the environment and the path to verify the behavior. 25 | 26 | ### Related issues/PRs 27 | 28 | Let us know if this is related to any issue/pull request. 29 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: build 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | pull_request: 7 | branches: [main] 8 | 9 | jobs: 10 | build-and-test: 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | - uses: actions/checkout@v2 15 | 16 | - name: Setup Node.js environment 17 | uses: actions/setup-node@v1 18 | with: 19 | node-version: 16.x 20 | 21 | - name: Restore cache 22 | id: cache 23 | uses: actions/cache@v2 24 | with: 25 | path: node_modules 26 | key: ${{ runner.os }}-${{ hashFiles('yarn.lock') }} 27 | 28 | - name: Install dependencies 29 | if: steps.cache.outputs.cache-hit != 'true' 30 | run: yarn 31 | 32 | - run: yarn lint 33 | - run: yarn type-coverage 34 | 35 | - name: Coverage 36 | uses: paambaati/codeclimate-action@v2.6.0 37 | env: 38 | CC_TEST_REPORTER_ID: ${{secrets.CC_TEST_REPORTER_ID}} 39 | with: 40 | coverageCommand: yarn test 41 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # OSX 2 | # 3 | .DS_Store 4 | 5 | # Xcode 6 | # 7 | build/ 8 | *.pbxuser 9 | !default.pbxuser 10 | *.mode1v3 11 | !default.mode1v3 12 | *.mode2v3 13 | !default.mode2v3 14 | *.perspectivev3 15 | !default.perspectivev3 16 | xcuserdata 17 | *.xccheckout 18 | *.moved-aside 19 | DerivedData 20 | *.hmap 21 | *.ipa 22 | *.xcuserstate 23 | 24 | # Android/IntelliJ 25 | # 26 | build/ 27 | .idea 28 | .gradle 29 | local.properties 30 | *.iml 31 | *.hprof 32 | /android/gradlew 33 | /android/gradlew.bat 34 | /android/gradle/ 35 | 36 | # node.js 37 | # 38 | node_modules/ 39 | npm-debug.log 40 | yarn-error.log 41 | 42 | # BUCK 43 | buck-out/ 44 | \.buckd/ 45 | *.keystore 46 | !debug.keystore 47 | 48 | # fastlane 49 | # 50 | # It is recommended to not store the screenshots in the git repo. Instead, use fastlane to re-generate the 51 | # screenshots whenever they are needed. 52 | # For more information about the recommended setup visit: 53 | # https://docs.fastlane.tools/best-practices/source-control/ 54 | 55 | */fastlane/report.xml 56 | */fastlane/Preview.html 57 | */fastlane/screenshots 58 | 59 | # Bundle artifact 60 | *.jsbundle 61 | 62 | # CocoaPods 63 | /ios/Pods/ 64 | 65 | # Library 66 | lib/ 67 | 68 | # Type coverage 69 | .type-coverage/ 70 | 71 | # Tests coverage 72 | coverage/ 73 | -------------------------------------------------------------------------------- /.prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | jsxSingleQuote: true, 3 | semi: false, 4 | singleQuote: true, 5 | } 6 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | We as members, contributors, and leaders pledge to make participation in our 6 | community a harassment-free experience for everyone, regardless of age, body 7 | size, visible or invisible disability, ethnicity, sex characteristics, gender 8 | identity and expression, level of experience, education, socio-economic status, 9 | nationality, personal appearance, race, caste, color, religion, or sexual identity 10 | and orientation. 11 | 12 | We pledge to act and interact in ways that contribute to an open, welcoming, 13 | diverse, inclusive, and healthy community. 14 | 15 | ## Our Standards 16 | 17 | Examples of behavior that contributes to a positive environment for our 18 | community include: 19 | 20 | * Demonstrating empathy and kindness toward other people 21 | * Being respectful of differing opinions, viewpoints, and experiences 22 | * Giving and gracefully accepting constructive feedback 23 | * Accepting responsibility and apologizing to those affected by our mistakes, 24 | and learning from the experience 25 | * Focusing on what is best not just for us as individuals, but for the 26 | overall community 27 | 28 | Examples of unacceptable behavior include: 29 | 30 | * The use of sexualized language or imagery, and sexual attention or 31 | advances of any kind 32 | * Trolling, insulting or derogatory comments, and personal or political attacks 33 | * Public or private harassment 34 | * Publishing others' private information, such as a physical or email 35 | address, without their explicit permission 36 | * Other conduct which could reasonably be considered inappropriate in a 37 | professional setting 38 | 39 | ## Enforcement Responsibilities 40 | 41 | Community leaders are responsible for clarifying and enforcing our standards of 42 | acceptable behavior and will take appropriate and fair corrective action in 43 | response to any behavior that they deem inappropriate, threatening, offensive, 44 | or harmful. 45 | 46 | Community leaders have the right and responsibility to remove, edit, or reject 47 | comments, commits, code, wiki edits, issues, and other contributions that are 48 | not aligned to this Code of Conduct, and will communicate reasons for moderation 49 | decisions when appropriate. 50 | 51 | ## Scope 52 | 53 | This Code of Conduct applies within all community spaces, and also applies when 54 | an individual is officially representing the community in public spaces. 55 | Examples of representing our community include using an official e-mail address, 56 | posting via an official social media account, or acting as an appointed 57 | representative at an online or offline event. 58 | 59 | ## Enforcement 60 | 61 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 62 | reported to the community leaders responsible for enforcement at . 63 | All complaints will be reviewed and investigated promptly and fairly. 64 | 65 | All community leaders are obligated to respect the privacy and security of the 66 | reporter of any incident. 67 | 68 | ## Enforcement Guidelines 69 | 70 | Community leaders will follow these Community Impact Guidelines in determining 71 | the consequences for any action they deem in violation of this Code of Conduct: 72 | 73 | ### 1. Correction 74 | 75 | **Community Impact**: Use of inappropriate language or other behavior deemed 76 | unprofessional or unwelcome in the community. 77 | 78 | **Consequence**: A private, written warning from community leaders, providing 79 | clarity around the nature of the violation and an explanation of why the 80 | behavior was inappropriate. A public apology may be requested. 81 | 82 | ### 2. Warning 83 | 84 | **Community Impact**: A violation through a single incident or series 85 | of actions. 86 | 87 | **Consequence**: A warning with consequences for continued behavior. No 88 | interaction with the people involved, including unsolicited interaction with 89 | those enforcing the Code of Conduct, for a specified period of time. This 90 | includes avoiding interactions in community spaces as well as external channels 91 | like social media. Violating these terms may lead to a temporary or 92 | permanent ban. 93 | 94 | ### 3. Temporary Ban 95 | 96 | **Community Impact**: A serious violation of community standards, including 97 | sustained inappropriate behavior. 98 | 99 | **Consequence**: A temporary ban from any sort of interaction or public 100 | communication with the community for a specified period of time. No public or 101 | private interaction with the people involved, including unsolicited interaction 102 | with those enforcing the Code of Conduct, is allowed during this period. 103 | Violating these terms may lead to a permanent ban. 104 | 105 | ### 4. Permanent Ban 106 | 107 | **Community Impact**: Demonstrating a pattern of violation of community 108 | standards, including sustained inappropriate behavior, harassment of an 109 | individual, or aggression toward or disparagement of classes of individuals. 110 | 111 | **Consequence**: A permanent ban from any sort of public interaction within 112 | the community. 113 | 114 | ## Attribution 115 | 116 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 117 | version 2.0, available at 118 | [https://www.contributor-covenant.org/version/2/0/code_of_conduct.html][v2.0]. 119 | 120 | Community Impact Guidelines were inspired by 121 | [Mozilla's code of conduct enforcement ladder][Mozilla CoC]. 122 | 123 | For answers to common questions about this code of conduct, see the FAQ at 124 | [https://www.contributor-covenant.org/faq][FAQ]. Translations are available 125 | at [https://www.contributor-covenant.org/translations][translations]. 126 | 127 | [homepage]: https://www.contributor-covenant.org 128 | [v2.0]: https://www.contributor-covenant.org/version/2/0/code_of_conduct.html 129 | [Mozilla CoC]: https://github.com/mozilla/diversity 130 | [FAQ]: https://www.contributor-covenant.org/faq 131 | [translations]: https://www.contributor-covenant.org/translations 132 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to Flyer Chat 2 | 3 | Thank you for your interest in contributing to Flyer Chat! Our vision is to create an easy-to-use chat experience for any application and while we can't have everything right from the start, we can build it together! If you plan to implement a feature and open a pull request, make sure you don't spend a lot of time on it, otherwise open an issue so we can discuss bigger stuff. 4 | 5 | The [Open Source Guides](https://opensource.guide) website has a collection of resources for individuals, communities, and companies who want to learn how to run and contribute to an open-source project. Contributors and people new to open source alike will find the following guides especially useful: 6 | 7 | * [How to Contribute to Open Source](https://opensource.guide/how-to-contribute/) 8 | * [Building Welcoming Communities](https://opensource.guide/building-community/) 9 | 10 | ## Helping with Documentation 11 | 12 | We use [Docusaurus](https://docusaurus.io) to build our documentation which is located in the [docs](https://github.com/flyerhq/react-native-chat-ui/tree/main/docs) folder. If you are adding new functionality or introducing a behavior change, we will ask you to update the documentation to reflect your changes. 13 | 14 | ## Contributing Code 15 | 16 | Code-level contributions to Flyer Chat generally come in the form of pull requests. These are done by forking the repo and making changes locally. Directly in the repo, there is the [example](https://github.com/flyerhq/react-native-chat-ui/tree/main/example) project that you can install on your device (or simulators) and use to test the changes you're making to Flyer Chat sources. 17 | 18 | [Here](https://opensource.guide/how-to-contribute/#opening-a-pull-request) you can read how to open a pull request. 19 | 20 | Please read [this section](https://github.com/demchenkoalex/react-native-module-template#how-to-see-my-changes-immediately-in-the-example) of the template library we use so the hot reload works for you when you are changing source files. Run `yarn` in the root folder to install dependencies for the library and `yarn` / `npx pod-install` in the `example` folder to install dependencies for the example project. After you are done with the changes, run `yarn compile`, `yarn lint` and `yarn type-coverage` in the root folder to make sure your code is consistent with our style. 21 | 22 | ## Community Contributions 23 | 24 | Contributions to Flyer Chat are not limited to GitHub. You can help others by sharing your experience using Flyer Chat, whether that is through blog posts, presenting talks at conferences, or simply sharing your thoughts on social media. 25 | 26 | ## License 27 | 28 | By contributing to Flyer Chat, you agree that your contributions will be licensed under its [Apache License, Version 2.0](LICENSE). 29 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | Copyright 2021 Oleksandr Demchenko 179 | 180 | Licensed under the Apache License, Version 2.0 (the "License"); 181 | you may not use this file except in compliance with the License. 182 | You may obtain a copy of the License at 183 | 184 | http://www.apache.org/licenses/LICENSE-2.0 185 | 186 | Unless required by applicable law or agreed to in writing, software 187 | distributed under the License is distributed on an "AS IS" BASIS, 188 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 189 | See the License for the specific language governing permissions and 190 | limitations under the License. 191 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | ⚠️⚠️ Currently not maintained, please fork or look for alternatives ⚠️⚠️ 3 |

4 | 5 |
6 | 7 |

8 | 9 | Flyer Chat Logo 10 | 11 |

12 | 13 |

React Native Chat UI

14 | 15 |

16 | Actively maintained, community-driven chat UI implementation with an optional Firebase BaaS. 17 |

18 | 19 |

20 | 21 | NPM 22 | 23 | 24 | Build Status 25 | 26 | 27 | Maintainability 28 | 29 | 30 | Test Coverage 31 | 32 | 33 | Type Coverage 34 | 35 |

36 | 37 |
38 | 39 |

40 | 41 | Chat Image 42 | 43 |

44 | 45 |
46 | 47 | Flyer Chat is a platform for creating in-app chat experiences using React Native or [Flutter](https://github.com/flyerhq/flutter_chat_ui). This repository contains chat UI implementation for React Native. 48 | 49 | * **Free, open-source and community-driven**. We offer no paid plugins and strive to create an easy-to-use, almost drop-in chat experience for any application. Contributions are more than welcome! Please read our [Contributing Guide](CONTRIBUTING.md). 50 | 51 | * **Backend agnostic**. You can choose the backend you prefer. But if you don't have one, we provide our own free and open-source [Firebase implementation](https://github.com/flyerhq/react-native-firebase-chat-core), which can be used to create a working chat in minutes. We are also working on our more advanced SaaS and self-hosted solutions. 52 | 53 | * **Customizable**. Supports custom themes, locales and more. Check our [documentation](https://docs.flyer.chat/react-native/chat-ui) for the info. More options are on the way, let us know if something is missing. 54 | 55 | * **Minimum dependencies**. Our packages are lightweight. Use your favourite packages for selecting images, opening files etc. See the [example](https://github.com/flyerhq/react-native-chat-ui/blob/main/example/src/App.tsx) for possible implementation. 56 | 57 | ## Getting Started 58 | 59 | ### Requirements 60 | 61 | `React Native >=0.60.0` 62 | 63 | Read our [documentation](https://docs.flyer.chat/react-native/chat-ui) or see the [example](https://github.com/flyerhq/react-native-chat-ui/tree/main/example) project. 64 | 65 | ## Contributing 66 | 67 | Please read our [Contributing Guide](CONTRIBUTING.md) before submitting a pull request to the project. 68 | 69 | ## Code of Conduct 70 | 71 | Flyer Chat has adopted the [Contributor Covenant](https://www.contributor-covenant.org) as its Code of Conduct, and we expect project participants to adhere to it. Please read [the full text](CODE_OF_CONDUCT.md) so that you can understand what actions will and will not be tolerated. 72 | 73 | ## License 74 | 75 | Licensed under the [Apache License, Version 2.0](LICENSE) 76 | -------------------------------------------------------------------------------- /__mocks__/react-native-safe-area-context.ts: -------------------------------------------------------------------------------- 1 | export const useSafeAreaFrame = jest.fn(() => ({ 2 | height: 896, 3 | width: 414, 4 | x: 0, 5 | y: 0, 6 | })) 7 | 8 | export const useSafeAreaInsets = jest.fn(() => ({ 9 | top: 0, 10 | right: 0, 11 | bottom: 34, 12 | left: 0, 13 | })) 14 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: ['module:metro-react-native-babel-preset'], 3 | } 4 | -------------------------------------------------------------------------------- /docs/basic-usage.md: -------------------------------------------------------------------------------- 1 | --- 2 | id: basic-usage 3 | title: Basic Usage 4 | --- 5 | 6 | You start with a `Chat` component that will render a chat. It has 3 required properties: 7 | 8 | * `messages` - an array of messages to be rendered. Accepts any message, see [types](types). If you have your message types you will need to map those to any of the defined ones. Let us know if we need to add more message types or add more fields to the existing ones. 9 | * `onSendPress` - a function that will have a partial text message as a parameter. See [types](types) for more info on how types are structured. From the partial text message you need to create a text message which will at least have `author`, `id`, `text` and `type: 'text'`, this is done by you because we wanted to give you more control over those values. 10 | * `user` - a [User](types#user) object, that has only one required field - an `id`, used to determine the message author. 11 | 12 | Below you will find a drop-in example of the chat with only text messages. 13 | 14 | :::note 15 | 16 | Try to write any URL, for example, `flyer.chat`, it should be unwrapped in a rich preview. 17 | 18 | ::: 19 | 20 | :::important 21 | 22 | All examples are in TypeScript. 23 | 24 | ::: 25 | 26 | ```ts 27 | import { Chat, MessageType } from '@flyerhq/react-native-chat-ui' 28 | import React, { useState } from 'react' 29 | import { SafeAreaProvider } from 'react-native-safe-area-context' 30 | 31 | // For the testing purposes, you should probably use https://github.com/uuidjs/uuid 32 | const uuidv4 = () => { 33 | return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => { 34 | const r = Math.floor(Math.random() * 16) 35 | const v = c === 'x' ? r : (r % 4) + 8 36 | return v.toString(16) 37 | }) 38 | } 39 | 40 | const App = () => { 41 | const [messages, setMessages] = useState([]) 42 | const user = { id: '06c33e8b-e835-4736-80f4-63f44b66666c' } 43 | 44 | const addMessage = (message: MessageType.Any) => { 45 | setMessages([message, ...messages]) 46 | } 47 | 48 | const handleSendPress = (message: MessageType.PartialText) => { 49 | const textMessage: MessageType.Text = { 50 | author: user, 51 | createdAt: Date.now(), 52 | id: uuidv4(), 53 | text: message.text, 54 | type: 'text', 55 | } 56 | addMessage(textMessage) 57 | } 58 | 59 | return ( 60 | // Remove this provider if already registered elsewhere 61 | // or you have React Navigation set up 62 | 63 | 68 | 69 | ) 70 | } 71 | 72 | export default App 73 | ``` 74 | -------------------------------------------------------------------------------- /docs/installation.md: -------------------------------------------------------------------------------- 1 | --- 2 | id: installation 3 | title: Installation 4 | --- 5 | 6 | This library depends on [react-native-safe-area-context](https://github.com/th3rdwave/react-native-safe-area-context). If you use [React Navigation](https://reactnavigation.org) you probably already have it in your dependencies, so you're good to go. If not, please follow the instructions [here](https://github.com/th3rdwave/react-native-safe-area-context) to install it. Then run: 7 | 8 | `yarn add @flyerhq/react-native-chat-ui` 9 | -------------------------------------------------------------------------------- /docs/localization.md: -------------------------------------------------------------------------------- 1 | --- 2 | id: localization 3 | title: Localization 4 | --- 5 | 6 | You can pass the `locale` prop to the `` component. This locale will be passed to [dayjs](https://day.js.org), so we can localize dates. To see all supported locales check function `initLocale` in [this file](https://github.com/flyerhq/react-native-chat-ui/blob/main/src/utils/index.ts). Additionally, `locale` prop will be used to localize a couple of texts defined [here](https://github.com/flyerhq/react-native-chat-ui/blob/main/src/l10n.ts). You can override texts regardless of the locale by passing `l10nOverride` prop. 7 | 8 | ```ts 9 | 13 | ``` 14 | -------------------------------------------------------------------------------- /docs/overview.md: -------------------------------------------------------------------------------- 1 | --- 2 | id: overview 3 | title: Overview 4 | slug: / 5 | --- 6 | 7 | Flyer Chat is a platform for creating in-app chat experiences using React Native or Flutter. This is the documentation for chat UI implementation for React Native. 8 | 9 | ## Motivation 10 | 11 | Ever estimated a simple chat for weeks of work? Didn't want to start because it is always the same boring work for an extended period of time? Was it moved to post MVP because of lack of time and resources? Were you left with a frustrated client, who couldn't understand why the thing that exists in almost every app takes that much time to implement? 12 | 13 | We are trying to solve all this by working on a Flyer Chat. 14 | 15 | You will say that there are libraries out there that will help create chats, but we are working on a more complete solution - very similar on completely different platforms like React Native and Flutter (we don't always work in just one) with an optional Firebase BaaS, since chat is not only UI. We are making this free and open-source so together we can create a product that works for everyone. 16 | -------------------------------------------------------------------------------- /docs/themes.md: -------------------------------------------------------------------------------- 1 | --- 2 | id: themes 3 | title: Themes 4 | --- 5 | 6 | You can override anything from some defined theme or create a new one from scratch. See the default theme implementation [here](https://github.com/flyerhq/react-native-chat-ui/blob/main/src/theme.ts). To override theme partially, destructure any defined theme and change what is needed, like on this example: 7 | 8 | ```ts 9 | import { Chat, defaultTheme } from '@flyerhq/react-native-chat-ui' 10 | 11 | 17 | ``` 18 | 19 | If you created a theme from scratch just pass it to the `theme` prop. We also provide `darkTheme` implementation, you can import it from the library and pass to the `theme` prop. 20 | -------------------------------------------------------------------------------- /docs/types.md: -------------------------------------------------------------------------------- 1 | --- 2 | id: types 3 | title: Types 4 | --- 5 | 6 | There are 3 supported message types at the moment - `File`, `Image` and `Text`. All of them have corresponding "partial" message types, that include only the message's content. "Partial" messages are useful to create the content and then pass it to some kind of a backend service, which will assign fields like `id` or `author` etc, returning a "full" message which can be passed to `messages` prop of the `Chat` component. In addition to that, there are `Custom` and `Unsupported` types. `Custom` can be used to render anything you want, and `Unsupported` is just a placeholder to have backwards compatibility. 7 | -------------------------------------------------------------------------------- /example/.buckconfig: -------------------------------------------------------------------------------- 1 | 2 | [android] 3 | target = Google Inc.:Google APIs:23 4 | 5 | [maven_repositories] 6 | central = https://repo1.maven.org/maven2 7 | -------------------------------------------------------------------------------- /example/.editorconfig: -------------------------------------------------------------------------------- 1 | # Windows files 2 | [*.bat] 3 | end_of_line = crlf 4 | -------------------------------------------------------------------------------- /example/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: ['@react-native-community', 'plugin:prettier/recommended'], 3 | plugins: ['simple-import-sort'], 4 | root: true, 5 | rules: { 6 | 'import/order': 'off', 7 | radix: ['error', 'as-needed'], 8 | 'simple-import-sort/exports': 'error', 9 | 'simple-import-sort/imports': 'error', 10 | 'sort-imports': 'off', 11 | }, 12 | } 13 | -------------------------------------------------------------------------------- /example/.gitattributes: -------------------------------------------------------------------------------- 1 | # Windows files should use crlf line endings 2 | # https://help.github.com/articles/dealing-with-line-endings/ 3 | *.bat text eol=crlf 4 | -------------------------------------------------------------------------------- /example/.gitignore: -------------------------------------------------------------------------------- 1 | # OSX 2 | # 3 | .DS_Store 4 | 5 | # Xcode 6 | # 7 | build/ 8 | *.pbxuser 9 | !default.pbxuser 10 | *.mode1v3 11 | !default.mode1v3 12 | *.mode2v3 13 | !default.mode2v3 14 | *.perspectivev3 15 | !default.perspectivev3 16 | xcuserdata 17 | *.xccheckout 18 | *.moved-aside 19 | DerivedData 20 | *.hmap 21 | *.ipa 22 | *.xcuserstate 23 | 24 | # Android/IntelliJ 25 | # 26 | build/ 27 | .idea 28 | .gradle 29 | local.properties 30 | *.iml 31 | *.hprof 32 | 33 | # node.js 34 | # 35 | node_modules/ 36 | npm-debug.log 37 | yarn-error.log 38 | 39 | # BUCK 40 | buck-out/ 41 | \.buckd/ 42 | *.keystore 43 | !debug.keystore 44 | 45 | # fastlane 46 | # 47 | # It is recommended to not store the screenshots in the git repo. Instead, use fastlane to re-generate the 48 | # screenshots whenever they are needed. 49 | # For more information about the recommended setup visit: 50 | # https://docs.fastlane.tools/best-practices/source-control/ 51 | 52 | */fastlane/report.xml 53 | */fastlane/Preview.html 54 | */fastlane/screenshots 55 | 56 | # Bundle artifact 57 | *.jsbundle 58 | 59 | # CocoaPods 60 | /ios/Pods/ 61 | 62 | # Messages 63 | messages.json 64 | -------------------------------------------------------------------------------- /example/.prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | jsxSingleQuote: true, 3 | semi: false, 4 | singleQuote: true, 5 | } 6 | -------------------------------------------------------------------------------- /example/.watchmanconfig: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /example/README.md: -------------------------------------------------------------------------------- 1 | # example 2 | 3 | ## Getting Started 4 | 5 | ```bash 6 | yarn 7 | ``` 8 | 9 | for iOS: 10 | 11 | ```bash 12 | npx pod-install 13 | ``` 14 | 15 | To run the app use: 16 | 17 | ```bash 18 | yarn ios 19 | ``` 20 | 21 | or 22 | 23 | ```bash 24 | yarn android 25 | ``` 26 | 27 | ## Updating project 28 | 29 | 1. Check if there are major versions of 3rd party dependencies, update and commit these changes first 30 | 2. Remove current `example` project 31 | 3. Create a project named `example` using [react-native-better-template](https://github.com/demchenkoalex/react-native-better-template) 32 | 4. Revert `README.md` so you can see this guide 33 | 5. In `tsconfig.json` add 34 | 35 | ```json 36 | "baseUrl": ".", 37 | "paths": { 38 | "@flyerhq/react-native-chat-ui": ["../src"] 39 | }, 40 | "resolveJsonModule": true, 41 | ``` 42 | 43 | 6. In `package.json` scripts section add 44 | 45 | ```json 46 | "generate-messages": "node scripts/generateMessages.js", 47 | "prepare": "yarn generate-messages", 48 | ``` 49 | 50 | 7. Check the difference in `metro.config.js` and combine all 51 | 8. Revert `src` folder 52 | 9. Revert `scripts` folder 53 | 10. Revert `index.js` 54 | 11. Check the difference in `.gitignore` and combine all 55 | 12. Check the difference in `.eslintrc.js` and combine all 56 | 13. Install all missing dependencies 57 | 14. Check the difference in `Info.plist` and combine all 58 | 15. Open Xcode and change build number from 1 to 2 and back in the UI, so Xcode will format `*.pbxproj` eliminating some changes 59 | -------------------------------------------------------------------------------- /example/android/app/_BUCK: -------------------------------------------------------------------------------- 1 | # To learn about Buck see [Docs](https://buckbuild.com/). 2 | # To run your application with Buck: 3 | # - install Buck 4 | # - `npm start` - to start the packager 5 | # - `cd android` 6 | # - `keytool -genkey -v -keystore keystores/debug.keystore -storepass android -alias androiddebugkey -keypass android -dname "CN=Android Debug,O=Android,C=US"` 7 | # - `./gradlew :app:copyDownloadableDepsToLibs` - make all Gradle compile dependencies available to Buck 8 | # - `buck install -r android/app` - compile, install and run application 9 | # 10 | 11 | load(":build_defs.bzl", "create_aar_targets", "create_jar_targets") 12 | 13 | lib_deps = [] 14 | 15 | create_aar_targets(glob(["libs/*.aar"])) 16 | 17 | create_jar_targets(glob(["libs/*.jar"])) 18 | 19 | android_library( 20 | name = "all-libs", 21 | exported_deps = lib_deps, 22 | ) 23 | 24 | android_library( 25 | name = "app-code", 26 | srcs = glob([ 27 | "src/main/java/**/*.java", 28 | ]), 29 | deps = [ 30 | ":all-libs", 31 | ":build_config", 32 | ":res", 33 | ], 34 | ) 35 | 36 | android_build_config( 37 | name = "build_config", 38 | package = "com.example", 39 | ) 40 | 41 | android_resource( 42 | name = "res", 43 | package = "com.example", 44 | res = "src/main/res", 45 | ) 46 | 47 | android_binary( 48 | name = "app", 49 | keystore = "//android/keystores:debug", 50 | manifest = "src/main/AndroidManifest.xml", 51 | package_type = "debug", 52 | deps = [ 53 | ":app-code", 54 | ], 55 | ) 56 | -------------------------------------------------------------------------------- /example/android/app/build.gradle: -------------------------------------------------------------------------------- 1 | apply plugin: 'com.android.application' 2 | apply plugin: 'kotlin-android' 3 | 4 | import com.android.build.OutputFile 5 | 6 | /** 7 | * The react.gradle file registers a task for each build variant (e.g. bundleDebugJsAndAssets 8 | * and bundleReleaseJsAndAssets). 9 | * These basically call `react-native bundle` with the correct arguments during the Android build 10 | * cycle. By default, bundleDebugJsAndAssets is skipped, as in debug/dev mode we prefer to load the 11 | * bundle directly from the development server. Below you can see all the possible configurations 12 | * and their defaults. If you decide to add a configuration block, make sure to add it before the 13 | * `apply from: "../../node_modules/react-native/react.gradle"` line. 14 | * 15 | * project.ext.react = [ 16 | * // the name of the generated asset file containing your JS bundle 17 | * bundleAssetName: "index.android.bundle", 18 | * 19 | * // the entry file for bundle generation. If none specified and 20 | * // "index.android.js" exists, it will be used. Otherwise "index.js" is 21 | * // default. Can be overridden with ENTRY_FILE environment variable. 22 | * entryFile: "index.android.js", 23 | * 24 | * // https://reactnative.dev/docs/performance#enable-the-ram-format 25 | * bundleCommand: "ram-bundle", 26 | * 27 | * // whether to bundle JS and assets in debug mode 28 | * bundleInDebug: false, 29 | * 30 | * // whether to bundle JS and assets in release mode 31 | * bundleInRelease: true, 32 | * 33 | * // whether to bundle JS and assets in another build variant (if configured). 34 | * // See http://tools.android.com/tech-docs/new-build-system/user-guide#TOC-Build-Variants 35 | * // The configuration property can be in the following formats 36 | * // 'bundleIn${productFlavor}${buildType}' 37 | * // 'bundleIn${buildType}' 38 | * // bundleInFreeDebug: true, 39 | * // bundleInPaidRelease: true, 40 | * // bundleInBeta: true, 41 | * 42 | * // whether to disable dev mode in custom build variants (by default only disabled in release) 43 | * // for example: to disable dev mode in the staging build type (if configured) 44 | * devDisabledInStaging: true, 45 | * // The configuration property can be in the following formats 46 | * // 'devDisabledIn${productFlavor}${buildType}' 47 | * // 'devDisabledIn${buildType}' 48 | * 49 | * // the root of your project, i.e. where "package.json" lives 50 | * root: "../../", 51 | * 52 | * // where to put the JS bundle asset in debug mode 53 | * jsBundleDirDebug: "$buildDir/intermediates/assets/debug", 54 | * 55 | * // where to put the JS bundle asset in release mode 56 | * jsBundleDirRelease: "$buildDir/intermediates/assets/release", 57 | * 58 | * // where to put drawable resources / React Native assets, e.g. the ones you use via 59 | * // require('./image.png')), in debug mode 60 | * resourcesDirDebug: "$buildDir/intermediates/res/merged/debug", 61 | * 62 | * // where to put drawable resources / React Native assets, e.g. the ones you use via 63 | * // require('./image.png')), in release mode 64 | * resourcesDirRelease: "$buildDir/intermediates/res/merged/release", 65 | * 66 | * // by default the gradle tasks are skipped if none of the JS files or assets change; this means 67 | * // that we don't look at files in android/ or ios/ to determine whether the tasks are up to 68 | * // date; if you have any other folders that you want to ignore for performance reasons (gradle 69 | * // indexes the entire tree), add them here. Alternatively, if you have JS files in android/ 70 | * // for example, you might want to remove it from here. 71 | * inputExcludes: ["android/**", "ios/**"], 72 | * 73 | * // override which node gets called and with what additional arguments 74 | * nodeExecutableAndArgs: ["node"], 75 | * 76 | * // supply additional arguments to the packager 77 | * extraPackagerArgs: [] 78 | * ] 79 | */ 80 | 81 | project.ext.react = [ 82 | enableHermes: false, // clean and rebuild if changing 83 | ] 84 | 85 | apply from: '../../node_modules/react-native/react.gradle' 86 | 87 | /** 88 | * Set this to true to create two separate APKs instead of one: 89 | * - An APK that only works on ARM devices 90 | * - An APK that only works on x86 devices 91 | * The advantage is the size of the APK is reduced by about 4MB. 92 | * Upload all the APKs to the Play Store and people will download 93 | * the correct one based on the CPU architecture of their device. 94 | */ 95 | def enableSeparateBuildPerCPUArchitecture = false 96 | 97 | /** 98 | * Run Proguard to shrink the Java bytecode in release builds. 99 | */ 100 | def enableProguardInReleaseBuilds = false 101 | 102 | /** 103 | * The preferred build flavor of JavaScriptCore. 104 | * 105 | * For example, to use the international variant, you can use: 106 | * `def jscFlavor = 'org.webkit:android-jsc-intl:+'` 107 | * 108 | * The international variant includes ICU i18n library and necessary data 109 | * allowing to use e.g. `Date.toLocaleString` and `String.localeCompare` that 110 | * give correct results when using with locales other than en-US. Note that 111 | * this variant is about 6MiB larger per architecture than default. 112 | */ 113 | def jscFlavor = 'org.webkit:android-jsc:+' 114 | 115 | /** 116 | * Whether to enable the Hermes VM. 117 | * 118 | * This should be set on project.ext.react and mirrored here. If it is not set 119 | * on project.ext.react, JavaScript will not be compiled to Hermes Bytecode 120 | * and the benefits of using Hermes will therefore be sharply reduced. 121 | */ 122 | def enableHermes = project.ext.react.get('enableHermes', false); 123 | 124 | /** 125 | * Architectures to build native code for in debug. 126 | */ 127 | def nativeArchitectures = project.getProperties().get("reactNativeDebugArchitectures") 128 | 129 | android { 130 | ndkVersion rootProject.ext.ndkVersion 131 | 132 | compileSdkVersion rootProject.ext.compileSdkVersion 133 | 134 | defaultConfig { 135 | applicationId 'com.example' 136 | minSdkVersion rootProject.ext.minSdkVersion 137 | targetSdkVersion rootProject.ext.targetSdkVersion 138 | versionCode 1 139 | versionName '1.0' 140 | } 141 | splits { 142 | abi { 143 | reset() 144 | enable enableSeparateBuildPerCPUArchitecture 145 | universalApk false // If true, also generate a universal APK 146 | include 'armeabi-v7a', 'x86', 'arm64-v8a', 'x86_64' 147 | } 148 | } 149 | signingConfigs { 150 | debug { 151 | storeFile file('debug.keystore') 152 | storePassword 'android' 153 | keyAlias 'androiddebugkey' 154 | keyPassword 'android' 155 | } 156 | } 157 | buildTypes { 158 | debug { 159 | signingConfig signingConfigs.debug 160 | if (nativeArchitectures) { 161 | ndk { 162 | abiFilters nativeArchitectures.split(',') 163 | } 164 | } 165 | } 166 | release { 167 | // Caution! In production, you need to generate your own keystore file. 168 | // see https://reactnative.dev/docs/signed-apk-android. 169 | signingConfig signingConfigs.debug 170 | minifyEnabled enableProguardInReleaseBuilds 171 | proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' 172 | } 173 | } 174 | 175 | // applicationVariants are e.g. debug, release 176 | applicationVariants.all { variant -> 177 | variant.outputs.each { output -> 178 | // For each separate APK per architecture, set a unique version code as described here: 179 | // https://developer.android.com/studio/build/configure-apk-splits.html 180 | // Example: versionCode 1 will generate 1001 for armeabi-v7a, 1002 for x86, etc. 181 | def versionCodes = ['armeabi-v7a': 1, 'x86': 2, 'arm64-v8a': 3, 'x86_64': 4] 182 | def abi = output.getFilter(OutputFile.ABI) 183 | if (abi != null) { // null for the universal-debug, universal-release variants 184 | output.versionCodeOverride = 185 | defaultConfig.versionCode * 1000 + versionCodes.get(abi) 186 | } 187 | 188 | } 189 | } 190 | } 191 | 192 | dependencies { 193 | implementation fileTree(dir: 'libs', include: ['*.jar']) 194 | //noinspection GradleDynamicVersion 195 | implementation 'com.facebook.react:react-native:+' // From node_modules 196 | 197 | implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0' 198 | 199 | debugImplementation("com.facebook.flipper:flipper:${FLIPPER_VERSION}") { 200 | exclude group: 'com.facebook.fbjni' 201 | } 202 | 203 | debugImplementation("com.facebook.flipper:flipper-network-plugin:${FLIPPER_VERSION}") { 204 | exclude group: 'com.facebook.flipper' 205 | exclude group: 'com.squareup.okhttp3', module: 'okhttp' 206 | } 207 | 208 | debugImplementation("com.facebook.flipper:flipper-fresco-plugin:${FLIPPER_VERSION}") { 209 | exclude group: 'com.facebook.flipper' 210 | } 211 | 212 | if (enableHermes) { 213 | def hermesPath = '../../node_modules/hermes-engine/android/'; 214 | debugImplementation files(hermesPath + 'hermes-debug.aar') 215 | releaseImplementation files(hermesPath + 'hermes-release.aar') 216 | } else { 217 | implementation jscFlavor 218 | } 219 | } 220 | 221 | // Run this once to be able to run the application with BUCK 222 | // puts all compile dependencies into folder libs for BUCK to use 223 | task copyDownloadableDepsToLibs(type: Copy) { 224 | from configurations.implementation 225 | into 'libs' 226 | } 227 | 228 | apply from: file('../../node_modules/@react-native-community/cli-platform-android/native_modules.gradle'); applyNativeModulesAppBuildGradle(project) 229 | -------------------------------------------------------------------------------- /example/android/app/build_defs.bzl: -------------------------------------------------------------------------------- 1 | """Helper definitions to glob .aar and .jar targets""" 2 | 3 | def create_aar_targets(aarfiles): 4 | for aarfile in aarfiles: 5 | name = "aars__" + aarfile[aarfile.rindex("/") + 1:aarfile.rindex(".aar")] 6 | lib_deps.append(":" + name) 7 | android_prebuilt_aar( 8 | name = name, 9 | aar = aarfile, 10 | ) 11 | 12 | def create_jar_targets(jarfiles): 13 | for jarfile in jarfiles: 14 | name = "jars__" + jarfile[jarfile.rindex("/") + 1:jarfile.rindex(".jar")] 15 | lib_deps.append(":" + name) 16 | prebuilt_jar( 17 | name = name, 18 | binary_jar = jarfile, 19 | ) 20 | -------------------------------------------------------------------------------- /example/android/app/debug.keystore: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/flyerhq/react-native-chat-ui/007ab3587d2bd96612ad9a96b80a815b5dede746/example/android/app/debug.keystore -------------------------------------------------------------------------------- /example/android/app/proguard-rules.pro: -------------------------------------------------------------------------------- 1 | # Add project specific ProGuard rules here. 2 | # By default, the flags in this file are appended to flags specified 3 | # in /usr/local/Cellar/android-sdk/24.3.3/tools/proguard/proguard-android.txt 4 | # You can edit the include path and order by changing the proguardFiles 5 | # directive in build.gradle. 6 | # 7 | # For more details, see 8 | # http://developer.android.com/guide/developing/tools/proguard.html 9 | 10 | # Add any project specific keep options here: 11 | -------------------------------------------------------------------------------- /example/android/app/src/debug/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | 7 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /example/android/app/src/debug/java/com/example/ReactNativeFlipper.kt: -------------------------------------------------------------------------------- 1 | package com.example 2 | 3 | import android.content.Context 4 | import com.facebook.flipper.android.AndroidFlipperClient 5 | import com.facebook.flipper.android.utils.FlipperUtils 6 | import com.facebook.flipper.plugins.crashreporter.CrashReporterPlugin 7 | import com.facebook.flipper.plugins.databases.DatabasesFlipperPlugin 8 | import com.facebook.flipper.plugins.fresco.FrescoFlipperPlugin 9 | import com.facebook.flipper.plugins.inspector.DescriptorMapping 10 | import com.facebook.flipper.plugins.inspector.InspectorFlipperPlugin 11 | import com.facebook.flipper.plugins.network.FlipperOkhttpInterceptor 12 | import com.facebook.flipper.plugins.network.NetworkFlipperPlugin 13 | import com.facebook.flipper.plugins.sharedpreferences.SharedPreferencesFlipperPlugin 14 | import com.facebook.react.ReactInstanceManager 15 | import com.facebook.react.ReactInstanceManager.ReactInstanceEventListener 16 | import com.facebook.react.bridge.ReactContext 17 | import com.facebook.react.modules.network.NetworkingModule 18 | 19 | object ReactNativeFlipper { 20 | @JvmStatic 21 | fun initializeFlipper(context: Context?, reactInstanceManager: ReactInstanceManager) { 22 | if (FlipperUtils.shouldEnableFlipper(context)) { 23 | val client = AndroidFlipperClient.getInstance(context) 24 | client.addPlugin(InspectorFlipperPlugin(context, DescriptorMapping.withDefaults())) 25 | client.addPlugin(DatabasesFlipperPlugin(context)) 26 | client.addPlugin(SharedPreferencesFlipperPlugin(context)) 27 | client.addPlugin(CrashReporterPlugin.getInstance()) 28 | val networkFlipperPlugin = NetworkFlipperPlugin() 29 | NetworkingModule.setCustomClientBuilder { builder -> builder.addNetworkInterceptor(FlipperOkhttpInterceptor(networkFlipperPlugin)) } 30 | client.addPlugin(networkFlipperPlugin) 31 | client.start() 32 | 33 | // Fresco Plugin needs to ensure that ImagePipelineFactory is initialized 34 | // Hence we run if after all native modules have been initialized 35 | val reactContext = reactInstanceManager.currentReactContext 36 | if (reactContext == null) { 37 | reactInstanceManager.addReactInstanceEventListener( 38 | object : ReactInstanceEventListener { 39 | override fun onReactContextInitialized(reactContext: ReactContext) { 40 | reactInstanceManager.removeReactInstanceEventListener(this) 41 | reactContext.runOnNativeModulesQueueThread { client.addPlugin(FrescoFlipperPlugin()) } 42 | } 43 | }) 44 | } else { 45 | client.addPlugin(FrescoFlipperPlugin()) 46 | } 47 | } 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /example/android/app/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 3 | 4 | 5 | 6 | 13 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /example/android/app/src/main/java/com/example/MainActivity.kt: -------------------------------------------------------------------------------- 1 | package com.example 2 | 3 | import com.facebook.react.ReactActivity 4 | 5 | class MainActivity : ReactActivity() { 6 | 7 | override fun getMainComponentName(): String? { 8 | return "example" 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /example/android/app/src/main/java/com/example/MainApplication.kt: -------------------------------------------------------------------------------- 1 | package com.example 2 | 3 | import android.app.Application 4 | import android.content.Context 5 | import com.facebook.react.* 6 | import com.facebook.soloader.SoLoader 7 | import java.lang.reflect.InvocationTargetException 8 | 9 | class MainApplication : Application(), ReactApplication { 10 | 11 | private val mReactNativeHost = object : ReactNativeHost(this) { 12 | override fun getUseDeveloperSupport(): Boolean { 13 | return BuildConfig.DEBUG 14 | } 15 | 16 | override fun getPackages(): List { 17 | val packages = PackageList(this).packages 18 | // Packages that cannot be autolinked yet can be added manually here, for example: 19 | // packages.add(MyReactNativePackage()); 20 | return packages 21 | } 22 | 23 | override fun getJSMainModuleName(): String { 24 | return "index" 25 | } 26 | } 27 | 28 | override fun getReactNativeHost(): ReactNativeHost { 29 | return mReactNativeHost 30 | } 31 | 32 | override fun onCreate() { 33 | super.onCreate() 34 | SoLoader.init(this, false) 35 | initializeFlipper(this, reactNativeHost.reactInstanceManager) 36 | } 37 | 38 | companion object { 39 | 40 | private fun initializeFlipper(context: Context, reactInstanceManager: ReactInstanceManager) { 41 | if (BuildConfig.DEBUG) { 42 | try { 43 | val aClass = Class.forName("com.example.ReactNativeFlipper") 44 | aClass 45 | .getMethod("initializeFlipper", Context::class.java, ReactInstanceManager::class.java) 46 | .invoke(null, context, reactInstanceManager) 47 | } catch (e: ClassNotFoundException) { 48 | e.printStackTrace() 49 | } catch (e: NoSuchMethodException) { 50 | e.printStackTrace() 51 | } catch (e: IllegalAccessException) { 52 | e.printStackTrace() 53 | } catch (e: InvocationTargetException) { 54 | e.printStackTrace() 55 | } 56 | } 57 | } 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /example/android/app/src/main/res/mipmap-hdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/flyerhq/react-native-chat-ui/007ab3587d2bd96612ad9a96b80a815b5dede746/example/android/app/src/main/res/mipmap-hdpi/ic_launcher.png -------------------------------------------------------------------------------- /example/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/flyerhq/react-native-chat-ui/007ab3587d2bd96612ad9a96b80a815b5dede746/example/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /example/android/app/src/main/res/mipmap-mdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/flyerhq/react-native-chat-ui/007ab3587d2bd96612ad9a96b80a815b5dede746/example/android/app/src/main/res/mipmap-mdpi/ic_launcher.png -------------------------------------------------------------------------------- /example/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/flyerhq/react-native-chat-ui/007ab3587d2bd96612ad9a96b80a815b5dede746/example/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /example/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/flyerhq/react-native-chat-ui/007ab3587d2bd96612ad9a96b80a815b5dede746/example/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png -------------------------------------------------------------------------------- /example/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/flyerhq/react-native-chat-ui/007ab3587d2bd96612ad9a96b80a815b5dede746/example/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /example/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/flyerhq/react-native-chat-ui/007ab3587d2bd96612ad9a96b80a815b5dede746/example/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /example/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/flyerhq/react-native-chat-ui/007ab3587d2bd96612ad9a96b80a815b5dede746/example/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /example/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/flyerhq/react-native-chat-ui/007ab3587d2bd96612ad9a96b80a815b5dede746/example/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /example/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/flyerhq/react-native-chat-ui/007ab3587d2bd96612ad9a96b80a815b5dede746/example/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /example/android/app/src/main/res/values/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | example 3 | 4 | -------------------------------------------------------------------------------- /example/android/app/src/main/res/values/styles.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /example/android/build.gradle: -------------------------------------------------------------------------------- 1 | // Top-level build file where you can add configuration options common to all sub-projects/modules. 2 | 3 | buildscript { 4 | ext { 5 | buildToolsVersion = '30.0.2' 6 | minSdkVersion = 21 7 | compileSdkVersion = 30 8 | targetSdkVersion = 30 9 | kotlinVersion = '1.5.31' 10 | ndkVersion = '21.4.7075529' 11 | } 12 | repositories { 13 | google() 14 | mavenCentral() 15 | } 16 | dependencies { 17 | classpath 'com.android.tools.build:gradle:4.2.2' 18 | classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlinVersion" 19 | // NOTE: Do not place your application dependencies here; they belong 20 | // in the individual module build.gradle files 21 | } 22 | } 23 | 24 | allprojects { 25 | repositories { 26 | mavenCentral() 27 | mavenLocal() 28 | maven { 29 | // All of React Native (JS, Obj-C sources, Android binaries) is installed from npm 30 | url "$rootDir/../node_modules/react-native/android" 31 | } 32 | maven { 33 | // Android JSC is installed from npm 34 | url "$rootDir/../node_modules/jsc-android/dist" 35 | } 36 | 37 | google() 38 | maven { url 'https://www.jitpack.io' } 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /example/android/gradle.properties: -------------------------------------------------------------------------------- 1 | # Project-wide Gradle settings. 2 | 3 | # IDE (e.g. Android Studio) users: 4 | # Gradle settings configured through the IDE *will override* 5 | # any settings specified in this file. 6 | 7 | # For more details on how to configure your build environment visit 8 | # http://www.gradle.org/docs/current/userguide/build_environment.html 9 | 10 | # Specifies the JVM arguments used for the daemon process. 11 | # The setting is particularly useful for tweaking memory settings. 12 | # Default value: -Xmx10248m -XX:MaxPermSize=256m 13 | # org.gradle.jvmargs=-Xmx2048m -XX:MaxPermSize=512m -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8 14 | 15 | # When configured, Gradle will run in incubating parallel mode. 16 | # This option should only be used with decoupled projects. More details, visit 17 | # http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects 18 | # org.gradle.parallel=true 19 | 20 | # AndroidX package structure to make it clearer which packages are bundled with the 21 | # Android operating system, and which are packaged with your app's APK 22 | # https://developer.android.com/topic/libraries/support-library/androidx-rn 23 | android.useAndroidX=true 24 | # Automatically convert third-party libraries to use AndroidX 25 | android.enableJetifier=true 26 | 27 | # Version of flipper SDK to use with React Native 28 | FLIPPER_VERSION=0.99.0 29 | -------------------------------------------------------------------------------- /example/android/gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/flyerhq/react-native-chat-ui/007ab3587d2bd96612ad9a96b80a815b5dede746/example/android/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /example/android/gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | distributionBase=GRADLE_USER_HOME 2 | distributionPath=wrapper/dists 3 | distributionUrl=https\://services.gradle.org/distributions/gradle-6.9-all.zip 4 | zipStoreBase=GRADLE_USER_HOME 5 | zipStorePath=wrapper/dists 6 | -------------------------------------------------------------------------------- /example/android/gradlew: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | # 4 | # Copyright 2015 the original author or authors. 5 | # 6 | # Licensed under the Apache License, Version 2.0 (the "License"); 7 | # you may not use this file except in compliance with the License. 8 | # You may obtain a copy of the License at 9 | # 10 | # https://www.apache.org/licenses/LICENSE-2.0 11 | # 12 | # Unless required by applicable law or agreed to in writing, software 13 | # distributed under the License is distributed on an "AS IS" BASIS, 14 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 15 | # See the License for the specific language governing permissions and 16 | # limitations under the License. 17 | # 18 | 19 | ############################################################################## 20 | ## 21 | ## Gradle start up script for UN*X 22 | ## 23 | ############################################################################## 24 | 25 | # Attempt to set APP_HOME 26 | # Resolve links: $0 may be a link 27 | PRG="$0" 28 | # Need this for relative symlinks. 29 | while [ -h "$PRG" ] ; do 30 | ls=`ls -ld "$PRG"` 31 | link=`expr "$ls" : '.*-> \(.*\)$'` 32 | if expr "$link" : '/.*' > /dev/null; then 33 | PRG="$link" 34 | else 35 | PRG=`dirname "$PRG"`"/$link" 36 | fi 37 | done 38 | SAVED="`pwd`" 39 | cd "`dirname \"$PRG\"`/" >/dev/null 40 | APP_HOME="`pwd -P`" 41 | cd "$SAVED" >/dev/null 42 | 43 | APP_NAME="Gradle" 44 | APP_BASE_NAME=`basename "$0"` 45 | 46 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 47 | DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' 48 | 49 | # Use the maximum available, or set MAX_FD != -1 to use that value. 50 | MAX_FD="maximum" 51 | 52 | warn () { 53 | echo "$*" 54 | } 55 | 56 | die () { 57 | echo 58 | echo "$*" 59 | echo 60 | exit 1 61 | } 62 | 63 | # OS specific support (must be 'true' or 'false'). 64 | cygwin=false 65 | msys=false 66 | darwin=false 67 | nonstop=false 68 | case "`uname`" in 69 | CYGWIN* ) 70 | cygwin=true 71 | ;; 72 | Darwin* ) 73 | darwin=true 74 | ;; 75 | MINGW* ) 76 | msys=true 77 | ;; 78 | NONSTOP* ) 79 | nonstop=true 80 | ;; 81 | esac 82 | 83 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar 84 | 85 | 86 | # Determine the Java command to use to start the JVM. 87 | if [ -n "$JAVA_HOME" ] ; then 88 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then 89 | # IBM's JDK on AIX uses strange locations for the executables 90 | JAVACMD="$JAVA_HOME/jre/sh/java" 91 | else 92 | JAVACMD="$JAVA_HOME/bin/java" 93 | fi 94 | if [ ! -x "$JAVACMD" ] ; then 95 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME 96 | 97 | Please set the JAVA_HOME variable in your environment to match the 98 | location of your Java installation." 99 | fi 100 | else 101 | JAVACMD="java" 102 | which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 103 | 104 | Please set the JAVA_HOME variable in your environment to match the 105 | location of your Java installation." 106 | fi 107 | 108 | # Increase the maximum file descriptors if we can. 109 | if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then 110 | MAX_FD_LIMIT=`ulimit -H -n` 111 | if [ $? -eq 0 ] ; then 112 | if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then 113 | MAX_FD="$MAX_FD_LIMIT" 114 | fi 115 | ulimit -n $MAX_FD 116 | if [ $? -ne 0 ] ; then 117 | warn "Could not set maximum file descriptor limit: $MAX_FD" 118 | fi 119 | else 120 | warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" 121 | fi 122 | fi 123 | 124 | # For Darwin, add options to specify how the application appears in the dock 125 | if $darwin; then 126 | GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" 127 | fi 128 | 129 | # For Cygwin or MSYS, switch paths to Windows format before running java 130 | if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then 131 | APP_HOME=`cygpath --path --mixed "$APP_HOME"` 132 | CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` 133 | 134 | JAVACMD=`cygpath --unix "$JAVACMD"` 135 | 136 | # We build the pattern for arguments to be converted via cygpath 137 | ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` 138 | SEP="" 139 | for dir in $ROOTDIRSRAW ; do 140 | ROOTDIRS="$ROOTDIRS$SEP$dir" 141 | SEP="|" 142 | done 143 | OURCYGPATTERN="(^($ROOTDIRS))" 144 | # Add a user-defined pattern to the cygpath arguments 145 | if [ "$GRADLE_CYGPATTERN" != "" ] ; then 146 | OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" 147 | fi 148 | # Now convert the arguments - kludge to limit ourselves to /bin/sh 149 | i=0 150 | for arg in "$@" ; do 151 | CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` 152 | CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option 153 | 154 | if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition 155 | eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` 156 | else 157 | eval `echo args$i`="\"$arg\"" 158 | fi 159 | i=`expr $i + 1` 160 | done 161 | case $i in 162 | 0) set -- ;; 163 | 1) set -- "$args0" ;; 164 | 2) set -- "$args0" "$args1" ;; 165 | 3) set -- "$args0" "$args1" "$args2" ;; 166 | 4) set -- "$args0" "$args1" "$args2" "$args3" ;; 167 | 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; 168 | 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; 169 | 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; 170 | 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; 171 | 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; 172 | esac 173 | fi 174 | 175 | # Escape application args 176 | save () { 177 | for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done 178 | echo " " 179 | } 180 | APP_ARGS=`save "$@"` 181 | 182 | # Collect all arguments for the java command, following the shell quoting and substitution rules 183 | eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" 184 | 185 | exec "$JAVACMD" "$@" 186 | -------------------------------------------------------------------------------- /example/android/gradlew.bat: -------------------------------------------------------------------------------- 1 | @rem 2 | @rem Copyright 2015 the original author or authors. 3 | @rem 4 | @rem Licensed under the Apache License, Version 2.0 (the "License"); 5 | @rem you may not use this file except in compliance with the License. 6 | @rem You may obtain a copy of the License at 7 | @rem 8 | @rem https://www.apache.org/licenses/LICENSE-2.0 9 | @rem 10 | @rem Unless required by applicable law or agreed to in writing, software 11 | @rem distributed under the License is distributed on an "AS IS" BASIS, 12 | @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | @rem See the License for the specific language governing permissions and 14 | @rem limitations under the License. 15 | @rem 16 | 17 | @if "%DEBUG%" == "" @echo off 18 | @rem ########################################################################## 19 | @rem 20 | @rem Gradle startup script for Windows 21 | @rem 22 | @rem ########################################################################## 23 | 24 | @rem Set local scope for the variables with windows NT shell 25 | if "%OS%"=="Windows_NT" setlocal 26 | 27 | set DIRNAME=%~dp0 28 | if "%DIRNAME%" == "" set DIRNAME=. 29 | set APP_BASE_NAME=%~n0 30 | set APP_HOME=%DIRNAME% 31 | 32 | @rem Resolve any "." and ".." in APP_HOME to make it shorter. 33 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi 34 | 35 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 36 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" 37 | 38 | @rem Find java.exe 39 | if defined JAVA_HOME goto findJavaFromJavaHome 40 | 41 | set JAVA_EXE=java.exe 42 | %JAVA_EXE% -version >NUL 2>&1 43 | if "%ERRORLEVEL%" == "0" goto execute 44 | 45 | echo. 46 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 47 | echo. 48 | echo Please set the JAVA_HOME variable in your environment to match the 49 | echo location of your Java installation. 50 | 51 | goto fail 52 | 53 | :findJavaFromJavaHome 54 | set JAVA_HOME=%JAVA_HOME:"=% 55 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 56 | 57 | if exist "%JAVA_EXE%" goto execute 58 | 59 | echo. 60 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 61 | echo. 62 | echo Please set the JAVA_HOME variable in your environment to match the 63 | echo location of your Java installation. 64 | 65 | goto fail 66 | 67 | :execute 68 | @rem Setup the command line 69 | 70 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 71 | 72 | 73 | @rem Execute Gradle 74 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* 75 | 76 | :end 77 | @rem End local scope for the variables with windows NT shell 78 | if "%ERRORLEVEL%"=="0" goto mainEnd 79 | 80 | :fail 81 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 82 | rem the _cmd.exe /c_ return code! 83 | if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 84 | exit /b 1 85 | 86 | :mainEnd 87 | if "%OS%"=="Windows_NT" endlocal 88 | 89 | :omega 90 | -------------------------------------------------------------------------------- /example/android/settings.gradle: -------------------------------------------------------------------------------- 1 | rootProject.name = 'example' 2 | apply from: file("../node_modules/@react-native-community/cli-platform-android/native_modules.gradle"); applyNativeModulesSettingsGradle(settings) 3 | include ':app' 4 | -------------------------------------------------------------------------------- /example/app.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "example", 3 | "displayName": "example" 4 | } 5 | -------------------------------------------------------------------------------- /example/babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: ['module:metro-react-native-babel-preset'], 3 | } 4 | -------------------------------------------------------------------------------- /example/index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @format 3 | */ 4 | 5 | import 'react-native-get-random-values' 6 | 7 | import { AppRegistry } from 'react-native' 8 | 9 | import { name as appName } from './app.json' 10 | import AppContainer from './src/AppContainer' 11 | 12 | AppRegistry.registerComponent(appName, () => AppContainer) 13 | -------------------------------------------------------------------------------- /example/ios/Podfile: -------------------------------------------------------------------------------- 1 | require_relative '../node_modules/react-native/scripts/react_native_pods' 2 | require_relative '../node_modules/@react-native-community/cli-platform-ios/native_modules' 3 | 4 | platform :ios, '11.0' 5 | 6 | target 'example' do 7 | config = use_native_modules! 8 | 9 | use_react_native!( 10 | :path => config[:reactNativePath], 11 | # to enable hermes on iOS, change `false` to `true` and then install pods 12 | :hermes_enabled => false 13 | ) 14 | 15 | # Enables Flipper. 16 | # 17 | # Note that if you have use_frameworks! enabled, Flipper will not work and 18 | # you should disable the next line. 19 | use_flipper!() 20 | 21 | post_install do |installer| 22 | react_native_post_install(installer) 23 | __apply_Xcode_12_5_M1_post_install_workaround(installer) 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /example/ios/example.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /example/ios/example.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /example/ios/example.xcodeproj/xcshareddata/xcschemes/example.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 24 | 25 | 30 | 31 | 32 | 33 | 43 | 45 | 51 | 52 | 53 | 54 | 60 | 62 | 68 | 69 | 70 | 71 | 73 | 74 | 77 | 78 | 79 | -------------------------------------------------------------------------------- /example/ios/example.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /example/ios/example.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /example/ios/example/AppDelegate.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | #if DEBUG 3 | import FlipperKit 4 | #endif 5 | 6 | @UIApplicationMain 7 | class AppDelegate: UIResponder, UIApplicationDelegate, RCTBridgeDelegate { 8 | 9 | var window: UIWindow? 10 | 11 | func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { 12 | initializeFlipper(with: application) 13 | 14 | let bridge = RCTBridge(delegate: self, launchOptions: launchOptions) 15 | let rootView = RCTRootView(bridge: bridge!, moduleName: "example", initialProperties: nil) 16 | 17 | if #available(iOS 13.0, *) { 18 | rootView.backgroundColor = UIColor.systemBackground 19 | } else { 20 | rootView.backgroundColor = UIColor.white 21 | } 22 | 23 | window = UIWindow(frame: UIScreen.main.bounds) 24 | let rootViewController = UIViewController() 25 | rootViewController.view = rootView 26 | window?.rootViewController = rootViewController 27 | window?.makeKeyAndVisible() 28 | 29 | return true 30 | } 31 | 32 | func sourceURL(for bridge: RCTBridge!) -> URL! { 33 | #if DEBUG 34 | return RCTBundleURLProvider.sharedSettings()?.jsBundleURL(forBundleRoot: "index", fallbackResource: nil) 35 | #else 36 | return Bundle.main.url(forResource: "main", withExtension: "jsbundle") 37 | #endif 38 | } 39 | 40 | private func initializeFlipper(with application: UIApplication) { 41 | #if DEBUG 42 | let client = FlipperClient.shared() 43 | let layoutDescriptionMapper = SKDescriptorMapper(defaults: ()) 44 | client?.add(FlipperKitLayoutPlugin(rootNode: application, with: layoutDescriptionMapper)) 45 | client?.add(FKUserDefaultsPlugin(suiteName: nil)) 46 | client?.add(FlipperKitReactPlugin()) 47 | client?.add(FlipperKitNetworkPlugin(networkAdapter: SKIOSNetworkAdapter())) 48 | client?.start() 49 | #endif 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /example/ios/example/Images.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "iphone", 5 | "scale" : "2x", 6 | "size" : "20x20" 7 | }, 8 | { 9 | "idiom" : "iphone", 10 | "scale" : "3x", 11 | "size" : "20x20" 12 | }, 13 | { 14 | "idiom" : "iphone", 15 | "scale" : "2x", 16 | "size" : "29x29" 17 | }, 18 | { 19 | "idiom" : "iphone", 20 | "scale" : "3x", 21 | "size" : "29x29" 22 | }, 23 | { 24 | "idiom" : "iphone", 25 | "scale" : "2x", 26 | "size" : "40x40" 27 | }, 28 | { 29 | "idiom" : "iphone", 30 | "scale" : "3x", 31 | "size" : "40x40" 32 | }, 33 | { 34 | "idiom" : "iphone", 35 | "scale" : "2x", 36 | "size" : "60x60" 37 | }, 38 | { 39 | "idiom" : "iphone", 40 | "scale" : "3x", 41 | "size" : "60x60" 42 | }, 43 | { 44 | "idiom" : "ios-marketing", 45 | "scale" : "1x", 46 | "size" : "1024x1024" 47 | } 48 | ], 49 | "info" : { 50 | "author" : "xcode", 51 | "version" : 1 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /example/ios/example/Images.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "version" : 1, 4 | "author" : "xcode" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /example/ios/example/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | en 7 | CFBundleDisplayName 8 | example 9 | CFBundleExecutable 10 | $(EXECUTABLE_NAME) 11 | CFBundleIdentifier 12 | $(PRODUCT_BUNDLE_IDENTIFIER) 13 | CFBundleInfoDictionaryVersion 14 | 6.0 15 | CFBundleName 16 | $(PRODUCT_NAME) 17 | CFBundlePackageType 18 | APPL 19 | CFBundleShortVersionString 20 | 1.0 21 | CFBundleSignature 22 | ???? 23 | CFBundleVersion 24 | $(CURRENT_PROJECT_VERSION) 25 | LSRequiresIPhoneOS 26 | 27 | NSAppTransportSecurity 28 | 29 | NSExceptionDomains 30 | 31 | localhost 32 | 33 | NSExceptionAllowsInsecureHTTPLoads 34 | 35 | 36 | 37 | 38 | NSCameraUsageDescription 39 | Allows you to send photos taken with the camera 40 | NSLocationWhenInUseUsageDescription 41 | 42 | NSPhotoLibraryUsageDescription 43 | Allows you to send photos from the photo library 44 | UILaunchStoryboardName 45 | LaunchScreen 46 | UIRequiredDeviceCapabilities 47 | 48 | armv7 49 | 50 | UISupportedInterfaceOrientations 51 | 52 | UIInterfaceOrientationPortrait 53 | UIInterfaceOrientationLandscapeLeft 54 | UIInterfaceOrientationLandscapeRight 55 | 56 | UIViewControllerBasedStatusBarAppearance 57 | 58 | 59 | 60 | -------------------------------------------------------------------------------- /example/ios/example/LaunchScreen.storyboard: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 24 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | -------------------------------------------------------------------------------- /example/ios/example/example-Bridging-Header.h: -------------------------------------------------------------------------------- 1 | // 2 | // Use this file to import your target's public headers that you would like to expose to Swift. 3 | // 4 | 5 | #import 6 | #import 7 | #import 8 | -------------------------------------------------------------------------------- /example/metro.config.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Metro configuration for React Native 3 | * https://github.com/facebook/react-native 4 | * 5 | * @format 6 | */ 7 | 8 | const path = require('path') 9 | const exclusionList = require('metro-config/src/defaults/exclusionList') 10 | 11 | const moduleRoot = path.resolve(__dirname, '..') 12 | 13 | module.exports = { 14 | watchFolders: [moduleRoot], 15 | resolver: { 16 | extraNodeModules: { 17 | react: path.resolve(__dirname, 'node_modules/react'), 18 | 'react-native': path.resolve(__dirname, 'node_modules/react-native'), 19 | 'react-native-safe-area-context': path.resolve( 20 | __dirname, 21 | 'node_modules/react-native-safe-area-context' 22 | ), 23 | }, 24 | blockList: exclusionList([ 25 | new RegExp(`${moduleRoot}/node_modules/react/.*`), 26 | new RegExp(`${moduleRoot}/node_modules/react-native/.*`), 27 | new RegExp( 28 | `${moduleRoot}/node_modules/react-native-safe-area-context/.*` 29 | ), 30 | ]), 31 | }, 32 | transformer: { 33 | getTransformOptions: async () => ({ 34 | transform: { 35 | experimentalImportSupport: false, 36 | inlineRequires: true, 37 | }, 38 | }), 39 | }, 40 | } 41 | -------------------------------------------------------------------------------- /example/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "example", 3 | "version": "0.0.1", 4 | "private": true, 5 | "scripts": { 6 | "android": "react-native run-android", 7 | "compile": "tsc -p .", 8 | "generate-messages": "node scripts/generateMessages.js", 9 | "ios": "react-native run-ios", 10 | "lint": "eslint . --ext .js,.jsx,.ts,.tsx", 11 | "prepare": "yarn generate-messages", 12 | "start": "react-native start", 13 | "test": "jest" 14 | }, 15 | "dependencies": { 16 | "@expo/react-native-action-sheet": "^3.12.0", 17 | "react": "^17.0.2", 18 | "react-native": "^0.66.1", 19 | "react-native-document-picker": "^7.1.1", 20 | "react-native-file-viewer": "^2.1.4", 21 | "react-native-get-random-values": "^1.7.0", 22 | "react-native-image-picker": "^4.1.2", 23 | "react-native-safe-area-context": "^3.3.2", 24 | "uuid": "^8.3.2" 25 | }, 26 | "devDependencies": { 27 | "@babel/core": "^7.15.8", 28 | "@babel/runtime": "^7.15.4", 29 | "@react-native-community/eslint-config": "^3.0.1", 30 | "@types/jest": "^27.0.2", 31 | "@types/react-native": "^0.66.0", 32 | "@types/react-test-renderer": "^17.0.1", 33 | "@types/uuid": "^8.3.1", 34 | "babel-jest": "^27.3.1", 35 | "casual": "^1.6.2", 36 | "eslint": "^7.32.0", 37 | "eslint-plugin-simple-import-sort": "^7.0.0", 38 | "jest": "^27.3.1", 39 | "metro-react-native-babel-preset": "^0.66.2", 40 | "react-test-renderer": "^17.0.2", 41 | "typescript": "^4.4.4" 42 | }, 43 | "resolutions": { 44 | "@types/react": "^17" 45 | }, 46 | "jest": { 47 | "preset": "react-native", 48 | "moduleFileExtensions": [ 49 | "ts", 50 | "tsx", 51 | "js", 52 | "jsx", 53 | "json", 54 | "node" 55 | ] 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /example/scripts/generateMessages.js: -------------------------------------------------------------------------------- 1 | const casual = require('casual') 2 | const fs = require('fs') 3 | const { v4: uuidv4 } = require('uuid') 4 | 5 | const users = [ 6 | { 7 | firstName: 'John', 8 | id: 'b4878b96-efbc-479a-8291-474ef323dec7', 9 | imageUrl: 'https://avatars.githubusercontent.com/u/14123304?v=4', 10 | }, 11 | { 12 | firstName: 'Jane', 13 | id: '06c33e8b-e835-4736-80f4-63f44b66666c', 14 | imageUrl: 'https://avatars.githubusercontent.com/u/33809426?v=4', 15 | }, 16 | ] 17 | 18 | let numberOfMessages = 10 19 | const arg = process.argv.slice(2)[0] 20 | 21 | if (!isNaN(arg) && parseInt(arg) > 0) { 22 | numberOfMessages = parseInt(arg) 23 | } 24 | 25 | const messages = [...Array(numberOfMessages)].map((_, index) => { 26 | const randomText = Math.round(Math.random()) 27 | const text = randomText ? casual.text : casual.sentence 28 | const randomAuthor = Math.round(Math.random()) 29 | const author = randomAuthor ? users[0] : users[1] 30 | const createdAt = Date.now() - index 31 | const data = { 32 | author, 33 | createdAt, 34 | id: uuidv4(), 35 | status: 'seen', 36 | text, 37 | type: 'text', 38 | } 39 | return data 40 | }) 41 | 42 | const json = `${JSON.stringify(messages, null, 2)}\n` 43 | 44 | fs.writeFile('src/messages.json', json, () => { 45 | console.log(`Generated ${numberOfMessages} messages`) 46 | }) 47 | -------------------------------------------------------------------------------- /example/src/App.tsx: -------------------------------------------------------------------------------- 1 | import { useActionSheet } from '@expo/react-native-action-sheet' 2 | import { Chat, MessageType } from '@flyerhq/react-native-chat-ui' 3 | import { PreviewData } from '@flyerhq/react-native-link-preview' 4 | import React, { useState } from 'react' 5 | import DocumentPicker from 'react-native-document-picker' 6 | import FileViewer from 'react-native-file-viewer' 7 | import { launchImageLibrary } from 'react-native-image-picker' 8 | import { v4 as uuidv4 } from 'uuid' 9 | 10 | import data from './messages.json' 11 | 12 | const App = () => { 13 | const { showActionSheetWithOptions } = useActionSheet() 14 | const [messages, setMessages] = useState(data as MessageType.Any[]) 15 | const user = { id: '06c33e8b-e835-4736-80f4-63f44b66666c' } 16 | 17 | const addMessage = (message: MessageType.Any) => { 18 | setMessages([message, ...messages]) 19 | } 20 | 21 | const handleAttachmentPress = () => { 22 | showActionSheetWithOptions( 23 | { 24 | options: ['Photo', 'File', 'Cancel'], 25 | cancelButtonIndex: 2, 26 | }, 27 | (buttonIndex) => { 28 | switch (buttonIndex) { 29 | case 0: 30 | handleImageSelection() 31 | break 32 | case 1: 33 | handleFileSelection() 34 | break 35 | } 36 | } 37 | ) 38 | } 39 | 40 | const handleFileSelection = async () => { 41 | try { 42 | const response = await DocumentPicker.pickSingle({ 43 | type: [DocumentPicker.types.allFiles], 44 | }) 45 | const fileMessage: MessageType.File = { 46 | author: user, 47 | createdAt: Date.now(), 48 | id: uuidv4(), 49 | mimeType: response.type ?? undefined, 50 | name: response.name, 51 | size: response.size ?? 0, 52 | type: 'file', 53 | uri: response.uri, 54 | } 55 | addMessage(fileMessage) 56 | } catch {} 57 | } 58 | 59 | const handleImageSelection = () => { 60 | launchImageLibrary( 61 | { 62 | includeBase64: true, 63 | maxWidth: 1440, 64 | mediaType: 'photo', 65 | quality: 0.7, 66 | }, 67 | ({ assets }) => { 68 | const response = assets?.[0] 69 | 70 | if (response?.base64) { 71 | const imageMessage: MessageType.Image = { 72 | author: user, 73 | createdAt: Date.now(), 74 | height: response.height, 75 | id: uuidv4(), 76 | name: response.fileName ?? response.uri?.split('/').pop() ?? '🖼', 77 | size: response.fileSize ?? 0, 78 | type: 'image', 79 | uri: `data:image/*;base64,${response.base64}`, 80 | width: response.width, 81 | } 82 | addMessage(imageMessage) 83 | } 84 | } 85 | ) 86 | } 87 | 88 | const handleMessagePress = async (message: MessageType.Any) => { 89 | if (message.type === 'file') { 90 | try { 91 | await FileViewer.open(message.uri, { showOpenWithDialog: true }) 92 | } catch {} 93 | } 94 | } 95 | 96 | const handlePreviewDataFetched = ({ 97 | message, 98 | previewData, 99 | }: { 100 | message: MessageType.Text 101 | previewData: PreviewData 102 | }) => { 103 | setMessages( 104 | messages.map((m) => 105 | m.id === message.id ? { ...m, previewData } : m 106 | ) 107 | ) 108 | } 109 | 110 | const handleSendPress = (message: MessageType.PartialText) => { 111 | const textMessage: MessageType.Text = { 112 | author: user, 113 | createdAt: Date.now(), 114 | id: uuidv4(), 115 | text: message.text, 116 | type: 'text', 117 | } 118 | addMessage(textMessage) 119 | } 120 | 121 | return ( 122 | 130 | ) 131 | } 132 | 133 | export default App 134 | -------------------------------------------------------------------------------- /example/src/AppContainer.tsx: -------------------------------------------------------------------------------- 1 | import { ActionSheetProvider } from '@expo/react-native-action-sheet' 2 | import React from 'react' 3 | import { SafeAreaProvider } from 'react-native-safe-area-context' 4 | 5 | import App from './App' 6 | 7 | const AppContainer = () => { 8 | return ( 9 | 10 | 11 | 12 | 13 | 14 | ) 15 | } 16 | 17 | export default AppContainer 18 | -------------------------------------------------------------------------------- /example/src/global.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'react-native/Libraries/Blob/Blob' { 2 | class Blob { 3 | constructor(parts: Array) 4 | 5 | get size(): number 6 | } 7 | 8 | export default Blob 9 | } 10 | -------------------------------------------------------------------------------- /example/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": ".", 4 | "esModuleInterop": true, 5 | "jsx": "react-native", 6 | "lib": ["ESNext"], 7 | "module": "CommonJS", 8 | "noEmit": true, 9 | "paths": { 10 | "@flyerhq/react-native-chat-ui": ["../src"] 11 | }, 12 | "resolveJsonModule": true, 13 | "skipLibCheck": true, 14 | "strict": true, 15 | "target": "ESNext", 16 | }, 17 | "include": ["src"] 18 | } 19 | -------------------------------------------------------------------------------- /jest/fixtures.ts: -------------------------------------------------------------------------------- 1 | import { MessageType, Size, User } from '../src/types' 2 | 3 | export const defaultDerivedMessageProps = { 4 | nextMessageInGroup: false, 5 | offset: 12, 6 | showName: false, 7 | showStatus: true, 8 | } 9 | 10 | export const fileMessage: MessageType.File = { 11 | author: { 12 | id: 'userId', 13 | }, 14 | createdAt: 2000000, 15 | id: 'file-uuidv4', 16 | mimeType: 'application/pdf', 17 | name: 'flyer.pdf', 18 | size: 15000, 19 | status: 'seen', 20 | type: 'file', 21 | uri: 'file:///Users/admin/flyer.pdf', 22 | } 23 | 24 | export const derivedFileMessage: MessageType.DerivedFile = { 25 | ...fileMessage, 26 | ...defaultDerivedMessageProps, 27 | } 28 | 29 | export const imageMessage: MessageType.Image = { 30 | author: { 31 | id: 'image-userId', 32 | }, 33 | createdAt: 0, 34 | height: 100, 35 | id: 'image-uuidv4', 36 | name: 'name', 37 | size: 15000, 38 | status: 'sending', 39 | type: 'image', 40 | uri: 'https://avatars1.githubusercontent.com/u/59206044', 41 | width: 100, 42 | } 43 | 44 | export const derivedImageMessage: MessageType.DerivedImage = { 45 | ...imageMessage, 46 | ...defaultDerivedMessageProps, 47 | } 48 | 49 | export const size: Size = { 50 | height: 896, 51 | width: 414, 52 | } 53 | 54 | export const textMessage: MessageType.Text = { 55 | author: { 56 | id: 'userId', 57 | }, 58 | createdAt: 0, 59 | id: 'uuidv4', 60 | text: 'text', 61 | type: 'text', 62 | } 63 | 64 | export const derivedTextMessage: MessageType.DerivedText = { 65 | ...textMessage, 66 | ...defaultDerivedMessageProps, 67 | } 68 | 69 | export const user: User = { 70 | id: 'userId', 71 | } 72 | -------------------------------------------------------------------------------- /jest/setup.ts: -------------------------------------------------------------------------------- 1 | jest.mock('react-native/Libraries/Animated/NativeAnimatedHelper') 2 | jest.spyOn(Date, 'now').mockReturnValue(0) 3 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@flyerhq/react-native-chat-ui", 3 | "version": "1.4.3", 4 | "description": "Actively maintained, community-driven chat UI implementation with an optional Firebase BaaS.", 5 | "homepage": "https://flyer.chat", 6 | "repository": { 7 | "type": "git", 8 | "url": "https://github.com/flyerhq/react-native-chat-ui.git" 9 | }, 10 | "main": "lib/index.js", 11 | "types": "lib/index.d.ts", 12 | "author": "Oleksandr Demchenko ", 13 | "contributors": [ 14 | "Vitalii Danylov ", 15 | "Volodymyr Smolianinov " 16 | ], 17 | "license": "Apache-2.0", 18 | "keywords": [ 19 | "chat", 20 | "ui", 21 | "react-native", 22 | "react-native-component", 23 | "ios", 24 | "android", 25 | "typescript" 26 | ], 27 | "files": [ 28 | "lib" 29 | ], 30 | "publishConfig": { 31 | "access": "public" 32 | }, 33 | "scripts": { 34 | "compile": "rm -rf lib && tsc -p . && copyup src/assets/*.png lib", 35 | "lint": "eslint . --ext .js,.jsx,.ts,.tsx", 36 | "prepare": "yarn compile", 37 | "test": "jest", 38 | "type-coverage": "type-coverage" 39 | }, 40 | "dependencies": { 41 | "@flyerhq/react-native-keyboard-accessory-view": "^2.3.3", 42 | "@flyerhq/react-native-link-preview": "^1.5.2", 43 | "dayjs": "^1.10.7", 44 | "react-native-image-viewing": "^0.2.1", 45 | "react-native-parsed-text": "^0.0.22" 46 | }, 47 | "devDependencies": { 48 | "@babel/core": "^7.15.8", 49 | "@babel/runtime": "^7.15.4", 50 | "@react-native-community/eslint-config": "^3.0.1", 51 | "@testing-library/react-native": "^8.0.0", 52 | "@types/jest": "^27.0.2", 53 | "@types/react-native": "^0.66.0", 54 | "@types/react-test-renderer": "^17.0.1", 55 | "babel-jest": "^27.3.1", 56 | "copyfiles": "^2.4.1", 57 | "eslint": "^7.32.0", 58 | "eslint-plugin-jest": "^25.2.2", 59 | "eslint-plugin-simple-import-sort": "^7.0.0", 60 | "jest": "^27.3.1", 61 | "metro-react-native-babel-preset": "^0.66.2", 62 | "react": "^17.0.2", 63 | "react-native": "^0.66.1", 64 | "react-native-safe-area-context": "^3.3.2", 65 | "react-test-renderer": "^17.0.2", 66 | "type-coverage": "^2.18.2", 67 | "typescript": "^4.4.4" 68 | }, 69 | "peerDependencies": { 70 | "react": "*", 71 | "react-native": "*" 72 | }, 73 | "jest": { 74 | "collectCoverage": true, 75 | "collectCoverageFrom": [ 76 | "src/**/*.{ts,tsx}", 77 | "!**/index.{ts,tsx}", 78 | "!**/styles.{ts,tsx}", 79 | "!**/types.{ts,tsx}", 80 | "!**/*.d.ts", 81 | "!**/ImageView.android.ts", 82 | "!**/ImageView.ios.ts", 83 | "!**/ImageView.tsx" 84 | ], 85 | "coverageThreshold": { 86 | "global": { 87 | "branches": 100, 88 | "functions": 100, 89 | "lines": 100, 90 | "statements": 100 91 | } 92 | }, 93 | "moduleFileExtensions": [ 94 | "ts", 95 | "tsx", 96 | "js", 97 | "jsx", 98 | "json", 99 | "node" 100 | ], 101 | "preset": "react-native", 102 | "setupFiles": [ 103 | "./jest/setup.ts" 104 | ], 105 | "transformIgnorePatterns": [ 106 | "node_modules/(?!(@flyerhq|@react-native|react-native))" 107 | ] 108 | }, 109 | "typeCoverage": { 110 | "cache": true, 111 | "ignoreCatch": true, 112 | "ignoreNonNullAssertion": true, 113 | "ignoreUnread": true, 114 | "is": 100, 115 | "showRelativePath": true, 116 | "strict": true 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /src/assets/icon-attachment.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/flyerhq/react-native-chat-ui/007ab3587d2bd96612ad9a96b80a815b5dede746/src/assets/icon-attachment.png -------------------------------------------------------------------------------- /src/assets/icon-attachment@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/flyerhq/react-native-chat-ui/007ab3587d2bd96612ad9a96b80a815b5dede746/src/assets/icon-attachment@2x.png -------------------------------------------------------------------------------- /src/assets/icon-attachment@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/flyerhq/react-native-chat-ui/007ab3587d2bd96612ad9a96b80a815b5dede746/src/assets/icon-attachment@3x.png -------------------------------------------------------------------------------- /src/assets/icon-delivered.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/flyerhq/react-native-chat-ui/007ab3587d2bd96612ad9a96b80a815b5dede746/src/assets/icon-delivered.png -------------------------------------------------------------------------------- /src/assets/icon-delivered@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/flyerhq/react-native-chat-ui/007ab3587d2bd96612ad9a96b80a815b5dede746/src/assets/icon-delivered@2x.png -------------------------------------------------------------------------------- /src/assets/icon-delivered@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/flyerhq/react-native-chat-ui/007ab3587d2bd96612ad9a96b80a815b5dede746/src/assets/icon-delivered@3x.png -------------------------------------------------------------------------------- /src/assets/icon-document.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/flyerhq/react-native-chat-ui/007ab3587d2bd96612ad9a96b80a815b5dede746/src/assets/icon-document.png -------------------------------------------------------------------------------- /src/assets/icon-document@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/flyerhq/react-native-chat-ui/007ab3587d2bd96612ad9a96b80a815b5dede746/src/assets/icon-document@2x.png -------------------------------------------------------------------------------- /src/assets/icon-document@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/flyerhq/react-native-chat-ui/007ab3587d2bd96612ad9a96b80a815b5dede746/src/assets/icon-document@3x.png -------------------------------------------------------------------------------- /src/assets/icon-error.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/flyerhq/react-native-chat-ui/007ab3587d2bd96612ad9a96b80a815b5dede746/src/assets/icon-error.png -------------------------------------------------------------------------------- /src/assets/icon-error@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/flyerhq/react-native-chat-ui/007ab3587d2bd96612ad9a96b80a815b5dede746/src/assets/icon-error@2x.png -------------------------------------------------------------------------------- /src/assets/icon-error@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/flyerhq/react-native-chat-ui/007ab3587d2bd96612ad9a96b80a815b5dede746/src/assets/icon-error@3x.png -------------------------------------------------------------------------------- /src/assets/icon-reply.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/flyerhq/react-native-chat-ui/007ab3587d2bd96612ad9a96b80a815b5dede746/src/assets/icon-reply.png -------------------------------------------------------------------------------- /src/assets/icon-reply@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/flyerhq/react-native-chat-ui/007ab3587d2bd96612ad9a96b80a815b5dede746/src/assets/icon-reply@2x.png -------------------------------------------------------------------------------- /src/assets/icon-reply@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/flyerhq/react-native-chat-ui/007ab3587d2bd96612ad9a96b80a815b5dede746/src/assets/icon-reply@3x.png -------------------------------------------------------------------------------- /src/assets/icon-seen.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/flyerhq/react-native-chat-ui/007ab3587d2bd96612ad9a96b80a815b5dede746/src/assets/icon-seen.png -------------------------------------------------------------------------------- /src/assets/icon-seen@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/flyerhq/react-native-chat-ui/007ab3587d2bd96612ad9a96b80a815b5dede746/src/assets/icon-seen@2x.png -------------------------------------------------------------------------------- /src/assets/icon-seen@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/flyerhq/react-native-chat-ui/007ab3587d2bd96612ad9a96b80a815b5dede746/src/assets/icon-seen@3x.png -------------------------------------------------------------------------------- /src/assets/icon-send.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/flyerhq/react-native-chat-ui/007ab3587d2bd96612ad9a96b80a815b5dede746/src/assets/icon-send.png -------------------------------------------------------------------------------- /src/assets/icon-send@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/flyerhq/react-native-chat-ui/007ab3587d2bd96612ad9a96b80a815b5dede746/src/assets/icon-send@2x.png -------------------------------------------------------------------------------- /src/assets/icon-send@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/flyerhq/react-native-chat-ui/007ab3587d2bd96612ad9a96b80a815b5dede746/src/assets/icon-send@3x.png -------------------------------------------------------------------------------- /src/assets/icon-x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/flyerhq/react-native-chat-ui/007ab3587d2bd96612ad9a96b80a815b5dede746/src/assets/icon-x.png -------------------------------------------------------------------------------- /src/assets/icon-x@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/flyerhq/react-native-chat-ui/007ab3587d2bd96612ad9a96b80a815b5dede746/src/assets/icon-x@2x.png -------------------------------------------------------------------------------- /src/assets/icon-x@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/flyerhq/react-native-chat-ui/007ab3587d2bd96612ad9a96b80a815b5dede746/src/assets/icon-x@3x.png -------------------------------------------------------------------------------- /src/components/AttachmentButton/AttachmentButton.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import { 3 | GestureResponderEvent, 4 | Image, 5 | StyleSheet, 6 | TouchableOpacity, 7 | TouchableOpacityProps, 8 | } from 'react-native' 9 | 10 | import { L10nContext, ThemeContext } from '../../utils' 11 | 12 | export interface AttachmentButtonAdditionalProps { 13 | touchableOpacityProps?: TouchableOpacityProps 14 | } 15 | 16 | export interface AttachmentButtonProps extends AttachmentButtonAdditionalProps { 17 | /** Callback for attachment button tap event */ 18 | onPress?: () => void 19 | } 20 | 21 | export const AttachmentButton = ({ 22 | onPress, 23 | touchableOpacityProps, 24 | }: AttachmentButtonProps) => { 25 | const l10n = React.useContext(L10nContext) 26 | const theme = React.useContext(ThemeContext) 27 | 28 | const handlePress = (event: GestureResponderEvent) => { 29 | onPress?.() 30 | touchableOpacityProps?.onPress?.(event) 31 | } 32 | 33 | return ( 34 | 40 | {theme.icons?.attachmentButtonIcon?.() ?? ( 41 | 45 | )} 46 | 47 | ) 48 | } 49 | 50 | const styles = StyleSheet.create({ 51 | image: { 52 | marginRight: 16, 53 | }, 54 | }) 55 | -------------------------------------------------------------------------------- /src/components/AttachmentButton/index.ts: -------------------------------------------------------------------------------- 1 | export * from './AttachmentButton' 2 | -------------------------------------------------------------------------------- /src/components/Avatar/Avatar.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import { Image, StyleSheet, Text, View } from 'react-native' 3 | 4 | import { MessageType, Theme } from '../../types' 5 | import { getUserAvatarNameColor, getUserInitials } from '../../utils' 6 | 7 | export const Avatar = React.memo( 8 | ({ 9 | author, 10 | currentUserIsAuthor, 11 | showAvatar, 12 | showUserAvatars, 13 | theme, 14 | }: { 15 | author: MessageType.Any['author'] 16 | currentUserIsAuthor: boolean 17 | showAvatar: boolean 18 | showUserAvatars?: boolean 19 | theme: Theme 20 | }) => { 21 | const renderAvatar = () => { 22 | const color = getUserAvatarNameColor( 23 | author, 24 | theme.colors.userAvatarNameColors 25 | ) 26 | const initials = getUserInitials(author) 27 | 28 | if (author.imageUrl) { 29 | return ( 30 | 39 | ) 40 | } 41 | 42 | return ( 43 | 44 | {initials} 45 | 46 | ) 47 | } 48 | 49 | return !currentUserIsAuthor && showUserAvatars ? ( 50 | 51 | {showAvatar ? renderAvatar() : } 52 | 53 | ) : null 54 | } 55 | ) 56 | 57 | const styles = StyleSheet.create({ 58 | avatarBackground: { 59 | alignItems: 'center', 60 | borderRadius: 16, 61 | height: 32, 62 | justifyContent: 'center', 63 | marginRight: 8, 64 | width: 32, 65 | }, 66 | image: { 67 | alignItems: 'center', 68 | borderRadius: 16, 69 | height: 32, 70 | justifyContent: 'center', 71 | marginRight: 8, 72 | width: 32, 73 | }, 74 | placeholder: { 75 | width: 40, 76 | }, 77 | }) 78 | -------------------------------------------------------------------------------- /src/components/Avatar/__tests__/Avatar.test.tsx: -------------------------------------------------------------------------------- 1 | import { render } from '@testing-library/react-native' 2 | import * as React from 'react' 3 | 4 | import { user } from '../../../../jest/fixtures' 5 | import { defaultTheme } from '../../../theme' 6 | import { Avatar } from '../Avatar' 7 | 8 | describe('avatar', () => { 9 | it(`should render container with a placeholder`, () => { 10 | expect.assertions(1) 11 | const { getByTestId } = render( 12 | 19 | ) 20 | expect(getByTestId('AvatarContainer')).toBeDefined() 21 | }) 22 | 23 | it('should render background with a first letter', () => { 24 | expect.assertions(1) 25 | const authorWithName = { ...user, firstName: 'John' } 26 | const { getByText } = render( 27 | 34 | ) 35 | expect(getByText(authorWithName.firstName[0])).toBeDefined() 36 | }) 37 | 38 | it('should render image background', () => { 39 | expect.assertions(2) 40 | const imageUrl = 'https://avatars.githubusercontent.com/u/14123304?v=4' 41 | const { getAllByRole } = render( 42 | 52 | ) 53 | const image = getAllByRole('image') 54 | expect(image).toBeDefined() 55 | expect(image[0]).toHaveProperty('props.source.uri', imageUrl) 56 | }) 57 | }) 58 | -------------------------------------------------------------------------------- /src/components/Avatar/index.ts: -------------------------------------------------------------------------------- 1 | export * from './Avatar' 2 | -------------------------------------------------------------------------------- /src/components/Chat/ImageView.android.ts: -------------------------------------------------------------------------------- 1 | import ImageView from 'react-native-image-viewing' 2 | 3 | export default ImageView 4 | -------------------------------------------------------------------------------- /src/components/Chat/ImageView.ios.ts: -------------------------------------------------------------------------------- 1 | import ImageView from 'react-native-image-viewing' 2 | 3 | export default ImageView 4 | -------------------------------------------------------------------------------- /src/components/Chat/ImageView.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import { ImageRequireSource, ImageURISource, View } from 'react-native' 3 | 4 | interface Props { 5 | imageIndex: number 6 | images: Array 7 | onRequestClose: () => void 8 | visible: boolean 9 | } 10 | 11 | const ImageView = (_: Props) => { 12 | return 13 | } 14 | 15 | export default ImageView 16 | -------------------------------------------------------------------------------- /src/components/Chat/__tests__/Chat.test.tsx: -------------------------------------------------------------------------------- 1 | import { fireEvent, render } from '@testing-library/react-native' 2 | import * as React from 'react' 3 | import { Text } from 'react-native' 4 | 5 | import { 6 | fileMessage, 7 | imageMessage, 8 | textMessage, 9 | user, 10 | } from '../../../../jest/fixtures' 11 | import { l10n } from '../../../l10n' 12 | import { MessageType } from '../../../types' 13 | import { Chat } from '../Chat' 14 | 15 | describe('chat', () => { 16 | it('renders image preview', () => { 17 | expect.assertions(1) 18 | const messages = [ 19 | textMessage, 20 | imageMessage, 21 | fileMessage, 22 | { 23 | ...textMessage, 24 | createdAt: 1, 25 | id: 'new-uuidv4', 26 | status: 'delivered' as const, 27 | }, 28 | ] 29 | const onSendPress = jest.fn() 30 | const { getByRole, getByText } = render( 31 | 32 | ) 33 | const button = getByRole('image').parent 34 | fireEvent.press(button) 35 | const closeButton = getByText('✕') 36 | expect(closeButton).toBeDefined() 37 | }) 38 | 39 | it('sends a text message', () => { 40 | expect.assertions(1) 41 | const messages = [ 42 | textMessage, 43 | fileMessage, 44 | { 45 | ...imageMessage, 46 | createdAt: 1, 47 | }, 48 | { 49 | ...textMessage, 50 | createdAt: 2, 51 | id: 'new-uuidv4', 52 | status: 'sending' as const, 53 | }, 54 | ] 55 | const onSendPress = jest.fn() 56 | const { getByLabelText } = render( 57 | 63 | ) 64 | const button = getByLabelText(l10n.en.sendButtonAccessibilityLabel) 65 | fireEvent.press(button) 66 | expect(onSendPress).toHaveBeenCalledWith({ text: 'text', type: 'text' }) 67 | }) 68 | 69 | it('opens file on a file message tap', () => { 70 | expect.assertions(1) 71 | const messages = [fileMessage, textMessage, imageMessage] 72 | const onSendPress = jest.fn() 73 | const onFilePress = jest.fn() 74 | const onMessagePress = (message: MessageType.Any) => { 75 | if (message.type === 'file') { 76 | onFilePress(message) 77 | } 78 | } 79 | const { getByLabelText } = render( 80 | 87 | ) 88 | 89 | const button = getByLabelText(l10n.en.fileButtonAccessibilityLabel) 90 | fireEvent.press(button) 91 | expect(onFilePress).toHaveBeenCalledWith(fileMessage) 92 | }) 93 | 94 | it('opens image on image message press', () => { 95 | expect.assertions(1) 96 | const messages = [imageMessage] 97 | const onSendPress = jest.fn() 98 | const onImagePress = jest.fn() 99 | const onMessagePress = (message: MessageType.Any) => { 100 | if (message.type === 'image') { 101 | onImagePress(message) 102 | } 103 | } 104 | 105 | const onMessageLongPress = jest.fn() 106 | 107 | const { getByTestId } = render( 108 | 116 | ) 117 | 118 | const button = getByTestId('ContentContainer') 119 | fireEvent.press(button) 120 | expect(onImagePress).toHaveBeenCalledWith(imageMessage) 121 | }) 122 | 123 | it('fires image on image message long press', () => { 124 | expect.assertions(1) 125 | const messages = [imageMessage] 126 | const onSendPress = jest.fn() 127 | const onImagePress = jest.fn() 128 | const onMessagePress = (message: MessageType.Any) => { 129 | if (message.type === 'image') { 130 | onImagePress(message) 131 | } 132 | } 133 | 134 | const onMessageLongPress = jest.fn() 135 | 136 | const { getByTestId } = render( 137 | 145 | ) 146 | 147 | const button = getByTestId('ContentContainer') 148 | fireEvent(button, 'onLongPress') 149 | expect(onMessageLongPress).toHaveBeenCalledWith(imageMessage) 150 | }) 151 | 152 | it('renders empty chat placeholder', () => { 153 | expect.assertions(1) 154 | const messages = [] 155 | const onSendPress = jest.fn() 156 | const onMessagePress = jest.fn() 157 | const { getByText } = render( 158 | 164 | ) 165 | 166 | const placeholder = getByText(l10n.en.emptyChatPlaceholder) 167 | expect(placeholder).toBeDefined() 168 | }) 169 | 170 | it('renders custom bottom component', () => { 171 | expect.assertions(1) 172 | const customBottomComponent = jest.fn(() => Bottom) 173 | const messages = [] 174 | const onSendPress = jest.fn() 175 | const onMessagePress = jest.fn() 176 | const { getByText } = render( 177 | 186 | ) 187 | 188 | const customComponent = getByText('Bottom') 189 | expect(customComponent).toBeDefined() 190 | }) 191 | }) 192 | -------------------------------------------------------------------------------- /src/components/Chat/index.ts: -------------------------------------------------------------------------------- 1 | export * from './Chat' 2 | -------------------------------------------------------------------------------- /src/components/Chat/styles.ts: -------------------------------------------------------------------------------- 1 | import { StyleSheet } from 'react-native' 2 | 3 | import { Theme } from '../../types' 4 | 5 | export default ({ theme }: { theme: Theme }) => 6 | StyleSheet.create({ 7 | container: { 8 | backgroundColor: theme.colors.background, 9 | flex: 1, 10 | }, 11 | emptyComponentContainer: { 12 | alignItems: 'center', 13 | marginHorizontal: 24, 14 | transform: [{ rotateX: '180deg' }], 15 | }, 16 | emptyComponentTitle: { 17 | ...theme.fonts.emptyChatPlaceholderTextStyle, 18 | textAlign: 'center', 19 | }, 20 | flatList: { 21 | backgroundColor: theme.colors.background, 22 | height: '100%', 23 | }, 24 | flatListContentContainer: { 25 | flexGrow: 1, 26 | }, 27 | footer: { 28 | height: 16, 29 | }, 30 | footerLoadingPage: { 31 | alignItems: 'center', 32 | justifyContent: 'center', 33 | marginTop: 16, 34 | height: 32, 35 | }, 36 | header: { 37 | height: 4, 38 | }, 39 | keyboardAccessoryView: { 40 | backgroundColor: theme.colors.inputBackground, 41 | borderTopLeftRadius: theme.borders.inputBorderRadius, 42 | borderTopRightRadius: theme.borders.inputBorderRadius, 43 | }, 44 | }) 45 | -------------------------------------------------------------------------------- /src/components/CircularActivityIndicator/CircularActivityIndicator.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import { 3 | Animated, 4 | ColorValue, 5 | Easing, 6 | StyleProp, 7 | ViewStyle, 8 | } from 'react-native' 9 | 10 | import styles from './styles' 11 | 12 | export interface CircularActivityIndicatorProps { 13 | color: ColorValue 14 | size?: number 15 | style?: StyleProp 16 | } 17 | 18 | export const CircularActivityIndicator = ({ 19 | color, 20 | size = 24, 21 | style, 22 | }: CircularActivityIndicatorProps) => { 23 | const spinValue = React.useRef(new Animated.Value(0)).current 24 | const { circle } = styles({ color, size }) 25 | 26 | React.useEffect(() => { 27 | Animated.loop( 28 | Animated.timing(spinValue, { 29 | toValue: 1, 30 | duration: 600, 31 | easing: Easing.linear, 32 | useNativeDriver: true, 33 | }) 34 | ).start() 35 | // eslint-disable-next-line react-hooks/exhaustive-deps 36 | }, []) 37 | 38 | return ( 39 | 56 | ) 57 | } 58 | -------------------------------------------------------------------------------- /src/components/CircularActivityIndicator/index.ts: -------------------------------------------------------------------------------- 1 | export * from './CircularActivityIndicator' 2 | -------------------------------------------------------------------------------- /src/components/CircularActivityIndicator/styles.ts: -------------------------------------------------------------------------------- 1 | import { ColorValue, StyleSheet } from 'react-native' 2 | 3 | const styles = ({ color, size }: { color: ColorValue; size: number }) => 4 | StyleSheet.create({ 5 | circle: { 6 | backgroundColor: 'transparent', 7 | borderBottomColor: 'transparent', 8 | borderLeftColor: color, 9 | borderRadius: size / 2, 10 | borderRightColor: color, 11 | borderTopColor: color, 12 | borderWidth: 1.5, 13 | height: size, 14 | width: size, 15 | }, 16 | }) 17 | 18 | export default styles 19 | -------------------------------------------------------------------------------- /src/components/FileMessage/FileMessage.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import { Image, Text, View } from 'react-native' 3 | 4 | import { MessageType } from '../../types' 5 | import { 6 | formatBytes, 7 | L10nContext, 8 | ThemeContext, 9 | UserContext, 10 | } from '../../utils' 11 | import styles from './styles' 12 | 13 | export interface FileMessageProps { 14 | message: MessageType.DerivedFile 15 | } 16 | 17 | export const FileMessage = ({ message }: FileMessageProps) => { 18 | const l10n = React.useContext(L10nContext) 19 | const theme = React.useContext(ThemeContext) 20 | const user = React.useContext(UserContext) 21 | const { container, icon, iconContainer, name, size, textContainer } = styles({ 22 | message, 23 | theme, 24 | user, 25 | }) 26 | 27 | return ( 28 | 32 | 33 | {theme.icons?.documentIcon?.() ?? ( 34 | 38 | )} 39 | 40 | 41 | {message.name} 42 | {formatBytes(message.size)} 43 | 44 | 45 | ) 46 | } 47 | -------------------------------------------------------------------------------- /src/components/FileMessage/index.ts: -------------------------------------------------------------------------------- 1 | export * from './FileMessage' 2 | -------------------------------------------------------------------------------- /src/components/FileMessage/styles.ts: -------------------------------------------------------------------------------- 1 | import { StyleSheet } from 'react-native' 2 | 3 | import { MessageType, Theme, User } from '../../types' 4 | 5 | const styles = ({ 6 | message, 7 | theme, 8 | user, 9 | }: { 10 | message: MessageType.DerivedFile 11 | theme: Theme 12 | user?: User 13 | }) => 14 | StyleSheet.create({ 15 | container: { 16 | alignItems: 'center', 17 | flexDirection: 'row', 18 | padding: theme.insets.messageInsetsVertical, 19 | paddingRight: theme.insets.messageInsetsHorizontal, 20 | }, 21 | icon: { 22 | tintColor: 23 | user?.id === message.author.id 24 | ? theme.colors.sentMessageDocumentIcon 25 | : theme.colors.receivedMessageDocumentIcon, 26 | }, 27 | iconContainer: { 28 | alignItems: 'center', 29 | backgroundColor: 30 | user?.id === message.author.id 31 | ? `${String(theme.colors.sentMessageDocumentIcon)}33` 32 | : `${String(theme.colors.receivedMessageDocumentIcon)}33`, 33 | borderRadius: 21, 34 | height: 42, 35 | justifyContent: 'center', 36 | width: 42, 37 | }, 38 | name: 39 | user?.id === message.author.id 40 | ? theme.fonts.sentMessageBodyTextStyle 41 | : theme.fonts.receivedMessageBodyTextStyle, 42 | size: { 43 | ...(user?.id === message.author.id 44 | ? theme.fonts.sentMessageCaptionTextStyle 45 | : theme.fonts.receivedMessageCaptionTextStyle), 46 | marginTop: 4, 47 | }, 48 | textContainer: { 49 | flexShrink: 1, 50 | marginLeft: 16, 51 | }, 52 | }) 53 | 54 | export default styles 55 | -------------------------------------------------------------------------------- /src/components/ImageMessage/ImageMessage.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import { Image, ImageBackground, Text, View } from 'react-native' 3 | 4 | import { MessageType, Size } from '../../types' 5 | import { formatBytes, ThemeContext, UserContext } from '../../utils' 6 | import styles from './styles' 7 | 8 | export interface ImageMessageProps { 9 | message: MessageType.DerivedImage 10 | /** Maximum message width */ 11 | messageWidth: number 12 | } 13 | 14 | /** Image message component. Supports different 15 | * aspect ratios, renders blurred image as a background which is visible 16 | * if the image is narrow, renders image in form of a file if aspect 17 | * ratio is very small or very big. */ 18 | export const ImageMessage = ({ message, messageWidth }: ImageMessageProps) => { 19 | const theme = React.useContext(ThemeContext) 20 | const user = React.useContext(UserContext) 21 | const defaultHeight = message.height ?? 0 22 | const defaultWidth = message.width ?? 0 23 | const [size, setSize] = React.useState({ 24 | height: defaultHeight, 25 | width: defaultWidth, 26 | }) 27 | const aspectRatio = size.width / (size.height || 1) 28 | const isMinimized = aspectRatio < 0.1 || aspectRatio > 10 29 | const { 30 | horizontalImage, 31 | minimizedImage, 32 | minimizedImageContainer, 33 | nameText, 34 | sizeText, 35 | textContainer, 36 | verticalImage, 37 | } = styles({ 38 | aspectRatio, 39 | message, 40 | messageWidth, 41 | theme, 42 | user, 43 | }) 44 | 45 | React.useEffect(() => { 46 | if (defaultHeight <= 0 || defaultWidth <= 0) 47 | Image.getSize( 48 | message.uri, 49 | (width, height) => setSize({ height, width }), 50 | () => setSize({ height: 0, width: 0 }) 51 | ) 52 | }, [defaultHeight, defaultWidth, message.uri]) 53 | 54 | const renderImage = () => { 55 | return ( 56 | 68 | ) 69 | } 70 | 71 | return isMinimized ? ( 72 | 73 | {renderImage()} 74 | 75 | {message.name} 76 | {formatBytes(message.size)} 77 | 78 | 79 | ) : ( 80 | 81 | {renderImage()} 82 | 83 | ) 84 | } 85 | -------------------------------------------------------------------------------- /src/components/ImageMessage/__tests__/ImageMessage.test.tsx: -------------------------------------------------------------------------------- 1 | import { act, render } from '@testing-library/react-native' 2 | import * as React from 'react' 3 | import { Image } from 'react-native' 4 | 5 | import { derivedImageMessage, size } from '../../../../jest/fixtures' 6 | import { ImageMessage } from '../ImageMessage' 7 | 8 | describe('image message', () => { 9 | it('gets image size and renders', () => { 10 | expect.assertions(5) 11 | const getSizeMock = jest.spyOn(Image, 'getSize') 12 | getSizeMock.mockImplementation(() => {}) 13 | const message = { 14 | ...derivedImageMessage, 15 | height: undefined, 16 | width: undefined, 17 | } 18 | const { getByRole } = render( 19 | 20 | ) 21 | expect(getSizeMock).toHaveBeenCalledTimes(1) 22 | const getSizeArgs = getSizeMock.mock.calls[0] 23 | expect(getSizeArgs[0]).toBe(derivedImageMessage.uri) 24 | const success = getSizeArgs[1] 25 | const error = getSizeArgs[2] 26 | act(() => { 27 | success(size.width, size.height) 28 | }) 29 | const successImageComponent = getByRole('image') 30 | expect(successImageComponent.props).toHaveProperty('style.height', 440) 31 | act(() => { 32 | success(size.width, size.width * 10 + 1) 33 | }) 34 | const successMinimizedImageComponent = getByRole('image') 35 | expect(successMinimizedImageComponent.props).toHaveProperty( 36 | 'style.width', 37 | 64 38 | ) 39 | act(() => { 40 | error(new Error()) 41 | }) 42 | const errorImageComponent = getByRole('image') 43 | expect(errorImageComponent.props).toHaveProperty('style.width', 64) 44 | getSizeMock.mockRestore() 45 | }) 46 | }) 47 | -------------------------------------------------------------------------------- /src/components/ImageMessage/index.ts: -------------------------------------------------------------------------------- 1 | export * from './ImageMessage' 2 | -------------------------------------------------------------------------------- /src/components/ImageMessage/styles.ts: -------------------------------------------------------------------------------- 1 | import { StyleSheet } from 'react-native' 2 | 3 | import { MessageType, Theme, User } from '../../types' 4 | 5 | const styles = ({ 6 | aspectRatio, 7 | message, 8 | messageWidth, 9 | theme, 10 | user, 11 | }: { 12 | aspectRatio: number 13 | message: MessageType.Image 14 | messageWidth: number 15 | theme: Theme 16 | user?: User 17 | }) => 18 | StyleSheet.create({ 19 | horizontalImage: { 20 | height: messageWidth / aspectRatio, 21 | maxHeight: messageWidth, 22 | width: messageWidth, 23 | }, 24 | minimizedImage: { 25 | borderRadius: 15, 26 | height: 64, 27 | marginLeft: theme.insets.messageInsetsVertical, 28 | marginRight: 16, 29 | marginVertical: theme.insets.messageInsetsVertical, 30 | width: 64, 31 | }, 32 | minimizedImageContainer: { 33 | alignItems: 'center', 34 | backgroundColor: 35 | user?.id === message.author.id 36 | ? theme.colors.primary 37 | : theme.colors.secondary, 38 | flexDirection: 'row', 39 | }, 40 | nameText: 41 | user?.id === message.author.id 42 | ? theme.fonts.sentMessageBodyTextStyle 43 | : theme.fonts.receivedMessageBodyTextStyle, 44 | sizeText: { 45 | ...(user?.id === message.author.id 46 | ? theme.fonts.sentMessageCaptionTextStyle 47 | : theme.fonts.receivedMessageCaptionTextStyle), 48 | marginTop: 4, 49 | }, 50 | textContainer: { 51 | flexShrink: 1, 52 | marginRight: theme.insets.messageInsetsHorizontal, 53 | marginVertical: theme.insets.messageInsetsVertical, 54 | }, 55 | verticalImage: { 56 | height: messageWidth, 57 | minWidth: 170, 58 | width: messageWidth * aspectRatio, 59 | }, 60 | }) 61 | 62 | export default styles 63 | -------------------------------------------------------------------------------- /src/components/Input/Input.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import { TextInput, TextInputProps, View } from 'react-native' 3 | 4 | import { MessageType } from '../../types' 5 | import { L10nContext, ThemeContext, unwrap, UserContext } from '../../utils' 6 | import { 7 | AttachmentButton, 8 | AttachmentButtonAdditionalProps, 9 | } from '../AttachmentButton' 10 | import { 11 | CircularActivityIndicator, 12 | CircularActivityIndicatorProps, 13 | } from '../CircularActivityIndicator' 14 | import { SendButton } from '../SendButton' 15 | import styles from './styles' 16 | 17 | export interface InputTopLevelProps { 18 | /** Whether attachment is uploading. Will replace attachment button with a 19 | * {@link CircularActivityIndicator}. Since we don't have libraries for 20 | * managing media in dependencies we have no way of knowing if 21 | * something is uploading so you need to set this manually. */ 22 | isAttachmentUploading?: boolean 23 | /** @see {@link AttachmentButtonProps.onPress} */ 24 | onAttachmentPress?: () => void 25 | /** Will be called on {@link SendButton} tap. Has {@link MessageType.PartialText} which can 26 | * be transformed to {@link MessageType.Text} and added to the messages list. */ 27 | onSendPress: (message: MessageType.PartialText) => void 28 | /** Controls the visibility behavior of the {@link SendButton} based on the 29 | * `TextInput` state. Defaults to `editing`. */ 30 | sendButtonVisibilityMode?: 'always' | 'editing' 31 | textInputProps?: TextInputProps 32 | } 33 | 34 | export interface InputAdditionalProps { 35 | attachmentButtonProps?: AttachmentButtonAdditionalProps 36 | attachmentCircularActivityIndicatorProps?: CircularActivityIndicatorProps 37 | } 38 | 39 | export type InputProps = InputTopLevelProps & InputAdditionalProps 40 | 41 | /** Bottom bar input component with a text input, attachment and 42 | * send buttons inside. By default hides send button when text input is empty. */ 43 | export const Input = ({ 44 | attachmentButtonProps, 45 | attachmentCircularActivityIndicatorProps, 46 | isAttachmentUploading, 47 | onAttachmentPress, 48 | onSendPress, 49 | sendButtonVisibilityMode, 50 | textInputProps, 51 | }: InputProps) => { 52 | const l10n = React.useContext(L10nContext) 53 | const theme = React.useContext(ThemeContext) 54 | const user = React.useContext(UserContext) 55 | const { container, input, marginRight } = styles({ theme }) 56 | 57 | // Use `defaultValue` if provided 58 | const [text, setText] = React.useState(textInputProps?.defaultValue ?? '') 59 | 60 | const value = textInputProps?.value ?? text 61 | 62 | const handleChangeText = (newText: string) => { 63 | // Track local state in case `onChangeText` is provided and `value` is not 64 | setText(newText) 65 | textInputProps?.onChangeText?.(newText) 66 | } 67 | 68 | const handleSend = () => { 69 | const trimmedValue = value.trim() 70 | 71 | // Impossible to test since button is not visible when value is empty. 72 | // Additional check for the keyboard input. 73 | /* istanbul ignore next */ 74 | if (trimmedValue) { 75 | onSendPress({ text: trimmedValue, type: 'text' }) 76 | setText('') 77 | } 78 | } 79 | 80 | return ( 81 | 82 | {user && 83 | (isAttachmentUploading ? ( 84 | 91 | ) : ( 92 | !!onAttachmentPress && ( 93 | 97 | ) 98 | ))} 99 | 110 | {sendButtonVisibilityMode === 'always' || 111 | (sendButtonVisibilityMode === 'editing' && user && value.trim()) ? ( 112 | 113 | ) : null} 114 | 115 | ) 116 | } 117 | -------------------------------------------------------------------------------- /src/components/Input/__tests__/Input.test.tsx: -------------------------------------------------------------------------------- 1 | import { fireEvent, render } from '@testing-library/react-native' 2 | import * as React from 'react' 3 | import { ScrollView } from 'react-native' 4 | 5 | import { user } from '../../../../jest/fixtures' 6 | import { l10n } from '../../../l10n' 7 | import { UserContext } from '../../../utils' 8 | import { Input } from '../Input' 9 | 10 | const renderScrollable = () => 11 | 12 | describe('input', () => { 13 | it('sends a text message', () => { 14 | expect.assertions(2) 15 | const onSendPress = jest.fn() 16 | const { getByPlaceholderText, getByLabelText } = render( 17 | 18 | 25 | 26 | ) 27 | const textInput = getByPlaceholderText(l10n.en.inputPlaceholder) 28 | fireEvent.changeText(textInput, 'text') 29 | const button = getByLabelText(l10n.en.sendButtonAccessibilityLabel) 30 | fireEvent.press(button) 31 | expect(onSendPress).toHaveBeenCalledWith({ text: 'text', type: 'text' }) 32 | expect(textInput.props).toHaveProperty('value', '') 33 | }) 34 | 35 | it('sends a text message if onChangeText and value are provided', () => { 36 | expect.assertions(2) 37 | const onSendPress = jest.fn() 38 | const value = 'value' 39 | const onChangeText = jest.fn((newValue) => { 40 | rerender( 41 | 42 | 50 | 51 | ) 52 | }) 53 | const { getByPlaceholderText, getByLabelText, rerender } = render( 54 | 55 | 63 | 64 | ) 65 | const textInput = getByPlaceholderText(l10n.en.inputPlaceholder) 66 | fireEvent.changeText(textInput, 'text') 67 | const button = getByLabelText(l10n.en.sendButtonAccessibilityLabel) 68 | fireEvent.press(button) 69 | expect(onSendPress).toHaveBeenCalledWith({ text: 'text', type: 'text' }) 70 | expect(textInput.props).toHaveProperty('value', 'text') 71 | }) 72 | 73 | it('sends a text message if onChangeText is provided', () => { 74 | expect.assertions(2) 75 | const onSendPress = jest.fn() 76 | const onChangeText = jest.fn() 77 | const { getByPlaceholderText, getByLabelText } = render( 78 | 79 | 87 | 88 | ) 89 | const textInput = getByPlaceholderText(l10n.en.inputPlaceholder) 90 | fireEvent.changeText(textInput, 'text') 91 | const button = getByLabelText(l10n.en.sendButtonAccessibilityLabel) 92 | fireEvent.press(button) 93 | expect(onSendPress).toHaveBeenCalledWith({ text: 'text', type: 'text' }) 94 | expect(textInput.props).toHaveProperty('value', '') 95 | }) 96 | 97 | it('sends a text message if value is provided', () => { 98 | expect.assertions(2) 99 | const onSendPress = jest.fn() 100 | const value = 'value' 101 | const { getByPlaceholderText, getByLabelText } = render( 102 | 103 | 111 | 112 | ) 113 | const textInput = getByPlaceholderText(l10n.en.inputPlaceholder) 114 | fireEvent.changeText(textInput, 'text') 115 | const button = getByLabelText(l10n.en.sendButtonAccessibilityLabel) 116 | fireEvent.press(button) 117 | expect(onSendPress).toHaveBeenCalledWith({ text: value, type: 'text' }) 118 | expect(textInput.props).toHaveProperty('value', value) 119 | }) 120 | 121 | it('sends a text message if defaultValue is provided', () => { 122 | expect.assertions(2) 123 | const onSendPress = jest.fn() 124 | const defaultValue = 'defaultValue' 125 | const { getByPlaceholderText, getByLabelText } = render( 126 | 127 | 135 | 136 | ) 137 | const textInput = getByPlaceholderText(l10n.en.inputPlaceholder) 138 | const button = getByLabelText(l10n.en.sendButtonAccessibilityLabel) 139 | fireEvent.press(button) 140 | expect(onSendPress).toHaveBeenCalledWith({ 141 | text: defaultValue, 142 | type: 'text', 143 | }) 144 | expect(textInput.props).toHaveProperty('value', '') 145 | }) 146 | 147 | it('sends an image message', () => { 148 | expect.assertions(1) 149 | const onAttachmentPress = jest.fn() 150 | const onSendPress = jest.fn() 151 | const { getByLabelText } = render( 152 | 153 | 161 | 162 | ) 163 | const button = getByLabelText(l10n.en.attachmentButtonAccessibilityLabel) 164 | fireEvent.press(button) 165 | expect(onAttachmentPress).toHaveBeenCalledTimes(1) 166 | }) 167 | 168 | it('shows activity indicator when attachment is uploading', () => { 169 | expect.assertions(1) 170 | const isAttachmentUploading = true 171 | const onSendPress = jest.fn() 172 | const { getByTestId } = render( 173 | 174 | 186 | 187 | ) 188 | 189 | const indicator = getByTestId('CircularActivityIndicator') 190 | expect(indicator).toBeDefined() 191 | }) 192 | }) 193 | -------------------------------------------------------------------------------- /src/components/Input/index.ts: -------------------------------------------------------------------------------- 1 | export * from './Input' 2 | -------------------------------------------------------------------------------- /src/components/Input/styles.ts: -------------------------------------------------------------------------------- 1 | import { StyleSheet } from 'react-native' 2 | 3 | import { Theme } from '../../types' 4 | 5 | export default ({ theme }: { theme: Theme }) => 6 | StyleSheet.create({ 7 | container: { 8 | alignItems: 'center', 9 | flexDirection: 'row', 10 | paddingHorizontal: 24, 11 | paddingVertical: 20, 12 | }, 13 | input: { 14 | ...theme.fonts.inputTextStyle, 15 | color: theme.colors.inputText, 16 | flex: 1, 17 | maxHeight: 100, 18 | // Fixes default paddings for Android 19 | paddingBottom: 0, 20 | paddingTop: 0, 21 | }, 22 | marginRight: { 23 | marginRight: 16, 24 | }, 25 | }) 26 | -------------------------------------------------------------------------------- /src/components/Message/Message.tsx: -------------------------------------------------------------------------------- 1 | import { oneOf } from '@flyerhq/react-native-link-preview' 2 | import * as React from 'react' 3 | import { Pressable, Text, View } from 'react-native' 4 | 5 | import { MessageType } from '../../types' 6 | import { 7 | excludeDerivedMessageProps, 8 | ThemeContext, 9 | UserContext, 10 | } from '../../utils' 11 | import { Avatar } from '../Avatar' 12 | import { FileMessage } from '../FileMessage' 13 | import { ImageMessage } from '../ImageMessage' 14 | import { StatusIcon } from '../StatusIcon' 15 | import { TextMessage, TextMessageTopLevelProps } from '../TextMessage' 16 | import styles from './styles' 17 | 18 | export interface MessageTopLevelProps extends TextMessageTopLevelProps { 19 | /** Called when user makes a long press on any message */ 20 | onMessageLongPress?: (message: MessageType.Any) => void 21 | /** Called when user taps on any message */ 22 | onMessagePress?: (message: MessageType.Any) => void 23 | /** Customize the default bubble using this function. `child` is a content 24 | * you should render inside your bubble, `message` is a current message 25 | * (contains `author` inside) and `nextMessageInGroup` allows you to see 26 | * if the message is a part of a group (messages are grouped when written 27 | * in quick succession by the same author) */ 28 | renderBubble?: (payload: { 29 | child: React.ReactNode 30 | message: MessageType.Any 31 | nextMessageInGroup: boolean 32 | }) => React.ReactNode 33 | /** Render a custom message inside predefined bubble */ 34 | renderCustomMessage?: ( 35 | message: MessageType.Custom, 36 | messageWidth: number 37 | ) => React.ReactNode 38 | /** Render a file message inside predefined bubble */ 39 | renderFileMessage?: ( 40 | message: MessageType.File, 41 | messageWidth: number 42 | ) => React.ReactNode 43 | /** Render an image message inside predefined bubble */ 44 | renderImageMessage?: ( 45 | message: MessageType.Image, 46 | messageWidth: number 47 | ) => React.ReactNode 48 | /** Render a text message inside predefined bubble */ 49 | renderTextMessage?: ( 50 | message: MessageType.Text, 51 | messageWidth: number, 52 | showName: boolean 53 | ) => React.ReactNode 54 | /** Show user avatars for received messages. Useful for a group chat. */ 55 | showUserAvatars?: boolean 56 | } 57 | 58 | export interface MessageProps extends MessageTopLevelProps { 59 | enableAnimation?: boolean 60 | message: MessageType.DerivedAny 61 | messageWidth: number 62 | roundBorder: boolean 63 | showAvatar: boolean 64 | showName: boolean 65 | showStatus: boolean 66 | } 67 | 68 | /** Base component for all message types in the chat. Renders bubbles around 69 | * messages and status. Sets maximum width for a message for 70 | * a nice look on larger screens. */ 71 | export const Message = React.memo( 72 | ({ 73 | enableAnimation, 74 | message, 75 | messageWidth, 76 | onMessagePress, 77 | onMessageLongPress, 78 | onPreviewDataFetched, 79 | renderBubble, 80 | renderCustomMessage, 81 | renderFileMessage, 82 | renderImageMessage, 83 | renderTextMessage, 84 | roundBorder, 85 | showAvatar, 86 | showName, 87 | showStatus, 88 | showUserAvatars, 89 | usePreviewData, 90 | }: MessageProps) => { 91 | const theme = React.useContext(ThemeContext) 92 | const user = React.useContext(UserContext) 93 | 94 | const currentUserIsAuthor = 95 | message.type !== 'dateHeader' && user?.id === message.author.id 96 | 97 | const { container, contentContainer, dateHeader, pressable } = styles({ 98 | currentUserIsAuthor, 99 | message, 100 | messageWidth, 101 | roundBorder, 102 | theme, 103 | }) 104 | 105 | if (message.type === 'dateHeader') { 106 | return ( 107 | 108 | {message.text} 109 | 110 | ) 111 | } 112 | 113 | const renderBubbleContainer = () => { 114 | const child = renderMessage() 115 | 116 | return oneOf( 117 | renderBubble, 118 | 119 | {child} 120 | 121 | )({ 122 | child, 123 | message: excludeDerivedMessageProps(message), 124 | nextMessageInGroup: roundBorder, 125 | }) 126 | } 127 | 128 | const renderMessage = () => { 129 | switch (message.type) { 130 | case 'custom': 131 | return ( 132 | renderCustomMessage?.( 133 | // It's okay to cast here since we checked message type above 134 | // type-coverage:ignore-next-line 135 | excludeDerivedMessageProps(message) as MessageType.Custom, 136 | messageWidth 137 | ) ?? null 138 | ) 139 | case 'file': 140 | return oneOf(renderFileMessage, )( 141 | // type-coverage:ignore-next-line 142 | excludeDerivedMessageProps(message) as MessageType.File, 143 | messageWidth 144 | ) 145 | case 'image': 146 | return oneOf( 147 | renderImageMessage, 148 | 154 | )( 155 | // type-coverage:ignore-next-line 156 | excludeDerivedMessageProps(message) as MessageType.Image, 157 | messageWidth 158 | ) 159 | case 'text': 160 | return oneOf( 161 | renderTextMessage, 162 | 172 | )( 173 | // type-coverage:ignore-next-line 174 | excludeDerivedMessageProps(message) as MessageType.Text, 175 | messageWidth, 176 | showName 177 | ) 178 | default: 179 | return null 180 | } 181 | } 182 | 183 | return ( 184 | 185 | 194 | 196 | onMessageLongPress?.(excludeDerivedMessageProps(message)) 197 | } 198 | onPress={() => onMessagePress?.(excludeDerivedMessageProps(message))} 199 | style={pressable} 200 | > 201 | {renderBubbleContainer()} 202 | 203 | 211 | 212 | ) 213 | } 214 | ) 215 | -------------------------------------------------------------------------------- /src/components/Message/__tests__/Message.test.tsx: -------------------------------------------------------------------------------- 1 | import { render } from '@testing-library/react-native' 2 | import * as React from 'react' 3 | 4 | import { derivedTextMessage } from '../../../../jest/fixtures' 5 | import { Message } from '../Message' 6 | 7 | describe('message', () => { 8 | it('renders undefined in ContentContainer', () => { 9 | expect.assertions(2) 10 | const { getByTestId } = render( 11 | 20 | ) 21 | const ContentContainer = getByTestId('ContentContainer') 22 | expect(ContentContainer).toBeDefined() 23 | expect(ContentContainer).toHaveProperty('props.children[0]', undefined) 24 | }) 25 | 26 | it('renders undefined in ContentContainer with wrong message type', () => { 27 | expect.assertions(2) 28 | const { getByTestId } = render( 29 | 38 | ) 39 | const ContentContainer = getByTestId('ContentContainer') 40 | expect(ContentContainer).toBeDefined() 41 | expect(ContentContainer).toHaveProperty('props.children[0]', undefined) 42 | }) 43 | }) 44 | -------------------------------------------------------------------------------- /src/components/Message/index.ts: -------------------------------------------------------------------------------- 1 | export * from './Message' 2 | -------------------------------------------------------------------------------- /src/components/Message/styles.ts: -------------------------------------------------------------------------------- 1 | import { StyleSheet } from 'react-native' 2 | 3 | import { MessageType, Theme } from '../../types' 4 | 5 | const styles = ({ 6 | currentUserIsAuthor, 7 | message, 8 | messageWidth, 9 | roundBorder, 10 | theme, 11 | }: { 12 | currentUserIsAuthor: boolean 13 | message: MessageType.DerivedAny 14 | messageWidth: number 15 | roundBorder: boolean 16 | theme: Theme 17 | }) => 18 | StyleSheet.create({ 19 | container: { 20 | alignItems: 'flex-end', 21 | alignSelf: currentUserIsAuthor ? 'flex-end' : 'flex-start', 22 | justifyContent: !currentUserIsAuthor ? 'flex-end' : 'flex-start', 23 | flex: 1, 24 | flexDirection: 'row', 25 | marginBottom: message.type === 'dateHeader' ? 0 : 4 + message.offset, 26 | marginLeft: 20, 27 | }, 28 | contentContainer: { 29 | backgroundColor: 30 | !currentUserIsAuthor || message.type === 'image' 31 | ? theme.colors.secondary 32 | : theme.colors.primary, 33 | borderBottomLeftRadius: 34 | currentUserIsAuthor || roundBorder 35 | ? theme.borders.messageBorderRadius 36 | : 0, 37 | borderBottomRightRadius: currentUserIsAuthor 38 | ? roundBorder 39 | ? theme.borders.messageBorderRadius 40 | : 0 41 | : theme.borders.messageBorderRadius, 42 | borderColor: 'transparent', 43 | borderRadius: theme.borders.messageBorderRadius, 44 | overflow: 'hidden', 45 | }, 46 | dateHeader: { 47 | alignItems: 'center', 48 | justifyContent: 'center', 49 | marginBottom: 32, 50 | marginTop: 16, 51 | }, 52 | pressable: { 53 | maxWidth: messageWidth, 54 | }, 55 | }) 56 | 57 | export default styles 58 | -------------------------------------------------------------------------------- /src/components/SendButton/SendButton.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import { 3 | GestureResponderEvent, 4 | Image, 5 | StyleSheet, 6 | TouchableOpacity, 7 | TouchableOpacityProps, 8 | } from 'react-native' 9 | 10 | import { L10nContext, ThemeContext } from '../../utils' 11 | 12 | export interface SendButtonPropsAdditionalProps { 13 | touchableOpacityProps?: TouchableOpacityProps 14 | } 15 | 16 | export interface SendButtonProps extends SendButtonPropsAdditionalProps { 17 | /** Callback for send button tap event */ 18 | onPress: () => void 19 | } 20 | 21 | export const SendButton = ({ 22 | onPress, 23 | touchableOpacityProps, 24 | }: SendButtonProps) => { 25 | const l10n = React.useContext(L10nContext) 26 | const theme = React.useContext(ThemeContext) 27 | 28 | const handlePress = (event: GestureResponderEvent) => { 29 | onPress() 30 | touchableOpacityProps?.onPress?.(event) 31 | } 32 | 33 | return ( 34 | 41 | {theme.icons?.sendButtonIcon?.() ?? ( 42 | 46 | )} 47 | 48 | ) 49 | } 50 | 51 | const styles = StyleSheet.create({ 52 | sendButton: { 53 | marginLeft: 16, 54 | }, 55 | }) 56 | -------------------------------------------------------------------------------- /src/components/SendButton/index.ts: -------------------------------------------------------------------------------- 1 | export * from './SendButton' 2 | -------------------------------------------------------------------------------- /src/components/StatusIcon/StatusIcon.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import { Image, StyleSheet, View } from 'react-native' 3 | 4 | import { MessageType, Theme } from '../../types' 5 | import { CircularActivityIndicator } from '../CircularActivityIndicator' 6 | 7 | export const StatusIcon = React.memo( 8 | ({ 9 | currentUserIsAuthor, 10 | showStatus, 11 | status, 12 | theme, 13 | }: { 14 | currentUserIsAuthor: boolean 15 | showStatus: boolean 16 | status?: MessageType.Any['status'] 17 | theme: Theme 18 | }) => { 19 | let statusIcon: React.ReactNode | null = null 20 | 21 | if (showStatus) { 22 | switch (status) { 23 | case 'delivered': 24 | case 'sent': 25 | statusIcon = theme.icons?.deliveredIcon?.() ?? ( 26 | 31 | ) 32 | break 33 | case 'error': 34 | statusIcon = theme.icons?.errorIcon?.() ?? ( 35 | 40 | ) 41 | break 42 | case 'seen': 43 | statusIcon = theme.icons?.seenIcon?.() ?? ( 44 | 49 | ) 50 | break 51 | case 'sending': 52 | statusIcon = theme.icons?.sendingIcon?.() ?? ( 53 | 54 | ) 55 | break 56 | default: 57 | break 58 | } 59 | } 60 | 61 | return currentUserIsAuthor ? ( 62 | 63 | {statusIcon} 64 | 65 | ) : null 66 | } 67 | ) 68 | 69 | const styles = StyleSheet.create({ 70 | container: { 71 | alignItems: 'center', 72 | height: 16, 73 | justifyContent: 'center', 74 | paddingHorizontal: 4, 75 | width: 16, 76 | }, 77 | }) 78 | -------------------------------------------------------------------------------- /src/components/StatusIcon/__tests__/StatusIcon.test.tsx: -------------------------------------------------------------------------------- 1 | import { render } from '@testing-library/react-native' 2 | import * as React from 'react' 3 | import { Image, View } from 'react-native' 4 | 5 | import { defaultTheme } from '../../../theme' 6 | import { StatusIcon } from '../StatusIcon' 7 | 8 | describe('status icon', () => { 9 | it('should render null if show status is false', () => { 10 | expect.assertions(1) 11 | const { queryByTestId } = render( 12 | 17 | ) 18 | expect(queryByTestId('StatusIconContainer')).toBeNull() 19 | }) 20 | 21 | it('should render delivered icon', () => { 22 | expect.assertions(1) 23 | const { getByTestId } = render( 24 | 30 | ) 31 | expect(getByTestId('DeliveredIcon')).toBeDefined() 32 | }) 33 | 34 | it('should render delivered icon from theme', () => { 35 | expect.assertions(1) 36 | const { queryByTestId } = render( 37 | ( 45 | 46 | ), 47 | }, 48 | }} 49 | /> 50 | ) 51 | expect(queryByTestId('DeliveredIcon')).toBeNull() 52 | }) 53 | 54 | it('should render delivered icon with sent status', () => { 55 | expect.assertions(1) 56 | const { getByTestId } = render( 57 | 63 | ) 64 | expect(getByTestId('DeliveredIcon')).toBeDefined() 65 | }) 66 | 67 | it('should render delivered icon with sent status from theme', () => { 68 | expect.assertions(1) 69 | const { queryByTestId } = render( 70 | ( 78 | 79 | ), 80 | }, 81 | }} 82 | /> 83 | ) 84 | expect(queryByTestId('DeliveredIcon')).toBeNull() 85 | }) 86 | 87 | it('should render error icon', () => { 88 | expect.assertions(1) 89 | const { getByTestId } = render( 90 | 96 | ) 97 | expect(getByTestId('ErrorIcon')).toBeDefined() 98 | }) 99 | 100 | it('should render error icon from theme', () => { 101 | expect.assertions(1) 102 | const { queryByTestId } = render( 103 | ( 111 | 112 | ), 113 | }, 114 | }} 115 | /> 116 | ) 117 | expect(queryByTestId('ErrorIcon')).toBeNull() 118 | }) 119 | 120 | it('should render seen icon', () => { 121 | expect.assertions(1) 122 | const { getByTestId } = render( 123 | 129 | ) 130 | expect(getByTestId('SeenIcon')).toBeDefined() 131 | }) 132 | 133 | it('should render seen icon from theme', () => { 134 | expect.assertions(1) 135 | const { queryByTestId } = render( 136 | ( 144 | 145 | ), 146 | }, 147 | }} 148 | /> 149 | ) 150 | expect(queryByTestId('SeenIcon')).toBeNull() 151 | }) 152 | 153 | it('should render activity indicator', () => { 154 | expect.assertions(1) 155 | const { getByTestId } = render( 156 | 162 | ) 163 | expect(getByTestId('CircularActivityIndicator')).toBeDefined() 164 | }) 165 | 166 | it('should render sending icon from theme', () => { 167 | expect.assertions(1) 168 | const { queryByTestId } = render( 169 | , 177 | }, 178 | }} 179 | /> 180 | ) 181 | expect(queryByTestId('CircularActivityIndicator')).toBeNull() 182 | }) 183 | }) 184 | -------------------------------------------------------------------------------- /src/components/StatusIcon/index.ts: -------------------------------------------------------------------------------- 1 | export * from './StatusIcon' 2 | -------------------------------------------------------------------------------- /src/components/TextMessage/TextMessage.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | LinkPreview, 3 | PreviewData, 4 | REGEX_LINK, 5 | } from '@flyerhq/react-native-link-preview' 6 | import * as React from 'react' 7 | import { Linking, Text, View } from 'react-native' 8 | import ParsedText from 'react-native-parsed-text' 9 | 10 | import { MessageType } from '../../types' 11 | import { 12 | excludeDerivedMessageProps, 13 | getUserName, 14 | ThemeContext, 15 | UserContext, 16 | } from '../../utils' 17 | import styles from './styles' 18 | 19 | export interface TextMessageTopLevelProps { 20 | /** @see {@link LinkPreviewProps.onPreviewDataFetched} */ 21 | onPreviewDataFetched?: ({ 22 | message, 23 | previewData, 24 | }: { 25 | message: MessageType.Text 26 | previewData: PreviewData 27 | }) => void 28 | /** Enables link (URL) preview */ 29 | usePreviewData?: boolean 30 | } 31 | 32 | export interface TextMessageProps extends TextMessageTopLevelProps { 33 | enableAnimation?: boolean 34 | message: MessageType.DerivedText 35 | messageWidth: number 36 | showName: boolean 37 | } 38 | 39 | export const TextMessage = ({ 40 | enableAnimation, 41 | message, 42 | messageWidth, 43 | onPreviewDataFetched, 44 | showName, 45 | usePreviewData, 46 | }: TextMessageProps) => { 47 | const theme = React.useContext(ThemeContext) 48 | const user = React.useContext(UserContext) 49 | const [previewData, setPreviewData] = React.useState(message.previewData) 50 | const { descriptionText, headerText, titleText, text, textContainer } = 51 | styles({ 52 | message, 53 | theme, 54 | user, 55 | }) 56 | 57 | const handleEmailPress = (email: string) => { 58 | try { 59 | Linking.openURL(`mailto:${email}`) 60 | } catch {} 61 | } 62 | 63 | const handlePreviewDataFetched = (data: PreviewData) => { 64 | setPreviewData(data) 65 | onPreviewDataFetched?.({ 66 | // It's okay to cast here since we know it is a text message 67 | // type-coverage:ignore-next-line 68 | message: excludeDerivedMessageProps(message) as MessageType.Text, 69 | previewData: data, 70 | }) 71 | } 72 | 73 | const handleUrlPress = (url: string) => { 74 | const uri = url.toLowerCase().startsWith('http') ? url : `https://${url}` 75 | 76 | Linking.openURL(uri) 77 | } 78 | 79 | const renderPreviewDescription = (description: string) => { 80 | return ( 81 | 82 | {description} 83 | 84 | ) 85 | } 86 | 87 | const renderPreviewHeader = (header: string) => { 88 | return ( 89 | 90 | {header} 91 | 92 | ) 93 | } 94 | 95 | const renderPreviewText = (previewText: string) => { 96 | return ( 97 | 113 | {previewText} 114 | 115 | ) 116 | } 117 | 118 | const renderPreviewTitle = (title: string) => { 119 | return ( 120 | 121 | {title} 122 | 123 | ) 124 | } 125 | 126 | return usePreviewData && 127 | !!onPreviewDataFetched && 128 | REGEX_LINK.test(message.text.toLowerCase()) ? ( 129 | 147 | ) : ( 148 | 149 | { 150 | // Tested inside the link preview 151 | /* istanbul ignore next */ showName 152 | ? renderPreviewHeader(getUserName(message.author)) 153 | : null 154 | } 155 | {message.text} 156 | 157 | ) 158 | } 159 | -------------------------------------------------------------------------------- /src/components/TextMessage/__tests__/TextMessage.test.tsx: -------------------------------------------------------------------------------- 1 | import * as utils from '@flyerhq/react-native-link-preview/lib/utils' 2 | import { fireEvent, render, waitFor } from '@testing-library/react-native' 3 | import * as React from 'react' 4 | import { Linking } from 'react-native' 5 | 6 | import { derivedTextMessage } from '../../../../jest/fixtures' 7 | import { TextMessage } from '../TextMessage' 8 | 9 | describe('text message', () => { 10 | it('renders preview image and handles link press', async () => { 11 | expect.assertions(2) 12 | const link = 'https://github.com/flyerhq/' 13 | const getPreviewDataMock = jest 14 | .spyOn(utils, 'getPreviewData') 15 | .mockResolvedValue({ 16 | description: 'description', 17 | image: { 18 | height: 460, 19 | url: 'https://avatars2.githubusercontent.com/u/59206044', 20 | width: 460, 21 | }, 22 | link, 23 | title: 'title', 24 | }) 25 | const openUrlMock = jest.spyOn(Linking, 'openURL') 26 | const { getByRole, getByText } = render( 27 | 38 | ) 39 | await waitFor(() => getByRole('image')) 40 | const image = getByRole('image') 41 | expect(image).toBeDefined() 42 | const text = getByText(link) 43 | fireEvent.press(text) 44 | expect(openUrlMock).toHaveBeenCalledWith(link) 45 | getPreviewDataMock.mockRestore() 46 | openUrlMock.mockRestore() 47 | }) 48 | 49 | it('renders preview image without https and handles link press', async () => { 50 | expect.assertions(2) 51 | const link = 'github.com/flyerhq/' 52 | const getPreviewDataMock = jest 53 | .spyOn(utils, 'getPreviewData') 54 | .mockResolvedValue({ 55 | description: 'description', 56 | image: { 57 | height: 460, 58 | url: 'https://avatars2.githubusercontent.com/u/59206044', 59 | width: 460, 60 | }, 61 | link, 62 | title: 'title', 63 | }) 64 | const openUrlMock = jest.spyOn(Linking, 'openURL') 65 | const { getByRole, getByText } = render( 66 | 73 | ) 74 | await waitFor(() => getByRole('image')) 75 | const image = getByRole('image') 76 | expect(image).toBeDefined() 77 | const text = getByText(link) 78 | fireEvent.press(text) 79 | expect(openUrlMock).toHaveBeenCalledWith('https://' + link) 80 | getPreviewDataMock.mockRestore() 81 | openUrlMock.mockRestore() 82 | }) 83 | 84 | it('renders and handles email press', async () => { 85 | expect.assertions(1) 86 | const email = 'john@flyer.chat' 87 | const getPreviewDataMock = jest 88 | .spyOn(utils, 'getPreviewData') 89 | .mockResolvedValue({}) 90 | const openUrlMock = jest.spyOn(Linking, 'openURL') 91 | const { getByText } = render( 92 | 103 | ) 104 | await waitFor(() => getByText(email)) 105 | const text = getByText(email) 106 | fireEvent.press(text) 107 | expect(openUrlMock).toHaveBeenCalledWith(`mailto:${email}`) 108 | getPreviewDataMock.mockRestore() 109 | openUrlMock.mockRestore() 110 | }) 111 | }) 112 | -------------------------------------------------------------------------------- /src/components/TextMessage/index.ts: -------------------------------------------------------------------------------- 1 | export * from './TextMessage' 2 | -------------------------------------------------------------------------------- /src/components/TextMessage/styles.ts: -------------------------------------------------------------------------------- 1 | import { StyleSheet } from 'react-native' 2 | 3 | import { MessageType, Theme, User } from '../../types' 4 | import { getUserAvatarNameColor } from '../../utils' 5 | 6 | const styles = ({ 7 | message, 8 | theme, 9 | user, 10 | }: { 11 | message: MessageType.Text 12 | theme: Theme 13 | user?: User 14 | }) => 15 | StyleSheet.create({ 16 | descriptionText: { 17 | ...(user?.id === message.author.id 18 | ? theme.fonts.sentMessageLinkDescriptionTextStyle 19 | : theme.fonts.receivedMessageLinkDescriptionTextStyle), 20 | marginTop: 4, 21 | }, 22 | headerText: { 23 | ...theme.fonts.userNameTextStyle, 24 | color: getUserAvatarNameColor( 25 | message.author, 26 | theme.colors.userAvatarNameColors 27 | ), 28 | marginBottom: 6, 29 | }, 30 | titleText: 31 | user?.id === message.author.id 32 | ? theme.fonts.sentMessageLinkTitleTextStyle 33 | : theme.fonts.receivedMessageLinkTitleTextStyle, 34 | text: 35 | user?.id === message.author.id 36 | ? theme.fonts.sentMessageBodyTextStyle 37 | : theme.fonts.receivedMessageBodyTextStyle, 38 | textContainer: { 39 | marginHorizontal: theme.insets.messageInsetsHorizontal, 40 | marginVertical: theme.insets.messageInsetsVertical, 41 | }, 42 | }) 43 | 44 | export default styles 45 | -------------------------------------------------------------------------------- /src/components/index.ts: -------------------------------------------------------------------------------- 1 | export * from './AttachmentButton' 2 | export * from './Avatar' 3 | export * from './Chat' 4 | export * from './CircularActivityIndicator' 5 | export * from './FileMessage' 6 | export * from './ImageMessage' 7 | export * from './Input' 8 | export * from './Message' 9 | export * from './SendButton' 10 | export * from './StatusIcon' 11 | export * from './TextMessage' 12 | -------------------------------------------------------------------------------- /src/global.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'react-native/Libraries/Blob/Blob' { 2 | class Blob { 3 | constructor(parts: Array) 4 | 5 | get size(): number 6 | } 7 | 8 | export default Blob 9 | } 10 | -------------------------------------------------------------------------------- /src/hooks/index.ts: -------------------------------------------------------------------------------- 1 | export * from './usePrevious' 2 | -------------------------------------------------------------------------------- /src/hooks/usePrevious.ts: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | 3 | export const usePrevious = (value: T) => { 4 | const ref = React.useRef() 5 | 6 | React.useEffect(() => { 7 | ref.current = value 8 | }, [value]) 9 | 10 | return ref.current 11 | } 12 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './components' 2 | export * from './hooks' 3 | export * from './l10n' 4 | export * from './theme' 5 | export * from './types' 6 | export * from './utils' 7 | -------------------------------------------------------------------------------- /src/l10n.ts: -------------------------------------------------------------------------------- 1 | /** Base chat l10n containing all required properties to provide localized copy. */ 2 | export const l10n = { 3 | en: { 4 | attachmentButtonAccessibilityLabel: 'Send media', 5 | emptyChatPlaceholder: 'No messages here yet', 6 | fileButtonAccessibilityLabel: 'File', 7 | inputPlaceholder: 'Message', 8 | sendButtonAccessibilityLabel: 'Send', 9 | }, 10 | es: { 11 | attachmentButtonAccessibilityLabel: 'Enviar multimedia', 12 | emptyChatPlaceholder: 'Aún no hay mensajes', 13 | fileButtonAccessibilityLabel: 'Archivo', 14 | inputPlaceholder: 'Mensaje', 15 | sendButtonAccessibilityLabel: 'Enviar', 16 | }, 17 | ko: { 18 | attachmentButtonAccessibilityLabel: '미디어 보내기', 19 | emptyChatPlaceholder: '주고받은 메시지가 없습니다', 20 | fileButtonAccessibilityLabel: '파일', 21 | inputPlaceholder: '메시지', 22 | sendButtonAccessibilityLabel: '보내기', 23 | }, 24 | pl: { 25 | attachmentButtonAccessibilityLabel: 'Wyślij multimedia', 26 | emptyChatPlaceholder: 'Tu jeszcze nie ma wiadomości', 27 | fileButtonAccessibilityLabel: 'Plik', 28 | inputPlaceholder: 'Napisz wiadomość', 29 | sendButtonAccessibilityLabel: 'Wyślij', 30 | }, 31 | pt: { 32 | attachmentButtonAccessibilityLabel: 'Envia mídia', 33 | emptyChatPlaceholder: 'Ainda não há mensagens aqui', 34 | fileButtonAccessibilityLabel: 'Arquivo', 35 | inputPlaceholder: 'Mensagem', 36 | sendButtonAccessibilityLabel: 'Enviar', 37 | }, 38 | ru: { 39 | attachmentButtonAccessibilityLabel: 'Отправить медиа', 40 | emptyChatPlaceholder: 'Пока что у вас нет сообщений', 41 | fileButtonAccessibilityLabel: 'Файл', 42 | inputPlaceholder: 'Сообщение', 43 | sendButtonAccessibilityLabel: 'Отправить', 44 | }, 45 | tr: { 46 | attachmentButtonAccessibilityLabel: 'Medya gönder', 47 | emptyChatPlaceholder: 'Henüz mesaj yok', 48 | fileButtonAccessibilityLabel: 'Dosya', 49 | inputPlaceholder: 'Mesaj yazın', 50 | sendButtonAccessibilityLabel: 'Gönder', 51 | }, 52 | uk: { 53 | attachmentButtonAccessibilityLabel: 'Надіслати медіа', 54 | emptyChatPlaceholder: 'Повідомлень ще немає', 55 | fileButtonAccessibilityLabel: 'Файл', 56 | inputPlaceholder: 'Повідомлення', 57 | sendButtonAccessibilityLabel: 'Надіслати', 58 | }, 59 | } 60 | -------------------------------------------------------------------------------- /src/theme.ts: -------------------------------------------------------------------------------- 1 | import { ColorValue } from 'react-native' 2 | 3 | import { Theme } from './types' 4 | 5 | // For internal usage only. Use values from theme itself. 6 | 7 | /** @see {@link ThemeColors.userAvatarNameColors} */ 8 | export const COLORS: ColorValue[] = [ 9 | '#ff6767', 10 | '#66e0da', 11 | '#f5a2d9', 12 | '#f0c722', 13 | '#6a85e5', 14 | '#fd9a6f', 15 | '#92db6e', 16 | '#73b8e5', 17 | '#fd7590', 18 | '#c78ae5', 19 | ] 20 | 21 | /** Dark */ 22 | const DARK = '#1f1c38' 23 | 24 | /** Error */ 25 | const ERROR = '#ff6767' 26 | 27 | /** N0 */ 28 | const NEUTRAL_0 = '#1d1c21' 29 | 30 | /** N2 */ 31 | const NEUTRAL_2 = '#9e9cab' 32 | 33 | /** N7 */ 34 | const NEUTRAL_7 = '#ffffff' 35 | 36 | /** N7 with opacity */ 37 | const NEUTRAL_7_WITH_OPACITY = '#ffffff80' 38 | 39 | /** Primary */ 40 | const PRIMARY = '#6f61e8' 41 | 42 | /** Secondary */ 43 | const SECONDARY = '#f5f5f7' 44 | 45 | /** Secondary dark */ 46 | const SECONDARY_DARK = '#2b2250' 47 | 48 | /** Default chat theme which implements {@link Theme} */ 49 | export const defaultTheme: Theme = { 50 | borders: { 51 | inputBorderRadius: 20, 52 | messageBorderRadius: 20, 53 | }, 54 | colors: { 55 | background: NEUTRAL_7, 56 | error: ERROR, 57 | inputBackground: NEUTRAL_0, 58 | inputText: NEUTRAL_7, 59 | primary: PRIMARY, 60 | receivedMessageDocumentIcon: PRIMARY, 61 | secondary: SECONDARY, 62 | sentMessageDocumentIcon: NEUTRAL_7, 63 | userAvatarImageBackground: 'transparent', 64 | userAvatarNameColors: COLORS, 65 | }, 66 | fonts: { 67 | dateDividerTextStyle: { 68 | color: NEUTRAL_2, 69 | fontSize: 12, 70 | fontWeight: '800', 71 | lineHeight: 16, 72 | }, 73 | emptyChatPlaceholderTextStyle: { 74 | color: NEUTRAL_2, 75 | fontSize: 16, 76 | fontWeight: '500', 77 | lineHeight: 24, 78 | }, 79 | inputTextStyle: { 80 | fontSize: 16, 81 | fontWeight: '500', 82 | lineHeight: 24, 83 | }, 84 | receivedMessageBodyTextStyle: { 85 | color: NEUTRAL_0, 86 | fontSize: 16, 87 | fontWeight: '500', 88 | lineHeight: 24, 89 | }, 90 | receivedMessageCaptionTextStyle: { 91 | color: NEUTRAL_2, 92 | fontSize: 12, 93 | fontWeight: '500', 94 | lineHeight: 16, 95 | }, 96 | receivedMessageLinkDescriptionTextStyle: { 97 | color: NEUTRAL_0, 98 | fontSize: 14, 99 | fontWeight: '400', 100 | lineHeight: 20, 101 | }, 102 | receivedMessageLinkTitleTextStyle: { 103 | color: NEUTRAL_0, 104 | fontSize: 16, 105 | fontWeight: '800', 106 | lineHeight: 22, 107 | }, 108 | sentMessageBodyTextStyle: { 109 | color: NEUTRAL_7, 110 | fontSize: 16, 111 | fontWeight: '500', 112 | lineHeight: 24, 113 | }, 114 | sentMessageCaptionTextStyle: { 115 | color: NEUTRAL_7_WITH_OPACITY, 116 | fontSize: 12, 117 | fontWeight: '500', 118 | lineHeight: 16, 119 | }, 120 | sentMessageLinkDescriptionTextStyle: { 121 | color: NEUTRAL_7, 122 | fontSize: 14, 123 | fontWeight: '400', 124 | lineHeight: 20, 125 | }, 126 | sentMessageLinkTitleTextStyle: { 127 | color: NEUTRAL_7, 128 | fontSize: 16, 129 | fontWeight: '800', 130 | lineHeight: 22, 131 | }, 132 | userAvatarTextStyle: { 133 | color: NEUTRAL_7, 134 | fontSize: 12, 135 | fontWeight: '800', 136 | lineHeight: 16, 137 | }, 138 | userNameTextStyle: { 139 | fontSize: 12, 140 | fontWeight: '800', 141 | lineHeight: 16, 142 | }, 143 | }, 144 | insets: { 145 | messageInsetsHorizontal: 20, 146 | messageInsetsVertical: 16, 147 | }, 148 | } 149 | 150 | /** Dark chat theme which implements {@link Theme} */ 151 | export const darkTheme: Theme = { 152 | ...defaultTheme, 153 | colors: { 154 | ...defaultTheme.colors, 155 | background: DARK, 156 | inputBackground: SECONDARY_DARK, 157 | secondary: SECONDARY_DARK, 158 | }, 159 | fonts: { 160 | ...defaultTheme.fonts, 161 | dateDividerTextStyle: { 162 | ...defaultTheme.fonts.dateDividerTextStyle, 163 | color: NEUTRAL_7, 164 | }, 165 | receivedMessageBodyTextStyle: { 166 | ...defaultTheme.fonts.receivedMessageBodyTextStyle, 167 | color: NEUTRAL_7, 168 | }, 169 | receivedMessageCaptionTextStyle: { 170 | ...defaultTheme.fonts.receivedMessageCaptionTextStyle, 171 | color: NEUTRAL_7_WITH_OPACITY, 172 | }, 173 | receivedMessageLinkDescriptionTextStyle: { 174 | ...defaultTheme.fonts.receivedMessageLinkDescriptionTextStyle, 175 | color: NEUTRAL_7, 176 | }, 177 | receivedMessageLinkTitleTextStyle: { 178 | ...defaultTheme.fonts.receivedMessageLinkTitleTextStyle, 179 | color: NEUTRAL_7, 180 | }, 181 | }, 182 | } 183 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import { PreviewData } from '@flyerhq/react-native-link-preview' 2 | import * as React from 'react' 3 | import { ColorValue, ImageURISource, TextStyle } from 'react-native' 4 | 5 | export namespace MessageType { 6 | export type Any = Custom | File | Image | Text | Unsupported 7 | 8 | export type DerivedMessage = 9 | | DerivedCustom 10 | | DerivedFile 11 | | DerivedImage 12 | | DerivedText 13 | | DerivedUnsupported 14 | export type DerivedAny = DateHeader | DerivedMessage 15 | 16 | export type PartialAny = 17 | | PartialCustom 18 | | PartialFile 19 | | PartialImage 20 | | PartialText 21 | 22 | interface Base { 23 | author: User 24 | createdAt?: number 25 | id: string 26 | metadata?: Record 27 | roomId?: string 28 | status?: 'delivered' | 'error' | 'seen' | 'sending' | 'sent' 29 | type: 'custom' | 'file' | 'image' | 'text' | 'unsupported' 30 | updatedAt?: number 31 | } 32 | 33 | export interface DerivedMessageProps extends Base { 34 | nextMessageInGroup: boolean 35 | // TODO: Check name? 36 | offset: number 37 | showName: boolean 38 | showStatus: boolean 39 | } 40 | 41 | export interface DerivedCustom extends DerivedMessageProps, Custom { 42 | type: Custom['type'] 43 | } 44 | 45 | export interface DerivedFile extends DerivedMessageProps, File { 46 | type: File['type'] 47 | } 48 | 49 | export interface DerivedImage extends DerivedMessageProps, Image { 50 | type: Image['type'] 51 | } 52 | 53 | export interface DerivedText extends DerivedMessageProps, Text { 54 | type: Text['type'] 55 | } 56 | 57 | export interface DerivedUnsupported extends DerivedMessageProps, Unsupported { 58 | type: Unsupported['type'] 59 | } 60 | 61 | export interface PartialCustom extends Base { 62 | metadata?: Record 63 | type: 'custom' 64 | } 65 | 66 | export interface Custom extends Base, PartialCustom { 67 | type: 'custom' 68 | } 69 | 70 | export interface PartialFile { 71 | metadata?: Record 72 | mimeType?: string 73 | name: string 74 | size: number 75 | type: 'file' 76 | uri: string 77 | } 78 | 79 | export interface File extends Base, PartialFile { 80 | type: 'file' 81 | } 82 | 83 | export interface PartialImage { 84 | height?: number 85 | metadata?: Record 86 | name: string 87 | size: number 88 | type: 'image' 89 | uri: string 90 | width?: number 91 | } 92 | 93 | export interface Image extends Base, PartialImage { 94 | type: 'image' 95 | } 96 | 97 | export interface PartialText { 98 | metadata?: Record 99 | previewData?: PreviewData 100 | text: string 101 | type: 'text' 102 | } 103 | 104 | export interface Text extends Base, PartialText { 105 | type: 'text' 106 | } 107 | 108 | export interface Unsupported extends Base { 109 | type: 'unsupported' 110 | } 111 | 112 | export interface DateHeader { 113 | id: string 114 | text: string 115 | type: 'dateHeader' 116 | } 117 | } 118 | 119 | export interface PreviewImage { 120 | id: string 121 | uri: ImageURISource['uri'] 122 | } 123 | 124 | export interface Size { 125 | height: number 126 | width: number 127 | } 128 | 129 | /** Base chat theme containing all required properties to make a theme. 130 | * Implement this interface if you want to create a custom theme. */ 131 | export interface Theme { 132 | borders: ThemeBorders 133 | colors: ThemeColors 134 | fonts: ThemeFonts 135 | icons?: ThemeIcons 136 | insets: ThemeInsets 137 | } 138 | 139 | export interface ThemeBorders { 140 | inputBorderRadius: number 141 | messageBorderRadius: number 142 | } 143 | 144 | export interface ThemeColors { 145 | background: ColorValue 146 | error: ColorValue 147 | inputBackground: ColorValue 148 | inputText: ColorValue 149 | primary: ColorValue 150 | secondary: ColorValue 151 | receivedMessageDocumentIcon: ColorValue 152 | sentMessageDocumentIcon: ColorValue 153 | userAvatarImageBackground: ColorValue 154 | userAvatarNameColors: ColorValue[] 155 | } 156 | 157 | export interface ThemeFonts { 158 | dateDividerTextStyle: TextStyle 159 | emptyChatPlaceholderTextStyle: TextStyle 160 | inputTextStyle: TextStyle 161 | receivedMessageBodyTextStyle: TextStyle 162 | receivedMessageCaptionTextStyle: TextStyle 163 | receivedMessageLinkDescriptionTextStyle: TextStyle 164 | receivedMessageLinkTitleTextStyle: TextStyle 165 | sentMessageBodyTextStyle: TextStyle 166 | sentMessageCaptionTextStyle: TextStyle 167 | sentMessageLinkDescriptionTextStyle: TextStyle 168 | sentMessageLinkTitleTextStyle: TextStyle 169 | userAvatarTextStyle: TextStyle 170 | userNameTextStyle: TextStyle 171 | } 172 | 173 | export interface ThemeIcons { 174 | attachmentButtonIcon?: () => React.ReactNode 175 | deliveredIcon?: () => React.ReactNode 176 | documentIcon?: () => React.ReactNode 177 | errorIcon?: () => React.ReactNode 178 | seenIcon?: () => React.ReactNode 179 | sendButtonIcon?: () => React.ReactNode 180 | sendingIcon?: () => React.ReactNode 181 | } 182 | 183 | export interface ThemeInsets { 184 | messageInsetsHorizontal: number 185 | messageInsetsVertical: number 186 | } 187 | 188 | export interface User { 189 | createdAt?: number 190 | firstName?: string 191 | id: string 192 | imageUrl?: ImageURISource['uri'] 193 | lastName?: string 194 | lastSeen?: number 195 | metadata?: Record 196 | role?: 'admin' | 'agent' | 'moderator' | 'user' 197 | updatedAt?: number 198 | } 199 | -------------------------------------------------------------------------------- /src/utils/__tests__/utils.test.ts: -------------------------------------------------------------------------------- 1 | import { formatBytes, getTextSizeInBytes, unwrap } from '..' 2 | 3 | describe('formatBytes', () => { 4 | it('formats bytes correctly when the size is 0', () => { 5 | expect.assertions(1) 6 | expect(formatBytes(0)).toBe('0 B') 7 | }) 8 | 9 | it('formats bytes correctly', () => { 10 | expect.assertions(1) 11 | expect(formatBytes(1024)).toBe('1 kB') 12 | }) 13 | }) 14 | 15 | describe('getTextSizeInBytes', () => { 16 | it('calculates the size for a simple text', () => { 17 | expect.assertions(1) 18 | const text = 'text' 19 | expect(getTextSizeInBytes(text)).toBe(4) 20 | }) 21 | 22 | it('calculates the size for an emoji text', () => { 23 | expect.assertions(1) 24 | const text = '🤔 🤓' 25 | expect(getTextSizeInBytes(text)).toBe(9) 26 | }) 27 | }) 28 | 29 | describe('unwrap', () => { 30 | it('returns an empty object', () => { 31 | expect.assertions(1) 32 | expect(unwrap(undefined)).toStrictEqual({}) 33 | }) 34 | 35 | it('returns a provided prop', () => { 36 | expect.assertions(1) 37 | const prop = 'prop' 38 | expect(unwrap(prop)).toStrictEqual(prop) 39 | }) 40 | }) 41 | -------------------------------------------------------------------------------- /src/utils/index.ts: -------------------------------------------------------------------------------- 1 | import dayjs from 'dayjs' 2 | import * as React from 'react' 3 | import { ColorValue } from 'react-native' 4 | import Blob from 'react-native/Libraries/Blob/Blob' 5 | 6 | import { l10n } from '../l10n' 7 | import { defaultTheme } from '../theme' 8 | import { MessageType, PreviewImage, Theme, User } from '../types' 9 | 10 | export const L10nContext = React.createContext( 11 | l10n.en 12 | ) 13 | export const ThemeContext = React.createContext(defaultTheme) 14 | export const UserContext = React.createContext(undefined) 15 | 16 | /** Returns text representation of a provided bytes value (e.g. 1kB, 1GB) */ 17 | export const formatBytes = (size: number, fractionDigits = 2) => { 18 | if (size <= 0) return '0 B' 19 | const multiple = Math.floor(Math.log(size) / Math.log(1024)) 20 | return ( 21 | parseFloat((size / Math.pow(1024, multiple)).toFixed(fractionDigits)) + 22 | ' ' + 23 | ['B', 'kB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'][multiple] 24 | ) 25 | } 26 | 27 | /** Returns size in bytes of the provided text */ 28 | export const getTextSizeInBytes = (text: string) => new Blob([text]).size 29 | 30 | /** Returns user avatar and name color based on the ID */ 31 | export const getUserAvatarNameColor = (user: User, colors: ColorValue[]) => 32 | colors[hashCode(user.id) % colors.length] 33 | 34 | /** Returns user initials (can have only first letter of firstName/lastName or both) */ 35 | export const getUserInitials = ({ firstName, lastName }: User) => 36 | `${firstName?.charAt(0) ?? ''}${lastName?.charAt(0) ?? ''}` 37 | .toUpperCase() 38 | .trim() 39 | 40 | /** Returns user name as joined firstName and lastName */ 41 | export const getUserName = ({ firstName, lastName }: User) => 42 | `${firstName ?? ''} ${lastName ?? ''}`.trim() 43 | 44 | /** Returns hash code of the provided text */ 45 | export const hashCode = (text = '') => { 46 | let i, 47 | chr, 48 | hash = 0 49 | if (text.length === 0) return hash 50 | for (i = 0; i < text.length; i++) { 51 | chr = text.charCodeAt(i) 52 | // eslint-disable-next-line no-bitwise 53 | hash = (hash << 5) - hash + chr 54 | // eslint-disable-next-line no-bitwise 55 | hash |= 0 // Convert to 32bit integer 56 | } 57 | return Math.abs(hash) 58 | } 59 | 60 | /** Inits dayjs locale */ 61 | export const initLocale = (locale?: keyof typeof l10n) => { 62 | const locales: { [key in keyof typeof l10n]: unknown } = { 63 | en: require('dayjs/locale/en'), 64 | es: require('dayjs/locale/es'), 65 | ko: require('dayjs/locale/ko'), 66 | pl: require('dayjs/locale/pl'), 67 | pt: require('dayjs/locale/pt'), 68 | ru: require('dayjs/locale/ru'), 69 | tr: require('dayjs/locale/tr'), 70 | uk: require('dayjs/locale/uk'), 71 | } 72 | 73 | locale ? locales[locale] : locales.en 74 | dayjs.locale(locale) 75 | } 76 | 77 | /** Returns either prop or empty object if null or undefined */ 78 | export const unwrap = (prop: T) => prop ?? {} 79 | 80 | /** Returns formatted date used as a divider between different days in the chat history */ 81 | const getVerboseDateTimeRepresentation = ( 82 | dateTime: number, 83 | { 84 | dateFormat, 85 | timeFormat, 86 | }: { 87 | dateFormat?: string 88 | timeFormat?: string 89 | } 90 | ) => { 91 | const formattedDate = dateFormat 92 | ? dayjs(dateTime).format(dateFormat) 93 | : dayjs(dateTime).format('MMM D') 94 | 95 | const formattedTime = timeFormat 96 | ? dayjs(dateTime).format(timeFormat) 97 | : dayjs(dateTime).format('HH:mm') 98 | 99 | const localDateTime = dayjs(dateTime) 100 | const now = dayjs() 101 | 102 | if ( 103 | localDateTime.isSame(now, 'day') && 104 | localDateTime.isSame(now, 'month') && 105 | localDateTime.isSame(now, 'year') 106 | ) { 107 | return formattedTime 108 | } 109 | 110 | return `${formattedDate}, ${formattedTime}` 111 | } 112 | 113 | /** Parses provided messages to chat messages (with headers) and returns them with a gallery */ 114 | export const calculateChatMessages = ( 115 | messages: MessageType.Any[], 116 | user: User, 117 | { 118 | customDateHeaderText, 119 | dateFormat, 120 | showUserNames, 121 | timeFormat, 122 | }: { 123 | customDateHeaderText?: (dateTime: number) => string 124 | dateFormat?: string 125 | showUserNames: boolean 126 | timeFormat?: string 127 | } 128 | ) => { 129 | let chatMessages: MessageType.DerivedAny[] = [] 130 | let gallery: PreviewImage[] = [] 131 | 132 | let shouldShowName = false 133 | 134 | for (let i = messages.length - 1; i >= 0; i--) { 135 | const isFirst = i === messages.length - 1 136 | const isLast = i === 0 137 | const message = messages[i] 138 | const messageHasCreatedAt = !!message.createdAt 139 | const nextMessage = isLast ? undefined : messages[i - 1] 140 | const nextMessageHasCreatedAt = !!nextMessage?.createdAt 141 | const nextMessageSameAuthor = message.author.id === nextMessage?.author.id 142 | const notMyMessage = message.author.id !== user.id 143 | 144 | let nextMessageDateThreshold = false 145 | let nextMessageDifferentDay = false 146 | let nextMessageInGroup = false 147 | let showName = false 148 | 149 | if (showUserNames) { 150 | const previousMessage = isFirst ? undefined : messages[i + 1] 151 | 152 | const isFirstInGroup = 153 | notMyMessage && 154 | (message.author.id !== previousMessage?.author.id || 155 | (messageHasCreatedAt && 156 | !!previousMessage?.createdAt && 157 | message.createdAt! - previousMessage!.createdAt! > 60000)) 158 | 159 | if (isFirstInGroup) { 160 | shouldShowName = false 161 | if (message.type === 'text') { 162 | showName = true 163 | } else { 164 | shouldShowName = true 165 | } 166 | } 167 | 168 | if (message.type === 'text' && shouldShowName) { 169 | showName = true 170 | shouldShowName = false 171 | } 172 | } 173 | 174 | if (messageHasCreatedAt && nextMessageHasCreatedAt) { 175 | nextMessageDateThreshold = 176 | nextMessage!.createdAt! - message.createdAt! >= 900000 177 | 178 | nextMessageDifferentDay = !dayjs(message.createdAt!).isSame( 179 | nextMessage!.createdAt!, 180 | 'day' 181 | ) 182 | 183 | nextMessageInGroup = 184 | nextMessageSameAuthor && 185 | nextMessage!.createdAt! - message.createdAt! <= 60000 186 | } 187 | 188 | if (isFirst && messageHasCreatedAt) { 189 | const text = 190 | customDateHeaderText?.(message.createdAt!) ?? 191 | getVerboseDateTimeRepresentation(message.createdAt!, { 192 | dateFormat, 193 | timeFormat, 194 | }) 195 | chatMessages = [{ id: text, text, type: 'dateHeader' }, ...chatMessages] 196 | } 197 | 198 | chatMessages = [ 199 | { 200 | ...message, 201 | nextMessageInGroup, 202 | // TODO: Check this 203 | offset: !nextMessageInGroup ? 12 : 0, 204 | showName: 205 | notMyMessage && 206 | showUserNames && 207 | showName && 208 | !!getUserName(message.author), 209 | showStatus: true, 210 | }, 211 | ...chatMessages, 212 | ] 213 | 214 | if (nextMessageDifferentDay || nextMessageDateThreshold) { 215 | const text = 216 | customDateHeaderText?.(nextMessage!.createdAt!) ?? 217 | getVerboseDateTimeRepresentation(nextMessage!.createdAt!, { 218 | dateFormat, 219 | timeFormat, 220 | }) 221 | 222 | chatMessages = [ 223 | { 224 | id: text, 225 | text, 226 | type: 'dateHeader', 227 | }, 228 | ...chatMessages, 229 | ] 230 | } 231 | 232 | if (message.type === 'image') { 233 | gallery = [...gallery, { id: message.id, uri: message.uri }] 234 | } 235 | } 236 | 237 | return { 238 | chatMessages, 239 | gallery, 240 | } 241 | } 242 | 243 | /** Removes all derived message props from the derived message */ 244 | export const excludeDerivedMessageProps = ( 245 | message: MessageType.DerivedMessage 246 | ) => { 247 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 248 | const { nextMessageInGroup, offset, showName, showStatus, ...rest } = message 249 | return { ...rest } as MessageType.Any 250 | } 251 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "declaration": true, 4 | "esModuleInterop": true, 5 | "jsx": "react", 6 | "lib": ["ESNext"], 7 | "module": "ESNext", 8 | "moduleResolution": "Node", 9 | "noEmitOnError": true, 10 | "outDir": "./lib", 11 | "skipLibCheck": true, 12 | "sourceMap": true, 13 | "strict": true, 14 | "target": "ES2018", 15 | }, 16 | "exclude": ["**/__tests__/*"], 17 | "include": ["src"] 18 | } 19 | --------------------------------------------------------------------------------