diff --git a/with-skeet-firebase/.eslintignore b/with-skeet-firebase/.eslintignore new file mode 100644 index 00000000..10432a59 --- /dev/null +++ b/with-skeet-firebase/.eslintignore @@ -0,0 +1,5 @@ +out +dist +build +node_modules +web-build diff --git a/with-skeet-firebase/.eslintrc.json b/with-skeet-firebase/.eslintrc.json new file mode 100644 index 00000000..828d5731 --- /dev/null +++ b/with-skeet-firebase/.eslintrc.json @@ -0,0 +1,35 @@ +{ + "extends": [ + "eslint:recommended", + "plugin:react-hooks/recommended", + "plugin:react-native/all", + "plugin:@typescript-eslint/recommended", + "prettier" + ], + "parserOptions": { + "ecmaVersion": "latest", + "sourceType": "module" + }, + "parser": "@typescript-eslint/parser", + "plugins": ["react-native", "@typescript-eslint", "react-hooks"], + "env": { + "browser": true, + "es2021": true, + "react-native/react-native": true + }, + "rules": { + "react-hooks/rules-of-hooks": "error", + "react-hooks/exhaustive-deps": "warn", + "@typescript-eslint/no-explicit-any": 0, + "react-native/no-raw-text": 0, + "@typescript-eslint/no-var-requires": 0, + "@typescript-eslint/no-unused-vars": 0, + "@typescript-eslint/no-empty-function": 0, + "@typescript-eslint/ban-ts-comment": [ + "off", + { + "ts-ignore": "allow-with-description" + } + ] + } +} diff --git a/with-skeet-firebase/.firebaserc b/with-skeet-firebase/.firebaserc new file mode 100644 index 00000000..4e5737da --- /dev/null +++ b/with-skeet-firebase/.firebaserc @@ -0,0 +1,14 @@ +{ + "projects": { + "default": "skeet-app" + }, + "targets": { + "skeet-app": { + "hosting": { + "skeet-app": [ + "skeet-app" + ] + } + } + } +} diff --git a/with-skeet-firebase/.gitignore b/with-skeet-firebase/.gitignore new file mode 100644 index 00000000..7f1b82de --- /dev/null +++ b/with-skeet-firebase/.gitignore @@ -0,0 +1,96 @@ +# Compiled JavaScript files +lib/**/*.js +lib/**/*.js.map + +# TypeScript v1 declaration files +typings/ + +# Node.js dependency directory +node_modules/ +keyfile.json +.env +functions/**/.env +functions/**/*.log +*.log + +# OSX +# +.DS_Store + +# Xcode +# +build/ +*.pbxuser +!default.pbxuser +*.mode1v3 +!default.mode1v3 +*.mode2v3 +!default.mode2v3 +*.perspectivev3 +!default.perspectivev3 +xcuserdata +*.xccheckout +*.moved-aside +DerivedData +*.hmap +*.ipa +*.xcuserstate +project.xcworkspace + +# Android/IntelliJ +# +build/ +.idea +.gradle +local.properties +*.iml +*.hprof + +# node.js +# +node_modules/ +npm-debug.log +yarn-error.log + +# BUCK +buck-out/ +\.buckd/ +*.keystore + +# fastlane +# +# It is recommended to not store the screenshots in the git repo. Instead, use fastlane to re-generate the +# screenshots whenever they are needed. +# For more information about the recommended setup visit: +# https://docs.fastlane.tools/best-practices/source-control/ + +*/fastlane/report.xml +*/fastlane/Preview.html +*/fastlane/screenshots + +# Bundle artifacts +*.jsbundle + +# CocoaPods +/ios/Pods/ + +# Expo +.expo/ +web-build/ + +# Temporary files created by Metro to check the health of the file watcher +.metro-health-check* + + +# local env files +.env*.local + +# Gcloud key +gcloud-key.json + +# env +.env + +#firebase +.firebase +firebase-debug.log \ No newline at end of file diff --git a/with-skeet-firebase/.node-version b/with-skeet-firebase/.node-version new file mode 100644 index 00000000..3c79f30e --- /dev/null +++ b/with-skeet-firebase/.node-version @@ -0,0 +1 @@ +18.16.0 \ No newline at end of file diff --git a/with-skeet-firebase/.prettierignore b/with-skeet-firebase/.prettierignore new file mode 100644 index 00000000..89a89e74 --- /dev/null +++ b/with-skeet-firebase/.prettierignore @@ -0,0 +1,5 @@ +out +dist +build +.expo +web-build \ No newline at end of file diff --git a/with-skeet-firebase/.prettierrc b/with-skeet-firebase/.prettierrc new file mode 100644 index 00000000..a72ad626 --- /dev/null +++ b/with-skeet-firebase/.prettierrc @@ -0,0 +1,7 @@ +{ + "semi": false, + "singleQuote": true, + "plugins": ["prettier-plugin-tailwindcss"], + "pluginSearchDirs": false, + "printWidth": 80 +} diff --git a/with-skeet-firebase/App.tsx b/with-skeet-firebase/App.tsx new file mode 100644 index 00000000..346bb4a5 --- /dev/null +++ b/with-skeet-firebase/App.tsx @@ -0,0 +1,59 @@ +import 'react-native-gesture-handler' +import '@/lib/i18n' +import { useDeviceContext } from 'twrnc' +import tw from '@/lib/tailwind' +import { useFonts } from 'expo-font' +import { + Outfit_300Light, + Outfit_400Regular, + Outfit_500Medium, + Outfit_700Bold, +} from '@expo-google-fonts/outfit' + +import AppLoading from '@/components/loading/AppLoading' +import { Suspense } from 'react' +import { RecoilRoot } from 'recoil' +import { MenuProvider } from 'react-native-popup-menu' +import Toast from 'react-native-toast-message' +import { toastConfig } from '@/lib/toast' +import Routes from '@/routes/Routes' + +export default function App() { + useDeviceContext(tw) + + const [fontsLoaded] = useFonts({ + Outfit_300Light, + Outfit_400Regular, + Outfit_500Medium, + Outfit_700Bold, + }) + + if (!fontsLoaded) { + return ( + <> + + + ) + } + + return ( + <> + }> + + + + + + ) +} + +function SkeetApp() { + return ( + <> + + + + + + ) +} diff --git a/with-skeet-firebase/CODE_OF_CONDUCT.md b/with-skeet-firebase/CODE_OF_CONDUCT.md new file mode 100755 index 00000000..bfcc6f79 --- /dev/null +++ b/with-skeet-firebase/CODE_OF_CONDUCT.md @@ -0,0 +1,133 @@ + +# Contributor Covenant Code of Conduct + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in our +community a harassment-free experience for everyone, regardless of age, body +size, visible or invisible disability, ethnicity, sex characteristics, gender +identity and expression, level of experience, education, socio-economic status, +nationality, personal appearance, race, caste, color, religion, or sexual +identity and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, +diverse, inclusive, and healthy community. + +## Our Standards + +Examples of behavior that contributes to a positive environment for our +community include: + +* Demonstrating empathy and kindness toward other people +* Being respectful of differing opinions, viewpoints, and experiences +* Giving and gracefully accepting constructive feedback +* Accepting responsibility and apologizing to those affected by our mistakes, + and learning from the experience +* Focusing on what is best not just for us as individuals, but for the overall + community + +Examples of unacceptable behavior include: + +* The use of sexualized language or imagery, and sexual attention or advances of + any kind +* Trolling, insulting or derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or email address, + without their explicit permission +* Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Enforcement Responsibilities + +Community leaders are responsible for clarifying and enforcing our standards of +acceptable behavior and will take appropriate and fair corrective action in +response to any behavior that they deem inappropriate, threatening, offensive, +or harmful. + +Community leaders have the right and responsibility to remove, edit, or reject +comments, commits, code, wiki edits, issues, and other contributions that are +not aligned to this Code of Conduct, and will communicate reasons for moderation +decisions when appropriate. + +## Scope + +This Code of Conduct applies within all community spaces, and also applies when +an individual is officially representing the community in public spaces. +Examples of representing our community include using an official e-mail address, +posting via an official social media account, or acting as an appointed +representative at an online or offline event. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported to the community leaders responsible for enforcement at +conduct@elsoul.nl. +All complaints will be reviewed and investigated promptly and fairly. + +All community leaders are obligated to respect the privacy and security of the +reporter of any incident. + +## Enforcement Guidelines + +Community leaders will follow these Community Impact Guidelines in determining +the consequences for any action they deem in violation of this Code of Conduct: + +### 1. Correction + +**Community Impact**: Use of inappropriate language or other behavior deemed +unprofessional or unwelcome in the community. + +**Consequence**: A private, written warning from community leaders, providing +clarity around the nature of the violation and an explanation of why the +behavior was inappropriate. A public apology may be requested. + +### 2. Warning + +**Community Impact**: A violation through a single incident or series of +actions. + +**Consequence**: A warning with consequences for continued behavior. No +interaction with the people involved, including unsolicited interaction with +those enforcing the Code of Conduct, for a specified period of time. This +includes avoiding interactions in community spaces as well as external channels +like social media. Violating these terms may lead to a temporary or permanent +ban. + +### 3. Temporary Ban + +**Community Impact**: A serious violation of community standards, including +sustained inappropriate behavior. + +**Consequence**: A temporary ban from any sort of interaction or public +communication with the community for a specified period of time. No public or +private interaction with the people involved, including unsolicited interaction +with those enforcing the Code of Conduct, is allowed during this period. +Violating these terms may lead to a permanent ban. + +### 4. Permanent Ban + +**Community Impact**: Demonstrating a pattern of violation of community +standards, including sustained inappropriate behavior, harassment of an +individual, or aggression toward or disparagement of classes of individuals. + +**Consequence**: A permanent ban from any sort of public interaction within the +community. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], +version 2.1, available at +[https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1]. + +Community Impact Guidelines were inspired by +[Mozilla's code of conduct enforcement ladder][Mozilla CoC]. + +For answers to common questions about this code of conduct, see the FAQ at +[https://www.contributor-covenant.org/faq][FAQ]. Translations are available at +[https://www.contributor-covenant.org/translations][translations]. + +[homepage]: https://www.contributor-covenant.org +[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html +[Mozilla CoC]: https://github.com/mozilla/diversity +[FAQ]: https://www.contributor-covenant.org/faq +[translations]: https://www.contributor-covenant.org/translations diff --git a/with-skeet-firebase/LICENSE.txt b/with-skeet-firebase/LICENSE.txt new file mode 100755 index 00000000..ab9e1d98 --- /dev/null +++ b/with-skeet-firebase/LICENSE.txt @@ -0,0 +1,67 @@ +Apache License + +Version 2.0, January 2004 + +http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + +"License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. + +"Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. + +"Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. + +"You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. + +"Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. + +"Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. + +"Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). + +"Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. + +"Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." + +"Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: + +You must give any other recipients of the Work or Derivative Works a copy of this License; and +You must cause any modified files to carry prominent notices stating that You changed the files; and +You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and +If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. + +You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. +5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +Copyright 2021 ELSOUL LABO B.V. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. \ No newline at end of file diff --git a/with-skeet-firebase/README.md b/with-skeet-firebase/README.md new file mode 100644 index 00000000..ef3ed392 --- /dev/null +++ b/with-skeet-firebase/README.md @@ -0,0 +1,119 @@ +![Skeet App Template](https://storage.googleapis.com/skeet-assets/imgs/samples/skeet-app-template.png) + +

+ + Follow @ELSOUL_LABO2 + +
+ + + + + + + + + + + + + +

+ +## Skeet Expo & Firebase Boilerplate + +- [Firebase - Serverless Platform](https://firebase.google.com/) +- [Google Cloud - Cloud Platform](https://cloud.google.com/) +- [Jest - Testing framework](https://jestjs.io/) +- [TypeScript - Type Check](https://www.typescriptlang.org/) +- [ESLint - Linter](https://eslint.org/) +- [Prettier - Formatter](https://prettier.io/) +- [React Native](https://reactnative.dev/) +- [Expo](https://docs.expo.dev/) +- [EAS Build](https://docs.expo.dev/build/introduction/) +- [Recoil - State Management](https://recoiljs.org/) +- [React i18n - Localization](https://react.i18next.com/) +- [twrnc - TailwindCSS](https://github.com/jaredh159/tailwind-react-native-classnames) +- [React Navigation - Routing](https://reactnavigation.org/) + +## What's Skeet? + +TypeScript Serverless Framework 'Skeet'. + +The Skeet project was launched with the goal of reducing software development, operation, and maintenance costs. + +Build Serverless Apps faster. + +## Dependency + +- [TypeScript](https://www.typescriptlang.org/) +- [Node](https://nodejs.org/) +- [Yarn](https://yarnpkg.com/) +- [Google SDK](https://cloud.google.com/sdk/docs) + +## Usage + +```bash +$ npm i -g firebase-tools +$ npm i -g @skeet-framework/cli +``` + +```bash +$ skeet create +$ cd +$ skeet s +``` + +or if you pulled this repo: + +```bash +$ skeet yarn install +$ skeet s +``` + +Open a new terminal and run: + +```bash +$ skeet login +$ export ACCESS_TOKEN= +``` + +**※ You need OpenAI API key to get success for default test.** + +_./functions/openai/.env_ + +```bash +CHAT_GPT_KEY=your-key +CHAT_GPT_ORG=your-org +``` + +Test your app: + +```bash +$ skeet test +``` + +Open http://localhost:4000 + +## EAS Build + +[EAS Build](https://docs.expo.dev/build/introduction/) + +You need to run this command to setup EAS Build project. +(Edit app.json for example changing names and deleting "extra" to build new project.) + +``` +yarn build:configure +``` + +## Contributing + +Bug reports and pull requests are welcome on GitHub at https://github.com/elsoul/skeet-app This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [Contributor Covenant](http://contributor-covenant.org) code of conduct. + +## License + +The package is available as open source under the terms of the [Apache-2.0 License](https://www.apache.org/licenses/LICENSE-2.0). + +## Code of Conduct + +Everyone interacting in the SKEET project’s codebases, issue trackers, chat rooms and mailing lists is expected to follow the [code of conduct](https://github.com/elsoul/skeet-app/blob/master/CODE_OF_CONDUCT.md). diff --git a/with-skeet-firebase/app.json b/with-skeet-firebase/app.json new file mode 100644 index 00000000..5fa7db71 --- /dev/null +++ b/with-skeet-firebase/app.json @@ -0,0 +1,43 @@ +{ + "expo": { + "name": "skeet-app", + "slug": "skeet-app", + "scheme": "skeetapp", + "owner": "elsoul-labo", + "platforms": ["web", "ios", "android"], + "githubUrl": "https://github.com/elsoul/skeet-app", + "version": "0.1.0", + "orientation": "portrait", + "primaryColor": "#111827", + "icon": "./assets/favicon.png", + "splash": { + "image": "./assets/splash.png", + "resizeMode": "contain", + "backgroundColor": "#ffffff" + }, + "assetBundlePatterns": ["**/*"], + "ios": { + "supportsTablet": true, + "bundleIdentifier": "com.elsoullabo.skeetapp" + }, + "android": { + "adaptiveIcon": { + "foregroundImage": "./assets/favicon.png", + "backgroundColor": "#ffffff" + }, + "versionCode": 1, + "package": "com.elsoullabo.skeetapp" + }, + "web": { + "favicon": "./assets/favicon.png" + }, + "plugins": [ + [ + "expo-image-picker", + { + "photosPermission": "The app accesses your photos to let you share them with your friends." + } + ] + ] + } +} diff --git a/with-skeet-firebase/assets/favicon.png b/with-skeet-firebase/assets/favicon.png new file mode 100644 index 00000000..7792b2c0 Binary files /dev/null and b/with-skeet-firebase/assets/favicon.png differ diff --git a/with-skeet-firebase/assets/logo/SkeetLogoHorizontal.svg b/with-skeet-firebase/assets/logo/SkeetLogoHorizontal.svg new file mode 100644 index 00000000..441d476a --- /dev/null +++ b/with-skeet-firebase/assets/logo/SkeetLogoHorizontal.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/with-skeet-firebase/assets/logo/SkeetLogoHorizontalInvert.svg b/with-skeet-firebase/assets/logo/SkeetLogoHorizontalInvert.svg new file mode 100644 index 00000000..07cc13fb --- /dev/null +++ b/with-skeet-firebase/assets/logo/SkeetLogoHorizontalInvert.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/with-skeet-firebase/assets/logo/SkeetLogoSquare.svg b/with-skeet-firebase/assets/logo/SkeetLogoSquare.svg new file mode 100644 index 00000000..f1d86645 --- /dev/null +++ b/with-skeet-firebase/assets/logo/SkeetLogoSquare.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/with-skeet-firebase/assets/logo/SkeetLogoSquareInvert.svg b/with-skeet-firebase/assets/logo/SkeetLogoSquareInvert.svg new file mode 100644 index 00000000..3dcc51fb --- /dev/null +++ b/with-skeet-firebase/assets/logo/SkeetLogoSquareInvert.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/with-skeet-firebase/assets/ogp.png b/with-skeet-firebase/assets/ogp.png new file mode 100644 index 00000000..183de655 Binary files /dev/null and b/with-skeet-firebase/assets/ogp.png differ diff --git a/with-skeet-firebase/assets/splash.png b/with-skeet-firebase/assets/splash.png new file mode 100644 index 00000000..66cdd15d Binary files /dev/null and b/with-skeet-firebase/assets/splash.png differ diff --git a/with-skeet-firebase/babel.config.js b/with-skeet-firebase/babel.config.js new file mode 100644 index 00000000..2cfbc1fd --- /dev/null +++ b/with-skeet-firebase/babel.config.js @@ -0,0 +1,34 @@ +module.exports = function (api) { + api.cache(true) + return { + presets: ['babel-preset-expo'], + plugins: [ + [ + 'module-resolver', + { + root: ['./src'], + extensions: [ + '.js', + '.jsx', + '.ts', + '.tsx', + '.android.js', + '.android.tsx', + '.ios.js', + '.ios.tsx', + '.svg', + '.jpg', + '.png', + '.gif', + ], + alias: { + '@': ['./src'], + '@assets': ['./assets'], + '@lib': ['./lib'], + '@root': ['.'], + }, + }, + ], + ], + } +} diff --git a/with-skeet-firebase/custom.d.ts b/with-skeet-firebase/custom.d.ts new file mode 100644 index 00000000..64d4b422 --- /dev/null +++ b/with-skeet-firebase/custom.d.ts @@ -0,0 +1,5 @@ +declare module '*.svg' { + import { SvgProps } from 'react-native-svg' + const content: React.FC + export default content +} diff --git a/with-skeet-firebase/eas.json b/with-skeet-firebase/eas.json new file mode 100644 index 00000000..0402b5e7 --- /dev/null +++ b/with-skeet-firebase/eas.json @@ -0,0 +1,28 @@ +{ + "cli": { + "version": "3.7.2" + }, + "build": { + "development": { + "developmentClient": false, + "distribution": "internal", + "ios": { + "resourceClass": "m1-medium" + } + }, + "preview": { + "distribution": "internal", + "ios": { + "resourceClass": "m1-medium" + } + }, + "production": { + "ios": { + "resourceClass": "m1-medium" + } + } + }, + "submit": { + "production": {} + } +} diff --git a/with-skeet-firebase/firebase.json b/with-skeet-firebase/firebase.json new file mode 100644 index 00000000..1da7fac8 --- /dev/null +++ b/with-skeet-firebase/firebase.json @@ -0,0 +1,45 @@ +{ + "functions": [ + { + "source": "functions/openai", + "codebase": "openai", + "ignore": [ + "node_modules", + ".git", + "firebase-debug.log", + "firebase-debug.*.log" + ] + } + ], + "firestore": { + "rules": "firestore.rules", + "indexes": "firestore.indexes.json" + }, + "storage": { + "rules": "storage.rules" + }, + "emulators": { + "auth": { + "port": 9099 + }, + "functions": { + "port": 5001 + }, + "firestore": { + "port": 8080 + }, + "pubsub": { + "port": 8085 + }, + "hosting": { + "port": 8000 + }, + "storage": { + "port": 9199 + }, + "ui": { + "enabled": true + }, + "singleProjectMode": true + } +} diff --git a/with-skeet-firebase/firestore.indexes.json b/with-skeet-firebase/firestore.indexes.json new file mode 100644 index 00000000..415027e5 --- /dev/null +++ b/with-skeet-firebase/firestore.indexes.json @@ -0,0 +1,4 @@ +{ + "indexes": [], + "fieldOverrides": [] +} diff --git a/with-skeet-firebase/firestore.rules b/with-skeet-firebase/firestore.rules new file mode 100644 index 00000000..547e76b9 --- /dev/null +++ b/with-skeet-firebase/firestore.rules @@ -0,0 +1,9 @@ +rules_version = '2'; +service cloud.firestore { + match /databases/{database}/documents { + match /User/{userId}/{document=**} { + allow read: if request.auth != null; + allow write: if request.auth.uid == userId; + } + } +} \ No newline at end of file diff --git a/with-skeet-firebase/jest.config.js b/with-skeet-firebase/jest.config.js new file mode 100755 index 00000000..e5027ebb --- /dev/null +++ b/with-skeet-firebase/jest.config.js @@ -0,0 +1,16 @@ +/** @type {import('ts-jest').JestConfigWithTsJest} */ + +module.exports = { + preset: 'ts-jest', + testEnvironment: 'node', + roots: ['/tests', '/src', '/lib'], + collectCoverage: true, + collectCoverageFrom: ['**/*.ts', '!**/node_modules/**'], + coverageDirectory: 'coverage_dir', + coverageReporters: ['html'], + moduleNameMapper: { + '^@/(.*)$': '/src/$1', + }, + setupFilesAfterEnv: ['./tests/jest.setup.ts'], + reporters: ['default', 'github-actions'], +} diff --git a/with-skeet-firebase/lib/firebaseConfig.ts b/with-skeet-firebase/lib/firebaseConfig.ts new file mode 100644 index 00000000..9daf3aa6 --- /dev/null +++ b/with-skeet-firebase/lib/firebaseConfig.ts @@ -0,0 +1,11 @@ +const firebaseConfig = { + projectId: "skeet-app", + appId: "1:970336459442:web:292c14b59159c5506407ab", + storageBucket: "skeet-app.appspot.com", + locationId: "europe-west3", + apiKey: "AIzaSyBYXs9JEym42gJCuO1OT7mJ_tyJUpJRB84", + authDomain: "skeet-app.firebaseapp.com", + messagingSenderId: "970336459442", + measurementId: "G-PR5LDPJSPY", +} +export default firebaseConfig; \ No newline at end of file diff --git a/with-skeet-firebase/metro.config.js b/with-skeet-firebase/metro.config.js new file mode 100644 index 00000000..b79fea52 --- /dev/null +++ b/with-skeet-firebase/metro.config.js @@ -0,0 +1,38 @@ +const path = require('path') +const { getDefaultConfig } = require('@expo/metro-config') + +const config = getDefaultConfig(__dirname) + +config.transformer = { + ...config.transformer, + babelTransformerPath: require.resolve('react-native-svg-transformer'), + getTransformOptions: () => ({ + transform: { + experimentalImportSupport: false, + inlineRequires: true, + }, + }), +} + +config.resolver = { + ...config.resolver, + assetExts: config.resolver.assetExts.filter((ext) => ext !== 'svg'), + sourceExts: [ + ...config.resolver.sourceExts, + 'cjs', + 'js', + 'ts', + 'tsx', + 'jsx', + 'svg', + ], + // extraNodeModules: require('expo-crypto-polyfills'), + alias: { + '@': path.resolve(__dirname, 'src'), + '@assets': path.resolve(__dirname, 'assets'), + '@lib': path.resolve(__dirname, 'lib'), + '@root': path.resolve(__dirname), + }, +} + +module.exports = config diff --git a/with-skeet-firebase/openai/.eslintignore b/with-skeet-firebase/openai/.eslintignore new file mode 100644 index 00000000..8af5919c --- /dev/null +++ b/with-skeet-firebase/openai/.eslintignore @@ -0,0 +1,4 @@ +out +dist +build +node_modules \ No newline at end of file diff --git a/with-skeet-firebase/openai/.eslintrc.json b/with-skeet-firebase/openai/.eslintrc.json new file mode 100644 index 00000000..bc61b1fe --- /dev/null +++ b/with-skeet-firebase/openai/.eslintrc.json @@ -0,0 +1,25 @@ +{ + "extends": [ + "eslint:recommended", + "plugin:@typescript-eslint/recommended", + "prettier" + ], + "parserOptions": { + "sourceType": "module", + "ecmaVersion": "latest" + }, + "parser": "@typescript-eslint/parser", + "plugins": ["@typescript-eslint"], + "env": { + "es6": true + }, + "rules": { + "@typescript-eslint/no-unused-vars": 0, + "@typescript-eslint/ban-ts-comment": [ + "off", + { + "ts-ignore": "allow-with-description" + } + ] + } +} diff --git a/with-skeet-firebase/openai/.gcloudignore b/with-skeet-firebase/openai/.gcloudignore new file mode 100644 index 00000000..5a616cba --- /dev/null +++ b/with-skeet-firebase/openai/.gcloudignore @@ -0,0 +1,17 @@ +# This file specifies files that are *not* uploaded to Google Cloud +# using gcloud. It follows the same syntax as .gitignore, with the addition of +# "#!include" directives (which insert the entries of the given .gitignore-style +# file at that point). +# +# For more information, run: +# $ gcloud topic gcloudignore +# +.gcloudignore +# If you would like to upload your .git directory, .gitignore file or files +# from your .gitignore file, remove the corresponding line +# below: +.git +.gitignore + +node_modules +#!include:.gitignore diff --git a/with-skeet-firebase/openai/.gitignore b/with-skeet-firebase/openai/.gitignore new file mode 100644 index 00000000..57411dab --- /dev/null +++ b/with-skeet-firebase/openai/.gitignore @@ -0,0 +1,12 @@ +# Compiled JavaScript files +lib/**/*.js +lib/**/*.js.map + +# TypeScript v1 declaration files +typings/ + +# Node.js dependency directory +node_modules/ +.env +*.log +dist/ \ No newline at end of file diff --git a/with-skeet-firebase/openai/.prettierignore b/with-skeet-firebase/openai/.prettierignore new file mode 100755 index 00000000..3e2c75a9 --- /dev/null +++ b/with-skeet-firebase/openai/.prettierignore @@ -0,0 +1,4 @@ +.next +out +dist +build \ No newline at end of file diff --git a/with-skeet-firebase/openai/.prettierrc b/with-skeet-firebase/openai/.prettierrc new file mode 100755 index 00000000..b2095be8 --- /dev/null +++ b/with-skeet-firebase/openai/.prettierrc @@ -0,0 +1,4 @@ +{ + "semi": false, + "singleQuote": true +} diff --git a/with-skeet-firebase/openai/build.ts b/with-skeet-firebase/openai/build.ts new file mode 100644 index 00000000..4a5cf68c --- /dev/null +++ b/with-skeet-firebase/openai/build.ts @@ -0,0 +1,14 @@ +import { build } from 'esbuild' +;(async () => { + await build({ + entryPoints: ['./src/index.ts'], + bundle: true, + minify: true, + outfile: './dist/index.js', + platform: 'node', + format: 'cjs', + define: { + 'process.env.NODE_ENV': `"production"`, + }, + }) +})() diff --git a/with-skeet-firebase/openai/devBuild.ts b/with-skeet-firebase/openai/devBuild.ts new file mode 100644 index 00000000..5de21d98 --- /dev/null +++ b/with-skeet-firebase/openai/devBuild.ts @@ -0,0 +1,14 @@ +import { build } from 'esbuild' +;(async () => { + await build({ + entryPoints: ['./src/index.ts'], + bundle: true, + minify: true, + outfile: './dist/index.js', + platform: 'node', + define: { + 'process.env.NODE_ENV': `"development"`, + }, + format: 'cjs', + }) +})() diff --git a/with-skeet-firebase/openai/nodemon.json b/with-skeet-firebase/openai/nodemon.json new file mode 100644 index 00000000..f25686eb --- /dev/null +++ b/with-skeet-firebase/openai/nodemon.json @@ -0,0 +1,7 @@ +{ + "watch": ["src"], + "ignore": ["src/**/*.test.ts", "node_modules"], + "ext": "ts,mjs,js,json", + "exec": "npx ts-node devBuild.ts && node ./dist/index.js", + "legacyWatch": true +} diff --git a/with-skeet-firebase/openai/package.json b/with-skeet-firebase/openai/package.json new file mode 100644 index 00000000..1fa75e8e --- /dev/null +++ b/with-skeet-firebase/openai/package.json @@ -0,0 +1,44 @@ +{ + "name": "functions", + "scripts": { + "lint": "eslint --ext .ts,.js --fix .", + "dev": "nodemon", + "dev:login": "npx ts-node -r tsconfig-paths/register --transpile-only src/scripts/login.ts", + "build": "npx ts-node build.ts", + "serve": "firebase emulators:start", + "shell": "yarn build && firebase functions:shell", + "start": "node dist/index.js", + "deploy": "firebase deploy --only functions", + "logs": "firebase functions:log", + "db": "npx ts-node -r tsconfig-paths/register --transpile-only src/models/chatRoom.ts", + "open": "npx ts-node -r tsconfig-paths/register --transpile-only src/lib/openai/openAi.ts" + }, + "engines": { + "node": "18" + }, + "main": "dist/index.js", + "dependencies": { + "@google-cloud/pubsub": "3.7.1", + "@skeet-framework/firestore": "^1.1.1", + "date-fns": "2.29.3", + "date-fns-tz": "2.0.0", + "dotenv": "16.0.3", + "firebase-admin": "11.9.0", + "firebase-functions": "4.4.1", + "node-fetch": "2.6.9", + "openai": "3.2.1" + }, + "devDependencies": { + "@typescript-eslint/eslint-plugin": "5.12.0", + "@typescript-eslint/parser": "5.12.0", + "esbuild": "0.17.14", + "eslint": "8.9.0", + "eslint-config-google": "0.14.0", + "eslint-plugin-import": "2.25.4", + "firebase": "9.21.0", + "nodemon": "2.0.20", + "prettier": "2.8.7", + "typescript": "5.0.4" + }, + "private": true +} diff --git a/with-skeet-firebase/openai/skeetOptions.json b/with-skeet-firebase/openai/skeetOptions.json new file mode 100644 index 00000000..ec701951 --- /dev/null +++ b/with-skeet-firebase/openai/skeetOptions.json @@ -0,0 +1,7 @@ +{ + "name": "skeet-app", + "projectId": "skeet-app", + "region": "europe-west6", + "appDomain": "", + "functionsDomain": "" +} diff --git a/with-skeet-firebase/openai/src/index.ts b/with-skeet-firebase/openai/src/index.ts new file mode 100644 index 00000000..56f6763a --- /dev/null +++ b/with-skeet-firebase/openai/src/index.ts @@ -0,0 +1,19 @@ +import admin from 'firebase-admin' +import dotenv from 'dotenv' +import { Request } from 'firebase-functions/v2/https' + +export interface TypedRequestBody extends Request { + body: T +} + +dotenv.config() +admin.initializeApp() + +export { + root, + authOnCreateUser, + createUserChatRoom, + getUserChatRoomMessages, + addUserChatRoomMessage, + addStreamUserChatRoomMessage, +} from '@/routings' diff --git a/with-skeet-firebase/openai/src/lib/getUserAuth.ts b/with-skeet-firebase/openai/src/lib/getUserAuth.ts new file mode 100644 index 00000000..0bb0485d --- /dev/null +++ b/with-skeet-firebase/openai/src/lib/getUserAuth.ts @@ -0,0 +1,32 @@ +import { gravatarIconUrl } from '@/utils/placeholder' +import { auth } from 'firebase-admin' +import { Request } from 'firebase-functions/v2/https' + +export type UserAuthType = { + uid: string + username: string + email: string + iconUrl: string +} + +export const getUserAuth = async (req: Request) => { + try { + const token = req.headers.authorization + if (token == 'undefined' || token == null) throw new Error('Invalid token!') + const bearer = token.split('Bearer ')[1] + const user = await auth().verifyIdToken(bearer) + const { uid, displayName, email, photoURL } = user + const response: UserAuthType = { + uid, + username: displayName || email?.split('@')[0] || '', + email: email || '', + iconUrl: + photoURL == '' || !photoURL + ? gravatarIconUrl(email ?? 'info@skeet.dev') + : photoURL, + } + return response + } catch (error) { + throw new Error(`getUserAuth: ${error}`) + } +} diff --git a/with-skeet-firebase/openai/src/lib/http.ts b/with-skeet-firebase/openai/src/lib/http.ts new file mode 100644 index 00000000..53a2630c --- /dev/null +++ b/with-skeet-firebase/openai/src/lib/http.ts @@ -0,0 +1,28 @@ +import fetch from 'node-fetch' + +export const sendPost = async (url: string, body: T) => { + try { + const response = await fetch(url, { + method: 'POST', + body: JSON.stringify(body), + headers: { 'Content-Type': 'application/json' }, + }) + return response + } catch (e) { + console.log({ e }) + throw new Error(`sendPost failed: ${body}`) + } +} + +export const sendGet = async (url: string) => { + try { + const res = fetch(url, { + method: 'GET', + headers: { 'Content-Type': 'application/json' }, + }) + return res + } catch (e) { + console.log({ e }) + throw new Error('sendGET failed') + } +} diff --git a/with-skeet-firebase/openai/src/lib/index.ts b/with-skeet-firebase/openai/src/lib/index.ts new file mode 100644 index 00000000..c47e3131 --- /dev/null +++ b/with-skeet-firebase/openai/src/lib/index.ts @@ -0,0 +1,4 @@ +export * from './getUserAuth' +export * from './pubsub' +export * from './openai' +export * from './http' diff --git a/with-skeet-firebase/openai/src/lib/openai/generateChatRoomTitle.ts b/with-skeet-firebase/openai/src/lib/openai/generateChatRoomTitle.ts new file mode 100644 index 00000000..eba5f752 --- /dev/null +++ b/with-skeet-firebase/openai/src/lib/openai/generateChatRoomTitle.ts @@ -0,0 +1,62 @@ +import dotenv from 'dotenv' +import { CreateChatCompletionRequest } from 'openai' +import { chat } from './openAi' +dotenv.config() + +export const generateChatRoomTitle = async ( + content: string, + organization: string, + apiKey: string +) => { + try { + const systemContent = systemContentJA + const createChatCompletionRequest: CreateChatCompletionRequest = { + model: 'gpt-3.5-turbo', + max_tokens: 500, + temperature: 0, + n: 1, + top_p: 1, + stream: false, + messages: [ + { + role: 'system', + content: systemContent, + }, + { + role: 'user', + content, + }, + ], + } + const result = await chat(createChatCompletionRequest, organization, apiKey) + if (!result) throw new Error('result not found') + return result.content + } catch (error) { + throw new Error(`generateChatRoomTitle: ${error}`) + } +} + +// const systemContentEN = `### Instructions ### +// Give a title to the content of the message coming from the user. The maximum number of characters for the title is 50 characters. Please make the title as short and descriptive as possible. Do not ask users questions in interrogative sentences. Be sure to respond with only the title. Don't answer questions from users. +// Here are some examples: Never answer user questions. +// Example 1: User's question: [Question] I want to start learning Javascript. +// Answer: How to start learning Javascript +// Example 2: User's question: [Question] Can you write the code to create the file in Javascript? +//      Answer: How to create a file with JavaScript +// Preferred response format: [content] +// :` + +const systemContentJA = `### 指示 ### +ユーザーから来るメッセージの内容にタイトルをつけます。タイトルの文字数は最大で50文字です。できるだけ短くわかりやすいタイトルをつけてください。疑問文でユーザーには質問しないでください。必ずタイトルのみをレスポンスしてください。ユーザーから来る質問には答えてはいけません。 +以下にいくつかの例を示します。絶対にユーザーの質問に答えてはいけません。すべて英語でメッセージが来た場合は英語のタイトルを付けてください。 +<重要>レスポンスはタイトルのみを返してください。 +例1: ユーザーからの質問: [質問]Javascriptの勉強を始めたいのですが、どうすればいいですか? + 答え: Javascriptの勉強の始め方 +例2: ユーザーからの質問: [質問]Javascriptでファイルを作成するコードを書いてくれますか? + 答え: JavaScriptでファイルを作成する方法 +例2: ユーザーからの質問: 今日も1日がんばるぞ! + 答え: 気合表明 +例3: ユーザーからの質問: あなたの今日の予定は? + 答え: 今日の予定 +望ましい回答フォーマット:<文章を要約したタイトル> +<質問>:` diff --git a/with-skeet-firebase/openai/src/lib/openai/index.ts b/with-skeet-firebase/openai/src/lib/openai/index.ts new file mode 100644 index 00000000..6a85df07 --- /dev/null +++ b/with-skeet-firebase/openai/src/lib/openai/index.ts @@ -0,0 +1,3 @@ +export * from './generateChatRoomTitle' +export * from './openAi' +export type { CreateChatCompletionRequest } from 'openai' diff --git a/with-skeet-firebase/openai/src/lib/openai/openAi.ts b/with-skeet-firebase/openai/src/lib/openai/openAi.ts new file mode 100644 index 00000000..11437c1f --- /dev/null +++ b/with-skeet-firebase/openai/src/lib/openai/openAi.ts @@ -0,0 +1,43 @@ +import { Configuration, CreateChatCompletionRequest, OpenAIApi } from 'openai' +import { IncomingMessage } from 'http' + +export const chat = async ( + createChatCompletionRequest: CreateChatCompletionRequest, + organization: string, + apiKey: string +) => { + const configuration = new Configuration({ + organization, + apiKey, + }) + const openai = new OpenAIApi(configuration) + const completion = await openai.createChatCompletion( + createChatCompletionRequest + ) + return completion.data.choices[0].message +} + +export const streamChat = async ( + createChatCompletionRequest: CreateChatCompletionRequest, + organization: string, + apiKey: string +) => { + const configuration = new Configuration({ + organization, + apiKey, + }) + const openai = new OpenAIApi(configuration) + try { + const result = await openai.createChatCompletion( + createChatCompletionRequest, + { + responseType: 'stream', + } + ) + + const stream = result.data as unknown as IncomingMessage + return stream + } catch (error) { + throw new Error(`streamChat error: ${error}`) + } +} diff --git a/with-skeet-firebase/openai/src/lib/pubsub.ts b/with-skeet-firebase/openai/src/lib/pubsub.ts new file mode 100644 index 00000000..df79f189 --- /dev/null +++ b/with-skeet-firebase/openai/src/lib/pubsub.ts @@ -0,0 +1,48 @@ +import { PubSub } from '@google-cloud/pubsub' +import { CloudEvent } from 'firebase-functions/lib/v2/core' +import { MessagePublishedData } from 'firebase-functions/v2/pubsub' + +export const pubsubPublish = async (topicName: string, json: T) => { + try { + const pubsub = new PubSub() + const messageId = await pubsub.topic(topicName).publishMessage({ json }) + console.log(`Message ${messageId} published.`) + return messageId + } catch (error) { + console.log(`pubsubPublish: ${JSON.stringify(error)}`) + throw new Error(JSON.stringify(error)) + } +} + +export const createTopic = async (topicName: string) => { + try { + const pubsub = new PubSub() + const [topic] = await pubsub.createTopic(topicName) + console.log(`Topic ${topic.name} created.`) + return topic.name + } catch (error) { + console.log(`createTopic: ${JSON.stringify(error)}`) + throw new Error(JSON.stringify(error)) + } +} + +export const parsePubSubMessage = ( + event: CloudEvent> +) => { + let pubsubMessage = '' + try { + pubsubMessage = Buffer.from(event.data.message.data, 'base64').toString( + 'utf-8' + ) + } catch (err) { + throw new Error(`Failed to decode pubsub message: ${err}`) + } + + let pubsubObject: T + try { + pubsubObject = JSON.parse(pubsubMessage) + return pubsubObject + } catch (err) { + throw new Error(`Failed to parse pubsub message: ${err}`) + } +} diff --git a/with-skeet-firebase/openai/src/models/index.ts b/with-skeet-firebase/openai/src/models/index.ts new file mode 100644 index 00000000..99e714db --- /dev/null +++ b/with-skeet-firebase/openai/src/models/index.ts @@ -0,0 +1,2 @@ +export * from './userModels' +export * from './lib' diff --git a/with-skeet-firebase/openai/src/models/lib/createUserChatRoomMessage.ts b/with-skeet-firebase/openai/src/models/lib/createUserChatRoomMessage.ts new file mode 100644 index 00000000..6ffd80ab --- /dev/null +++ b/with-skeet-firebase/openai/src/models/lib/createUserChatRoomMessage.ts @@ -0,0 +1,38 @@ +import { + User, + UserChatRoom, + UserChatRoomMessage, + userChatRoomCollectionName, + userChatRoomMessageCollectionName, + userCollectionName, +} from '@/models' +import { Ref, addGrandChildCollectionItem } from '@skeet-framework/firestore' + +export const createUserChatRoomMessage = async ( + userChatRoomRef: Ref, + userId: string, + content: string, + role = 'user' +) => { + try { + const newMessage: UserChatRoomMessage = { + userChatRoomRef, + role, + content, + } + return await addGrandChildCollectionItem< + UserChatRoomMessage, + UserChatRoom, + User + >( + userCollectionName, + userChatRoomCollectionName, + userChatRoomMessageCollectionName, + userId, + userChatRoomRef.id, + newMessage + ) + } catch (error) { + throw new Error(`createUserChatRoomMessage: ${error}`) + } +} diff --git a/with-skeet-firebase/openai/src/models/lib/getMessages.ts b/with-skeet-firebase/openai/src/models/lib/getMessages.ts new file mode 100644 index 00000000..98e284fa --- /dev/null +++ b/with-skeet-firebase/openai/src/models/lib/getMessages.ts @@ -0,0 +1,49 @@ +import { + limit, + order, + queryGrandChildCollectionItem, +} from '@skeet-framework/firestore' +import { + User, + UserChatRoom, + UserChatRoomMessage, + userChatRoomCollectionName, + userChatRoomMessageCollectionName, + userCollectionName, +} from '@/models' +import { ChatCompletionRequestMessage } from 'openai' + +export const getMessages = async ( + userId: string, + userChatRoomId: string, + limitCount?: number +) => { + try { + const query: any[] = [order('createdAt', 'desc')] + if (limitCount) { + query.push(limit(limitCount)) + } + const userChatRoomMessages = await queryGrandChildCollectionItem< + UserChatRoomMessage, + UserChatRoom, + User + >( + userCollectionName, + userChatRoomCollectionName, + userChatRoomMessageCollectionName, + userId, + userChatRoomId, + query + ) + const messages = [] + for await (const message of userChatRoomMessages) { + messages.push({ + role: message.data.role, + content: message.data.content, + } as ChatCompletionRequestMessage) + } + return messages.reverse() + } catch (error) { + throw new Error(`getUserChatRoomMessages: ${error}`) + } +} diff --git a/with-skeet-firebase/openai/src/models/lib/getUserChatRoom.ts b/with-skeet-firebase/openai/src/models/lib/getUserChatRoom.ts new file mode 100644 index 00000000..fbad7884 --- /dev/null +++ b/with-skeet-firebase/openai/src/models/lib/getUserChatRoom.ts @@ -0,0 +1,26 @@ +import { getChildCollectionItem } from '@skeet-framework/firestore' +import { + User, + UserChatRoom, + userChatRoomCollectionName, + userCollectionName, +} from '@/models' + +export const getUserChatRoom = async ( + userId: string, + userChatRoomId: string +) => { + try { + const userChatRoom = await getChildCollectionItem( + userCollectionName, + userChatRoomCollectionName, + userId, + userChatRoomId + ) + if (!userChatRoom) throw new Error('userChatRoom not found') + + return userChatRoom + } catch (error) { + throw new Error(`getUserChatRoom: ${error}`) + } +} diff --git a/with-skeet-firebase/openai/src/models/lib/index.ts b/with-skeet-firebase/openai/src/models/lib/index.ts new file mode 100644 index 00000000..7ce01097 --- /dev/null +++ b/with-skeet-firebase/openai/src/models/lib/index.ts @@ -0,0 +1,3 @@ +export * from './getMessages' +export * from './getUserChatRoom' +export * from './createUserChatRoomMessage' diff --git a/with-skeet-firebase/openai/src/models/userModels.ts b/with-skeet-firebase/openai/src/models/userModels.ts new file mode 100644 index 00000000..9e13cdc1 --- /dev/null +++ b/with-skeet-firebase/openai/src/models/userModels.ts @@ -0,0 +1,40 @@ +import { Ref, Timestamp } from '@skeet-framework/firestore' + +// Define Collection Name +export const userCollectionName = 'User' +export const userChatRoomCollectionName = 'UserChatRoom' +export const userChatRoomMessageCollectionName = 'UserChatRoomMessage' + +// CollectionId: User +// DocumentId: uid +export type User = { + uid: string + username: string + email: string + iconUrl: string + createdAt?: Timestamp + updatedAt?: Timestamp +} + +// CollectionId: UserChatRoom +// DocumentId: auto +export type UserChatRoom = { + userRef: Ref + title: string + model: string + maxTokens: number + temperature: number + stream: boolean + createdAt?: Timestamp + updatedAt?: Timestamp +} + +// CollectionId: UserChatRoomMessage +// DocumentId: auto +export type UserChatRoomMessage = { + userChatRoomRef: Ref + role: string + content: string + createdAt?: Timestamp + updatedAt?: Timestamp +} diff --git a/with-skeet-firebase/openai/src/routings/auth/authOnCreateUser.ts b/with-skeet-firebase/openai/src/routings/auth/authOnCreateUser.ts new file mode 100644 index 00000000..8f418c4a --- /dev/null +++ b/with-skeet-firebase/openai/src/routings/auth/authOnCreateUser.ts @@ -0,0 +1,30 @@ +import { User } from '@/models' +import { addCollectionItem } from '@skeet-framework/firestore' +import * as functions from 'firebase-functions/v1' +import { authPublicOption } from '@/routings' +import { gravatarIconUrl } from '@/utils/placeholder' +import skeetConfig from '../../../skeetOptions.json' +const region = skeetConfig.region + +export const authOnCreateUser = functions + .runWith(authPublicOption) + .region(region) + .auth.user() + .onCreate(async (user) => { + try { + const { uid, email, displayName, photoURL } = user + const userParams = { + uid, + email: email || '', + username: displayName || email?.split('@')[0] || '', + iconUrl: + photoURL == '' || !photoURL + ? gravatarIconUrl(email ?? 'info@skeet.dev') + : photoURL, + } + const userRef = await addCollectionItem('User', userParams, uid) + console.log({ status: 'success', userRef }) + } catch (error) { + console.log({ status: 'error', message: String(error) }) + } + }) diff --git a/with-skeet-firebase/openai/src/routings/auth/index.ts b/with-skeet-firebase/openai/src/routings/auth/index.ts new file mode 100644 index 00000000..f12740fa --- /dev/null +++ b/with-skeet-firebase/openai/src/routings/auth/index.ts @@ -0,0 +1 @@ +export * from './authOnCreateUser' diff --git a/with-skeet-firebase/openai/src/routings/firestore/firestoreExample.ts b/with-skeet-firebase/openai/src/routings/firestore/firestoreExample.ts new file mode 100644 index 00000000..e7877b6f --- /dev/null +++ b/with-skeet-firebase/openai/src/routings/firestore/firestoreExample.ts @@ -0,0 +1,14 @@ +import { onDocumentCreated } from 'firebase-functions/v2/firestore' +import { firestoreDefaultOption } from '@/routings/options' + +export const firestoreExample = onDocumentCreated( + firestoreDefaultOption('User/{userId}'), + (event) => { + console.log('firestoreExample triggered') + try { + console.log(event.params) + } catch (error) { + console.log({ status: 'error', message: String(error) }) + } + } +) diff --git a/with-skeet-firebase/openai/src/routings/firestore/index.ts b/with-skeet-firebase/openai/src/routings/firestore/index.ts new file mode 100644 index 00000000..673633e0 --- /dev/null +++ b/with-skeet-firebase/openai/src/routings/firestore/index.ts @@ -0,0 +1 @@ +export * from './firestoreExample' diff --git a/with-skeet-firebase/openai/src/routings/http/addStreamUserChatRoomMessage.ts b/with-skeet-firebase/openai/src/routings/http/addStreamUserChatRoomMessage.ts new file mode 100644 index 00000000..15cdaafd --- /dev/null +++ b/with-skeet-firebase/openai/src/routings/http/addStreamUserChatRoomMessage.ts @@ -0,0 +1,143 @@ +import { onRequest } from 'firebase-functions/v2/https' +import { TypedRequestBody } from '@/index' +import { updateChildCollectionItem } from '@skeet-framework/firestore' +import { sleep } from '@/utils/time' +import { + getUserAuth, + generateChatRoomTitle, + streamChat, + CreateChatCompletionRequest, +} from '@/lib' +import { publicHttpOption } from '@/routings' +import { AddStreamUserChatRoomMessageParams } from '@/types/http/addStreamUserChatRoomMessageParams' +import { defineSecret } from 'firebase-functions/params' +import { + User, + UserChatRoom, + userChatRoomCollectionName, + userCollectionName, + createUserChatRoomMessage, + getMessages, + getUserChatRoom, +} from '@/models' + +const chatGptOrg = defineSecret('CHAT_GPT_ORG') +const chatGptKey = defineSecret('CHAT_GPT_KEY') + +export const addStreamUserChatRoomMessage = onRequest( + { ...publicHttpOption, secrets: [chatGptOrg, chatGptKey] }, + async (req: TypedRequestBody, res) => { + const organization = chatGptOrg.value() + const apiKey = chatGptKey.value() + + try { + if (!organization || !apiKey) + throw new Error( + `ChatGPT organization or apiKey is empty\nPlease run \`skeet add secret CHAT_GPT_ORG/CHAT_GPT_KEY\`` + ) + + // Get Request Body + const body = { + userChatRoomId: req.body.userChatRoomId || '', + content: req.body.content, + } + if (body.userChatRoomId === '') throw new Error('userChatRoomId is empty') + + // Get User Info from Firebase Auth + const user = await getUserAuth(req) + + // Get UserChatRoom + const userChatRoom = await getUserChatRoom(user.uid, body.userChatRoomId) + if (userChatRoom.data.stream === false) + throw new Error('stream must be true') + + // Add UserChatRoomMessage + await createUserChatRoomMessage(userChatRoom.ref, user.uid, body.content) + + // Get UserChatRoomMessages for OpenAI Request + const messages = await getMessages(user.uid, body.userChatRoomId) + + console.log('messages.length', messages.length) + // Update UserChatRoom Title + if (messages.length === 2) { + const title = await generateChatRoomTitle( + body.content, + organization, + apiKey + ) + await updateChildCollectionItem( + userCollectionName, + userChatRoomCollectionName, + user.uid, + body.userChatRoomId, + { title } + ) + } + + // Send Request to OpenAI + const openAiBody: CreateChatCompletionRequest = { + model: userChatRoom.data.model, + max_tokens: userChatRoom.data.maxTokens, + temperature: userChatRoom.data.temperature, + n: 1, + top_p: 1, + stream: userChatRoom.data.stream, + messages, + } + + // Get OpenAI Stream + const stream = await streamChat( + openAiBody, + chatGptOrg.value(), + chatGptKey.value() + ) + const messageResults: string[] = [] + let streamClosed = false + res.once('error', () => (streamClosed = true)) + res.once('close', () => (streamClosed = true)) + stream.on('data', async (chunk: Buffer) => { + const payloads = chunk.toString().split('\n\n') + for await (const payload of payloads) { + if (payload.includes('[DONE]')) return + if (payload.startsWith('data:')) { + const data = payload.replaceAll(/(\n)?^data:\s*/g, '') + try { + const delta = JSON.parse(data.trim()) + const message = delta.choices[0].delta?.content + if (message == undefined) continue + + console.log(message) + messageResults.push(message) + + while (!streamClosed && res.writableLength > 0) { + await sleep(10) + } + + // Send Message to Client + res.write(JSON.stringify({ text: message })) + } catch (error) { + console.log(`Error with JSON.parse and ${payload}.\n${error}`) + } + } + } + if (streamClosed) res.end('Stream disconnected') + }) + + // Stream End + stream.on('end', async () => { + const message = messageResults.join('') + const lastMessage = await createUserChatRoomMessage( + userChatRoom.ref, + user.uid, + message, + 'assistant' + ) + console.log(`Stream end - messageId: ${lastMessage.id}`) + res.end('Stream done') + }) + stream.on('error', (e: Error) => console.error(e)) + } catch (error) { + res.status(500).json({ status: 'error', message: String(error) }) + } + } +) diff --git a/with-skeet-firebase/openai/src/routings/http/addUserChatRoomMessage.ts b/with-skeet-firebase/openai/src/routings/http/addUserChatRoomMessage.ts new file mode 100644 index 00000000..4cbd2097 --- /dev/null +++ b/with-skeet-firebase/openai/src/routings/http/addUserChatRoomMessage.ts @@ -0,0 +1,88 @@ +import { onRequest } from 'firebase-functions/v2/https' +import { TypedRequestBody } from '@/index' +import { updateChildCollectionItem } from '@skeet-framework/firestore' +import { + chat, + getUserAuth, + generateChatRoomTitle, + CreateChatCompletionRequest, +} from '@/lib' +import { AddUserChatRoomMessageParams } from '@/types/http/addUserChatRoomMessageParams' +import { publicHttpOption } from '@/routings/options' +import { + User, + UserChatRoom, + userChatRoomCollectionName, + userCollectionName, + createUserChatRoomMessage, + getMessages, + getUserChatRoom, +} from '@/models' +import { defineSecret } from 'firebase-functions/params' + +const chatGptOrg = defineSecret('CHAT_GPT_ORG') +const chatGptKey = defineSecret('CHAT_GPT_KEY') + +export const addUserChatRoomMessage = onRequest( + { ...publicHttpOption, secrets: [chatGptOrg, chatGptKey] }, + async (req: TypedRequestBody, res) => { + const organization = chatGptOrg.value() + const apiKey = chatGptKey.value() + try { + if (!organization || !apiKey) + throw new Error('ChatGPT organization or apiKey is empty') + const body = { + userChatRoomId: req.body.userChatRoomId ?? '', + content: req.body.content, + isFirstMessage: req.body.isFirstMessage ?? false, + } + if (body.userChatRoomId === '') throw new Error('userChatRoomId is empty') + const user = await getUserAuth(req) + + const userChatRoom = await getUserChatRoom(user.uid, body.userChatRoomId) + if (!userChatRoom) throw new Error('userChatRoom not found') + if (userChatRoom.data.stream === true) + throw new Error('stream must be false') + + await createUserChatRoomMessage(userChatRoom.ref, user.uid, body.content) + + const messages = await getMessages(user.uid, body.userChatRoomId) + let openAiBody: CreateChatCompletionRequest = { + model: userChatRoom.data.model, + max_tokens: userChatRoom.data.maxTokens, + temperature: userChatRoom.data.temperature, + n: 1, + top_p: 1, + stream: userChatRoom.data.stream, + messages, + } + const openAiResponse = await chat(openAiBody, organization, apiKey) + if (!openAiResponse) throw new Error('openAiResponse not found') + const content = String(openAiResponse.content) || '' + + await createUserChatRoomMessage( + userChatRoom.ref, + user.uid, + content, + 'assistant' + ) + if (messages.length === 3) { + const title = await generateChatRoomTitle( + body.content, + organization, + apiKey + ) + await updateChildCollectionItem( + userCollectionName, + userChatRoomCollectionName, + user.uid, + body.userChatRoomId, + { title } + ) + } + res.json({ status: 'success', openAiResponse }) + } catch (error) { + res.status(500).json({ status: 'error', message: String(error) }) + } + } +) diff --git a/with-skeet-firebase/openai/src/routings/http/createUserChatRoom.ts b/with-skeet-firebase/openai/src/routings/http/createUserChatRoom.ts new file mode 100644 index 00000000..3cad4184 --- /dev/null +++ b/with-skeet-firebase/openai/src/routings/http/createUserChatRoom.ts @@ -0,0 +1,75 @@ +import { onRequest } from 'firebase-functions/v2/https' +import { TypedRequestBody } from '@/index' +import { + User, + UserChatRoom, + userChatRoomCollectionName, + userCollectionName, + createUserChatRoomMessage, +} from '@/models' +import { + addChildCollectionItem, + getCollectionItem, + queryChildCollectionItem, + order, +} from '@skeet-framework/firestore' +import { publicHttpOption } from '@/routings/options' +import { CreateUserChatRoomParams } from '@/types/http/createUserChatRoomParams' +import { getUserAuth } from '@/lib' + +export const createUserChatRoom = onRequest( + publicHttpOption, + async (req: TypedRequestBody, res) => { + try { + const body = { + model: req.body.model || 'gpt-3.5-turbo', + systemContent: + req.body.systemContent || + 'This is a great chatbot. This Assistant is very kind and helpful.', + maxTokens: req.body.maxTokens || 256, + temperature: + req.body.temperature == 0 + ? 0 + : !req.body.temperature + ? 1 + : req.body.temperature, + stream: req.body.stream || true, + } + const user = await getUserAuth(req) + + const userDoc = await getCollectionItem( + userCollectionName, + user.uid + ) + if (!userDoc) throw new Error('userDoc is not found') + console.log(`userDoc: ${userDoc}`) + + const parentId = user.uid || '' + const params: UserChatRoom = { + userRef: userDoc.ref, + title: '', + model: body.model, + maxTokens: body.maxTokens, + temperature: body.temperature, + stream: body.stream, + } + const userChatRoomRef = await addChildCollectionItem( + userCollectionName, + userChatRoomCollectionName, + parentId, + params + ) + console.log(`created userChatRoomRef: ${userChatRoomRef.id}`) + + const userChatRoomMessageRef = await createUserChatRoomMessage( + userChatRoomRef, + user.uid, + body.systemContent, + 'system' + ) + res.json({ status: 'success', userChatRoomRef, userChatRoomMessageRef }) + } catch (error) { + res.status(500).json({ status: 'error', message: String(error) }) + } + } +) diff --git a/with-skeet-firebase/openai/src/routings/http/getUserChatRoomMessages.ts b/with-skeet-firebase/openai/src/routings/http/getUserChatRoomMessages.ts new file mode 100644 index 00000000..2784cc81 --- /dev/null +++ b/with-skeet-firebase/openai/src/routings/http/getUserChatRoomMessages.ts @@ -0,0 +1,26 @@ +import { onRequest } from 'firebase-functions/v2/https' +import { TypedRequestBody } from '@/index' +import { GetUserChatRoomMessagesParams } from '@/types/http/getUserChatRoomParams' +import { publicHttpOption } from '@/routings/options' +import { getUserAuth } from '@/lib/getUserAuth' +import { getMessages } from '@/models/lib/getMessages' + +export const getUserChatRoomMessages = onRequest( + publicHttpOption, + async (req: TypedRequestBody, res) => { + try { + const user = await getUserAuth(req) + const body = { + userId: user.uid, + userChatRoomId: req.body.userChatRoomId, + } + const messages = await getMessages(user.uid, body.userChatRoomId) + res.json({ + status: 'success', + messages, + }) + } catch (error) { + res.status(500).json({ status: 'error', message: String(error) }) + } + } +) diff --git a/with-skeet-firebase/openai/src/routings/http/index.ts b/with-skeet-firebase/openai/src/routings/http/index.ts new file mode 100644 index 00000000..812d53de --- /dev/null +++ b/with-skeet-firebase/openai/src/routings/http/index.ts @@ -0,0 +1,5 @@ +export * from './addUserChatRoomMessage' +export * from './createUserChatRoom' +export * from './getUserChatRoomMessages' +export * from './root' +export * from './addStreamUserChatRoomMessage' \ No newline at end of file diff --git a/with-skeet-firebase/openai/src/routings/http/root.ts b/with-skeet-firebase/openai/src/routings/http/root.ts new file mode 100644 index 00000000..06328d81 --- /dev/null +++ b/with-skeet-firebase/openai/src/routings/http/root.ts @@ -0,0 +1,20 @@ +import { onRequest } from 'firebase-functions/v2/https' +import { publicHttpOption } from '@/routings/options' +import { TypedRequestBody } from '@/index' +import { RootParams } from '@/types/http/rootParams' + +export const root = onRequest( + publicHttpOption, + async (req: TypedRequestBody, res) => { + try { + const body = req.body + res.json({ + status: 'success', + message: 'Skeet Backend is running!', + body, + }) + } catch (error) { + res.status(500).json({ status: 'error', message: String(error) }) + } + } +) diff --git a/with-skeet-firebase/openai/src/routings/index.ts b/with-skeet-firebase/openai/src/routings/index.ts new file mode 100644 index 00000000..99cb0f2e --- /dev/null +++ b/with-skeet-firebase/openai/src/routings/index.ts @@ -0,0 +1,6 @@ +export * from './http' +export * from './pubsub' +export * from './schedule' +export * from './options' +export * from './auth' +export * from './firestore' diff --git a/with-skeet-firebase/openai/src/routings/options/authOptions.ts b/with-skeet-firebase/openai/src/routings/options/authOptions.ts new file mode 100644 index 00000000..793ffa3f --- /dev/null +++ b/with-skeet-firebase/openai/src/routings/options/authOptions.ts @@ -0,0 +1,32 @@ +import { RuntimeOptions } from 'firebase-functions/v1' +import skeetOptions from '../../../skeetOptions.json' + +const appName = skeetOptions.name +const project = skeetOptions.projectId + +const serviceAccount = `${appName}@${project}.iam.gserviceaccount.com` +const vpcConnector = `${appName}-con` + +export const authPublicOption: RuntimeOptions = { + memory: '1GB', + maxInstances: 100, + minInstances: 0, + timeoutSeconds: 300, + labels: { + skeet: 'auth', + }, +} + +export const authPrivateOption: RuntimeOptions = { + memory: '1GB', + maxInstances: 100, + minInstances: 0, + timeoutSeconds: 300, + serviceAccount, + ingressSettings: 'ALLOW_INTERNAL_ONLY', + vpcConnector, + vpcConnectorEgressSettings: 'PRIVATE_RANGES_ONLY', + labels: { + skeet: 'auth', + }, +} diff --git a/with-skeet-firebase/openai/src/routings/options/firestoreOptions.ts b/with-skeet-firebase/openai/src/routings/options/firestoreOptions.ts new file mode 100644 index 00000000..739c9bf3 --- /dev/null +++ b/with-skeet-firebase/openai/src/routings/options/firestoreOptions.ts @@ -0,0 +1,26 @@ +import { DocumentOptions } from 'firebase-functions/v2/firestore' +import skeetOptions from '../../../skeetOptions.json' + +const appName = skeetOptions.name +const project = skeetOptions.projectId +const region = skeetOptions.region +const serviceAccount = `${appName}@${project}.iam.gserviceaccount.com` +const vpcConnector = `${appName}-con` + +export const firestoreDefaultOption = (document: string): DocumentOptions => ({ + document, + region, + cpu: 1, + memory: '1GiB', + maxInstances: 100, + minInstances: 0, + concurrency: 1, + serviceAccount, + ingressSettings: 'ALLOW_INTERNAL_ONLY', + vpcConnector, + vpcConnectorEgressSettings: 'PRIVATE_RANGES_ONLY', + timeoutSeconds: 540, + labels: { + skeet: 'firestore', + }, +}) diff --git a/with-skeet-firebase/openai/src/routings/options/httpOptions.ts b/with-skeet-firebase/openai/src/routings/options/httpOptions.ts new file mode 100644 index 00000000..9f39a4ad --- /dev/null +++ b/with-skeet-firebase/openai/src/routings/options/httpOptions.ts @@ -0,0 +1,40 @@ +import { HttpsOptions } from 'firebase-functions/v2/https' +import skeetOptions from '../../../skeetOptions.json' + +const appName = skeetOptions.name +const project = skeetOptions.projectId +const region = skeetOptions.region +const serviceAccount = `${appName}@${project}.iam.gserviceaccount.com` +const vpcConnector = `${appName}-con` +const cors = true + +export const publicHttpOption: HttpsOptions = { + region, + cpu: 1, + memory: '1GiB', + maxInstances: 100, + minInstances: 0, + concurrency: 1, + timeoutSeconds: 540, + labels: { + skeet: 'http', + }, +} + +export const privateHttpOption: HttpsOptions = { + region, + cpu: 1, + memory: '1GiB', + maxInstances: 100, + minInstances: 0, + concurrency: 80, + serviceAccount, + ingressSettings: 'ALLOW_INTERNAL_AND_GCLB', + vpcConnector, + vpcConnectorEgressSettings: 'PRIVATE_RANGES_ONLY', + cors, + timeoutSeconds: 540, + labels: { + skeet: 'http', + }, +} diff --git a/with-skeet-firebase/openai/src/routings/options/index.ts b/with-skeet-firebase/openai/src/routings/options/index.ts new file mode 100644 index 00000000..bcf98cb9 --- /dev/null +++ b/with-skeet-firebase/openai/src/routings/options/index.ts @@ -0,0 +1,5 @@ +export * from './httpOptions' +export * from './pubsubOptions' +export * from './firestoreOptions' +export * from './scheduleOptions' +export * from './authOptions' diff --git a/with-skeet-firebase/openai/src/routings/options/pubsubOptions.ts b/with-skeet-firebase/openai/src/routings/options/pubsubOptions.ts new file mode 100644 index 00000000..a6497bcd --- /dev/null +++ b/with-skeet-firebase/openai/src/routings/options/pubsubOptions.ts @@ -0,0 +1,26 @@ +import { PubSubOptions } from 'firebase-functions/v2/pubsub' +import skeetOptions from '../../../skeetOptions.json' + +const appName = skeetOptions.name +const project = skeetOptions.projectId +const region = skeetOptions.region +const serviceAccount = `${appName}@${project}.iam.gserviceaccount.com` +const vpcConnector = `${appName}-con` + +export const pubsubDefaultOption = (topic: string): PubSubOptions => ({ + topic, + region, + cpu: 1, + memory: '1GiB', + maxInstances: 100, + minInstances: 0, + concurrency: 1, + serviceAccount, + ingressSettings: 'ALLOW_INTERNAL_ONLY', + vpcConnector, + vpcConnectorEgressSettings: 'PRIVATE_RANGES_ONLY', + timeoutSeconds: 540, + labels: { + skeet: 'pubsub', + }, +}) diff --git a/with-skeet-firebase/openai/src/routings/options/scheduleOptions.ts b/with-skeet-firebase/openai/src/routings/options/scheduleOptions.ts new file mode 100644 index 00000000..49b89cdd --- /dev/null +++ b/with-skeet-firebase/openai/src/routings/options/scheduleOptions.ts @@ -0,0 +1,26 @@ +import { ScheduleOptions } from 'firebase-functions/v2/scheduler' +import skeetOptions from '../../../skeetOptions.json' + +const appName = skeetOptions.name +const project = skeetOptions.projectId +const region = skeetOptions.region +const serviceAccount = `${appName}@${project}.iam.gserviceaccount.com` +const vpcConnector = `${appName}-con` + +export const scheduleDefaultOption: ScheduleOptions = { + region, + schedule: 'every 1 hours', + timeZone: 'UTC', + retryCount: 3, + maxRetrySeconds: 60, + minBackoffSeconds: 1, + maxBackoffSeconds: 10, + serviceAccount, + ingressSettings: 'ALLOW_INTERNAL_ONLY', + vpcConnector, + vpcConnectorEgressSettings: 'PRIVATE_RANGES_ONLY', + timeoutSeconds: 540, + labels: { + skeet: 'schedule', + }, +} diff --git a/with-skeet-firebase/openai/src/routings/pubsub/index.ts b/with-skeet-firebase/openai/src/routings/pubsub/index.ts new file mode 100644 index 00000000..8ccbdd2e --- /dev/null +++ b/with-skeet-firebase/openai/src/routings/pubsub/index.ts @@ -0,0 +1 @@ +export * from './pubsubExample' diff --git a/with-skeet-firebase/openai/src/routings/pubsub/pubsubExample.ts b/with-skeet-firebase/openai/src/routings/pubsub/pubsubExample.ts new file mode 100644 index 00000000..d31ef6e4 --- /dev/null +++ b/with-skeet-firebase/openai/src/routings/pubsub/pubsubExample.ts @@ -0,0 +1,23 @@ +import { onMessagePublished } from 'firebase-functions/v2/pubsub' +import { pubsubDefaultOption } from '@/routings/options' +import { parsePubSubMessage } from '@/lib/pubsub' +import { PubsubExampleParams } from '@/types/pubsub/pubsubExampleParams' + +export const pubsubTopic = 'pubsubExample' + +export const pubsubExample = onMessagePublished( + pubsubDefaultOption(pubsubTopic), + async (event) => { + try { + const pubsubObject = parsePubSubMessage(event) + console.log({ + status: 'success', + topic: pubsubTopic, + event, + pubsubObject, + }) + } catch (error) { + console.error({ status: 'error', message: String(error) }) + } + } +) diff --git a/with-skeet-firebase/openai/src/routings/schedule/index.ts b/with-skeet-firebase/openai/src/routings/schedule/index.ts new file mode 100644 index 00000000..19f22d40 --- /dev/null +++ b/with-skeet-firebase/openai/src/routings/schedule/index.ts @@ -0,0 +1 @@ +export * from './scheduleExample' diff --git a/with-skeet-firebase/openai/src/routings/schedule/scheduleExample.ts b/with-skeet-firebase/openai/src/routings/schedule/scheduleExample.ts new file mode 100644 index 00000000..7bebf9f6 --- /dev/null +++ b/with-skeet-firebase/openai/src/routings/schedule/scheduleExample.ts @@ -0,0 +1,13 @@ +import { onSchedule } from 'firebase-functions/v2/scheduler' +import { scheduleDefaultOption } from '@/routings/options' + +export const scheduleExample = onSchedule( + scheduleDefaultOption, + async (event) => { + try { + console.log({ status: 'success' }) + } catch (error) { + console.log({ status: 'error', message: String(error) }) + } + } +) diff --git a/with-skeet-firebase/openai/src/types/http/addStreamUserChatRoomMessageParams.ts b/with-skeet-firebase/openai/src/types/http/addStreamUserChatRoomMessageParams.ts new file mode 100644 index 00000000..4dcc5417 --- /dev/null +++ b/with-skeet-firebase/openai/src/types/http/addStreamUserChatRoomMessageParams.ts @@ -0,0 +1,5 @@ +export type AddStreamUserChatRoomMessageParams = { + userChatRoomId: string + content: string + isFirstMessage: boolean +} diff --git a/with-skeet-firebase/openai/src/types/http/addUserChatRoomMessageParams.ts b/with-skeet-firebase/openai/src/types/http/addUserChatRoomMessageParams.ts new file mode 100644 index 00000000..0b654672 --- /dev/null +++ b/with-skeet-firebase/openai/src/types/http/addUserChatRoomMessageParams.ts @@ -0,0 +1,6 @@ +// default: {userChatRoomId: 'rooomid', content: 'message'} +export type AddUserChatRoomMessageParams = { + userChatRoomId: string + content: string + isFirstMessage: boolean +} diff --git a/with-skeet-firebase/openai/src/types/http/createUserChatRoomParams.ts b/with-skeet-firebase/openai/src/types/http/createUserChatRoomParams.ts new file mode 100644 index 00000000..5acc3baa --- /dev/null +++ b/with-skeet-firebase/openai/src/types/http/createUserChatRoomParams.ts @@ -0,0 +1,7 @@ +export type CreateUserChatRoomParams = { + model?: string + systemContent?: string + maxTokens?: number + temperature?: number + stream?: boolean +} diff --git a/with-skeet-firebase/openai/src/types/http/getUserChatRoomParams.ts b/with-skeet-firebase/openai/src/types/http/getUserChatRoomParams.ts new file mode 100644 index 00000000..0d2926fd --- /dev/null +++ b/with-skeet-firebase/openai/src/types/http/getUserChatRoomParams.ts @@ -0,0 +1,3 @@ +export type GetUserChatRoomMessagesParams = { + userChatRoomId: string +} diff --git a/with-skeet-firebase/openai/src/types/http/rootParams.ts b/with-skeet-firebase/openai/src/types/http/rootParams.ts new file mode 100644 index 00000000..843ede34 --- /dev/null +++ b/with-skeet-firebase/openai/src/types/http/rootParams.ts @@ -0,0 +1,3 @@ +export type RootParams = { + name?: string +} diff --git a/with-skeet-firebase/openai/src/types/pubsub/pubsubExampleParams.ts b/with-skeet-firebase/openai/src/types/pubsub/pubsubExampleParams.ts new file mode 100644 index 00000000..bc5f18d3 --- /dev/null +++ b/with-skeet-firebase/openai/src/types/pubsub/pubsubExampleParams.ts @@ -0,0 +1,3 @@ +export type PubsubExampleParams = { + name?: string +} diff --git a/with-skeet-firebase/openai/src/utils/base64.ts b/with-skeet-firebase/openai/src/utils/base64.ts new file mode 100644 index 00000000..aec2e149 --- /dev/null +++ b/with-skeet-firebase/openai/src/utils/base64.ts @@ -0,0 +1,5 @@ +const encodeBase64 = async (payload: string) => { + return Buffer.from(payload).toString('base64') +} + +export default encodeBase64 diff --git a/with-skeet-firebase/openai/src/utils/placeholder.ts b/with-skeet-firebase/openai/src/utils/placeholder.ts new file mode 100644 index 00000000..4ef73114 --- /dev/null +++ b/with-skeet-firebase/openai/src/utils/placeholder.ts @@ -0,0 +1,8 @@ +import crypto from 'crypto' + +export function gravatarIconUrl(email: string): string { + const md5Hash = crypto.createHash('md5') + const trimmedEmail = email.trim().toLowerCase() + const hash = md5Hash.update(trimmedEmail).digest('hex') + return `https://www.gravatar.com/avatar/${hash}?d=retro&s=256` +} diff --git a/with-skeet-firebase/openai/src/utils/time.ts b/with-skeet-firebase/openai/src/utils/time.ts new file mode 100644 index 00000000..d89698eb --- /dev/null +++ b/with-skeet-firebase/openai/src/utils/time.ts @@ -0,0 +1,14 @@ +import { format } from 'date-fns' +import { utcToZonedTime } from 'date-fns-tz' + +export function sleep(ms: number) { + return new Promise((resolve) => setTimeout(resolve, ms)) +} + +export const getTimestamp = async () => { + const now = new Date() + const timeZone = 'UTC' + const nowUtc = utcToZonedTime(now, timeZone) + const timestamp = format(nowUtc, 'yyyy-MM-dd:HH:mm:ss') + return timestamp +} diff --git a/with-skeet-firebase/openai/tsconfig.json b/with-skeet-firebase/openai/tsconfig.json new file mode 100644 index 00000000..66bbc013 --- /dev/null +++ b/with-skeet-firebase/openai/tsconfig.json @@ -0,0 +1,27 @@ +{ + "compilerOptions": { + "module": "commonjs", + "noImplicitReturns": true, + "noUnusedLocals": true, + "outDir": "dist", + "target": "ESNext", + "rootDir": ".", + "strict": true, + "moduleResolution": "node", + "baseUrl": ".", + "esModuleInterop": true, + "experimentalDecorators": true, + "emitDecoratorMetadata": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "isolatedModules": true, + "resolveJsonModule": true, + "lib": ["esnext"], + "sourceMap": true, + "paths": { + "@/*": ["src/*"] + } + }, + "compileOnSave": true, + "include": ["src/*", "src/**/*"] +} diff --git a/with-skeet-firebase/package.json b/with-skeet-firebase/package.json new file mode 100644 index 00000000..0aa6fb1e --- /dev/null +++ b/with-skeet-firebase/package.json @@ -0,0 +1,119 @@ +{ + "name": "skeet-app", + "version": "1.0.0", + "description": "Skeet Framework Boilerplate", + "author": "ELSOUL LABO B.V.", + "license": "Apache-2.0", + "private": false, + "scripts": { + "format": "prettier --write --ignore-unknown .", + "lint": "eslint --ext .ts,.tsx --fix .", + "test": "jest --coverage=false --detectOpenHandles --maxWorkers=1", + "typecheck": "tsc --noEmit", + "skeet": "run-p skeet:*", + "skeet:openai": "yarn --cwd functions/openai dev", + "skeet:dev": "firebase emulators:start", + "skeet:webapp": "yarn webapp", + "skeet:webappBuildForEmulator": "yarn build:development:webapp", + "functions:build": "yarn --cwd functions/openai build", + "functions:dev": "firebase emulators:start --only functions", + "functions:deploy": "firebase deploy --only functions", + "dev": "EXPO_USE_PATH_ALIASES=1 expo start -c", + "android": "EXPO_USE_PATH_ALIASES=1 expo start --android", + "ios": "EXPO_USE_PATH_ALIASES=1 expo start --ios", + "webapp": "EXPO_USE_PATH_ALIASES=1 expo start --web", + "update:packages": "ncu -x 'react-native-reanimated,react-native-svg' && yarn", + "build:configure": "eas build:configure", + "build:development:ios": "eas build --platform ios --profile development", + "build:development:android": "eas build --platform android --profile development", + "build:development:webapp": "expo export:web --dev", + "build:preview:ios": "eas build --platform ios --profile preview", + "build:preview:android": "eas build --platform android --profile preview", + "build:production:ios": "eas build --platform ios --profile production", + "build:production:android": "eas build --platform android --profile production", + "build:production:webapp": "expo export:web", + "deploy:android": "eas submit --platform android", + "deploy:webapp": "yarn build:production:webapp && npx firebase deploy --only hosting", + "deploy:rules": "npx firebase deploy --only firestore:rules,storage", + "check:webapp": "npx serve web-build", + "start": "EXPO_USE_PATH_ALIASES=1 expo start -c" + }, + "dependencies": { + "@expo-google-fonts/noto-sans-jp": "0.2.3", + "@expo-google-fonts/outfit": "0.2.3", + "@react-native-async-storage/async-storage": "1.17.11", + "@react-native-community/cli-platform-android": "10.2.0", + "@react-native-community/cli-platform-ios": "10.2.0", + "@react-navigation/native": "6.1.6", + "@react-navigation/native-stack": "6.9.12", + "@rivascva/react-native-code-editor": "1.2.2", + "@skeet-framework/firestore": "1.0.9", + "clsx": "1.2.1", + "date-fns": "2.30.0", + "dotenv": "16.0.3", + "expo": "48.0.5", + "expo-checkbox": "2.3.1", + "expo-font": "11.1.1", + "expo-image": "1.0.1", + "expo-image-picker": "14.1.1", + "expo-linking": "4.0.1", + "firebase": "9.17.2", + "framer-motion": "10.3.4", + "i18next": "22.4.10", + "react": "18.2.0", + "react-dom": "18.2.0", + "react-i18next": "12.2.0", + "react-native": "0.71.8", + "react-native-gesture-handler": "2.9.0", + "react-native-heroicons": "3.2.0", + "react-native-popup-menu": "0.16.1", + "react-native-reanimated": "2.14.4", + "react-native-safe-area-context": "4.5.0", + "react-native-screens": "3.20.0", + "react-native-svg": "13.4.0", + "react-native-svg-transformer": "1.0.0", + "react-native-toast-message": "2.1.6", + "react-native-utilities": "0.1.7", + "react-native-web": "0.18.12", + "recoil": "0.7.7", + "text-encoding": "0.7.0", + "twrnc": "3.6.1", + "typesaurus": "7", + "zod": "3.21.4" + }, + "devDependencies": { + "@babel/core": "7.21.0", + "@expo/metro-config": "0.7.1", + "@expo/webpack-config": "18.0.1", + "@svgr/webpack": "6.5.1", + "@types/jest": "29.5.0", + "@types/node": "18.16.0", + "@types/node-fetch": "2.6.2", + "@types/react": "18.0.28", + "@types/react-dom": "18.0.11", + "@types/text-encoding": "0.0.36", + "@typescript-eslint/eslint-plugin": "5.54.0", + "@typescript-eslint/parser": "5.54.0", + "babel-loader": "9.1.2", + "babel-plugin-module-resolver": "5.0.0", + "eas-cli": "3.7.2", + "esbuild": "0.17.14", + "eslint": "8.36.0", + "eslint-config-prettier": "8.8.0", + "eslint-plugin-react": "7.32.2", + "eslint-plugin-react-hooks": "4.6.0", + "eslint-plugin-react-native": "4.0.0", + "firebase-tools": "12.0.1", + "jest": "29.3.1", + "nodemon": "2.0.22", + "npm-check-updates": "16.8.0", + "npm-run-all": "4.1.5", + "prettier": "2.8.8", + "prettier-plugin-tailwindcss": "0.2.4", + "tailwindcss": "3.3.1", + "ts-jest": "29.0.5", + "ts-loader": "9.4.2", + "tsconfig-paths": "4.1.2", + "typescript": "4.9.4" + } +} diff --git a/with-skeet-firebase/skeet-cloud.config.json b/with-skeet-firebase/skeet-cloud.config.json new file mode 100644 index 00000000..461925fc --- /dev/null +++ b/with-skeet-firebase/skeet-cloud.config.json @@ -0,0 +1,40 @@ +{ + "app": { + "name": "skeet-app", + "projectId": "skeet-app", + "region": "europe-west6", + "appDomain": "", + "functionsDomain": "", + "secretKeys": [] + }, + "cloudArmor": [ + { + "securityPolicyName": "skeet-skeet-app-armor", + "rules": [ + { + "priority": "10", + "description": "Allow Your Home IP addresses", + "options": { + "src-ip-ranges": "x.x.x.x", + "action": "allow" + } + }, + { + "priority": "300", + "description": "Defense from NodeJS attack", + "options": { + "action": "deny-403", + "expression": "evaluatePreconfiguredExpr('nodejs-v33-stable')" + } + }, + { + "priority": "2147483647", + "description": "Deny All IP addresses", + "options": { + "action": "deny-403" + } + } + ] + } + ] +} diff --git a/with-skeet-firebase/src/components/common/atoms/Button.tsx b/with-skeet-firebase/src/components/common/atoms/Button.tsx new file mode 100644 index 00000000..98f16e67 --- /dev/null +++ b/with-skeet-firebase/src/components/common/atoms/Button.tsx @@ -0,0 +1,136 @@ +import type { ReactNode } from 'react' +import clsx from 'clsx' +import tw from '@/lib/tailwind' +import { Pressable, StyleProp, ViewStyle } from 'react-native' + +const baseStyles = { + solid: + 'items-center justify-center py-2 px-4 font-semibold focus:outline-none focus-visible:outline-2 focus-visible:outline-offset-2', + outline: 'ring-1 items-center justify-center py-2 px-4 focus:outline-none', +} + +const variantStyles = { + solid: { + gray: 'bg-gray-900 text-white dark:bg-gray-500 dark:hover:bg-gray-400 hover:bg-gray-700 hover:text-gray-50 active:bg-gray-800 active:text-gray-50 focus-visible:outline-gray-900', + blue: 'bg-blue-600 text-white hover:text-blue-50 hover:bg-blue-500 active:bg-blue-800 active:text-blue-100 focus-visible:outline-blue-600', + red: 'bg-red-600 text-white hover:text-red-50 hover:bg-red-500 active:bg-red-800 active:text-red-100 focus-visible:outline-red-600', + green: + 'bg-green-600 text-white hover:text-green-50 hover:bg-green-500 active:bg-green-800 active:text-green-100 focus-visible:outline-green-600', + yellow: + 'bg-yellow-600 text-white hover:text-yellow-50 hover:bg-yellow-500 active:bg-yellow-800 active:text-yellow-100 focus-visible:outline-yellow-600', + purple: + 'bg-purple-600 text-white hover:text-purple-50 hover:bg-purple-500 active:bg-purple-800 active:text-purple-100 focus-visible:outline-purple-600', + orange: + 'bg-orange-600 text-white hover:text-orange-50 hover:bg-orange-500 active:bg-orange-800 active:text-orange-100 focus-visible:outline-orange-600', + amber: + 'bg-amber-600 text-white hover:text-amber-50 hover:bg-amber-500 active:bg-amber-800 active:text-amber-100 focus-visible:outline-amber-600', + pink: 'bg-pink-600 text-white hover:text-pink-50 hover:bg-pink-500 active:bg-pink-800 active:text-pink-100 focus-visible:outline-pink-600', + indigo: + 'bg-indigo-600 text-white hover:text-indigo-50 hover:bg-indigo-500 active:bg-indigo-800 active:text-indigo-100 focus-visible:outline-indigo-600', + + white: + 'bg-white text-gray-900 hover:bg-blue-50 active:bg-blue-200 active:text-blue-700 focus-visible:outline-white', + black: + 'bg-gray-900 text-white hover:bg-gray-500 active:bg-gray-700 active:text-bg-gray-100 focus-visible:outline-white', + }, + outline: { + gray: 'ring-gray-200 text-gray-700 dark:text-gray-100 dark:ring-gray-100 dark:hover:text-gray-50 dark:hover:ring-gray-50 hover:text-gray-900 hover:ring-gray-400 active:bg-gray-100 dark:active:bg-gray-500 active:text-gray-700 focus-visible:outline-blue-600 focus-visible:ring-gray-300', + blue: 'ring-blue-200 text-blue-700 dark:text-blue-200 hover:text-blue-500 hover:ring-blue-400 dark:hover:ring-blue-100 dark:hover:text-blue-100 active:bg-blue-100 active:text-blue-700 dark:active:bg-blue-400 focus-visible:outline-blue-600 focus-visible:ring-blue-300', + red: 'ring-red-200 text-red-700 dark:text-red-200 hover:text-red-500 hover:ring-red-400 dark:hover:ring-red-100 dark:hover:text-red-100 active:bg-red-100 active:text-red-700 dark:active:bg-red-400 focus-visible:outline-red-600 focus-visible:ring-red-300', + green: + 'ring-green-200 text-green-700 dark:text-green-200 hover:text-green-500 hover:ring-green-400 dark:hover:ring-green-100 dark:hover:text-green-100 active:bg-green-100 active:text-green-700 dark:active:bg-green-400 focus-visible:outline-green-600 focus-visible:ring-green-300', + yellow: + 'ring-yellow-200 text-yellow-700 dark:text-yellow-200 hover:text-yellow-500 hover:ring-yellow-400 dark:hover:ring-yellow-100 dark:hover:text-yellow-100 active:bg-yellow-100 active:text-yellow-700 dark:active:bg-yellow-400 focus-visible:outline-yellow-600 focus-visible:ring-yellow-300', + purple: + 'ring-purple-200 text-purple-700 dark:text-purple-200 hover:text-purple-500 hover:ring-purple-400 dark:hover:ring-purple-100 dark:hover:text-purple-100 active:bg-purple-100 active:text-purple-700 dark:active:bg-purple-400 focus-visible:outline-purple-600 focus-visible:ring-purple-300', + orange: + 'ring-orange-200 text-orange-700 dark:text-orange-200 hover:text-orange-500 hover:ring-orange-400 dark:hover:ring-orange-100 dark:hover:text-orange-100 active:bg-orange-100 active:text-orange-700 dark:active:bg-orange-400 focus-visible:outline-orange-600 focus-visible:ring-orange-300', + amber: + 'ring-amber-200 text-amber-700 dark:text-amber-200 hover:text-amber-500 hover:ring-amber-400 dark:hover:ring-amber-100 dark:hover:text-amber-100 active:bg-amber-100 active:text-amber-700 dark:active:bg-amber-400 focus-visible:outline-amber-600 focus-visible:ring-amber-300', + pink: 'ring-pink-200 text-pink-700 dark:text-pink-200 hover:text-pink-500 hover:ring-pink-400 dark:hover:ring-pink-100 dark:hover:text-pink-100 active:bg-pink-100 active:text-pink-700 dark:active:bg-pink-400 focus-visible:outline-pink-600 focus-visible:ring-pink-300', + indigo: + 'ring-indigo-200 text-indigo-700 dark:text-indigo-200 hover:text-indigo-500 hover:ring-indigo-400 dark:hover:ring-indigo-100 dark:hover:text-indigo-100 active:bg-indigo-100 active:text-indigo-700 dark:active:bg-indigo-400 focus-visible:outline-indigo-600 focus-visible:ring-indigo-300', + white: + 'ring-gray-200 text-white hover:ring-gray-500 hover:text-gray-100 active:ring-gray-700 active:text-gray-400 focus-visible:outline-white', + black: + 'ring-gray-900 text-gray-900 hover:ring-gray-500 hover:text-gray-500 active:ring-gray-700 active:text-gray-700 focus-visible:outline-white', + }, +} + +const pressedStyles = { + solid: { + gray: 'bg-gray-700 text-gray-50 dark:bg-gray-700', + blue: 'bg-blue-700 text-blue-100 dark:bg-blue-700', + red: 'bg-red-700 text-red-100 dark:bg-red-700', + green: 'bg-green-700 text-green-100 dark:bg-green-700', + yellow: 'bg-yellow-700 text-yellow-100 dark:bg-yellow-700', + purple: 'bg-purple-700 text-purple-100 dark:bg-purple-700', + orange: 'bg-orange-700 text-orange-100 dark:bg-orange-700', + amber: 'bg-amber-700 text-amber-100 dark:bg-amber-700', + pink: 'bg-pink-700 text-pink-100 dark:bg-pink-700', + indigo: 'bg-indigo-700 text-indigo-100 dark:bg-indigo-700', + white: 'bg-blue-200 text-blue-700', + black: 'bg-gray-700 text-bg-gray-100', + }, + outline: { + gray: 'bg-gray-100 dark:bg-gray-500 text-gray-700', + blue: 'bg-blue-100 text-blue-700 dark:bg-blue-400', + red: 'bg-red-100 text-red-700 dark:bg-red-400', + green: 'bg-green-100 text-green-700 dark:bg-green-400', + yellow: 'bg-yellow-100 text-yellow-700 dark:bg-yellow-400', + purple: 'bg-purple-100 text-purple-700 dark:bg-purple-400', + orange: 'bg-orange-100 text-orange-700 dark:bg-orange-400', + amber: 'bg-amber-100 text-amber-700 dark:bg-amber-400', + pink: 'bg-pink-100 text-pink-700 dark:bg-pink-400', + indigo: 'bg-indigo-100 text-indigo-700 dark:bg-indigo-400', + white: 'ring-gray-700 text-gray-400 ', + black: 'ring-gray-700 text-gray-700', + }, +} + +type Props = { + children: ReactNode | string + variant?: 'solid' | 'outline' + color?: + | 'gray' + | 'blue' + | 'red' + | 'green' + | 'yellow' + | 'purple' + | 'orange' + | 'amber' + | 'pink' + | 'indigo' + | 'white' + | 'black' + className?: string + disabled?: boolean + onPress?: () => void +} + +export default function Button({ + variant = 'solid', + color = 'gray', + className, + children, + ...props +}: Props) { + return ( + <> + + tw`${clsx( + baseStyles[variant], + variantStyles[variant][color], + className, + pressed ? pressedStyles[variant][color] : '' + )}` + } + {...props} + > + {children} + + + ) +} diff --git a/with-skeet-firebase/src/components/common/atoms/LogoHorizontal.tsx b/with-skeet-firebase/src/components/common/atoms/LogoHorizontal.tsx new file mode 100644 index 00000000..ee5e48cd --- /dev/null +++ b/with-skeet-firebase/src/components/common/atoms/LogoHorizontal.tsx @@ -0,0 +1,21 @@ +import tw from '@/lib/tailwind' +import SkeetLogoHorizontal from '@assets/logo/SkeetLogoHorizontal.svg' +import SkeetLogoHorizontalInvert from '@assets/logo/SkeetLogoHorizontalInvert.svg' +import clsx from 'clsx' + +type Props = { + className?: string +} + +export default function LogoHorizontal({ className }: Props) { + return ( + <> + + + + ) +} diff --git a/with-skeet-firebase/src/components/error/InvalidParamsError.tsx b/with-skeet-firebase/src/components/error/InvalidParamsError.tsx new file mode 100644 index 00000000..590561b9 --- /dev/null +++ b/with-skeet-firebase/src/components/error/InvalidParamsError.tsx @@ -0,0 +1,45 @@ +import tw from '@/lib/tailwind' +import { useTranslation } from 'react-i18next' +import { View, Text } from 'react-native' +import Button from '@/components/common/atoms/Button' +import { useNavigation } from '@react-navigation/native' + +export default function InvalidParamsError() { + const { t } = useTranslation() + const navigation = useNavigation() + return ( + <> + + + + + {t('invalidParamsErrorTitle')} + + + {t('invalidParamsErrorBody')} + + + + + + + + + ) +} diff --git a/with-skeet-firebase/src/components/loading/ActionLoading.tsx b/with-skeet-firebase/src/components/loading/ActionLoading.tsx new file mode 100644 index 00000000..96a41614 --- /dev/null +++ b/with-skeet-firebase/src/components/loading/ActionLoading.tsx @@ -0,0 +1,16 @@ +import tw from '@/lib/tailwind' +import { View } from 'react-native' + +export default function ActionLoading() { + return ( + <> + + + + + + + + + ) +} diff --git a/with-skeet-firebase/src/components/loading/AppLoading.tsx b/with-skeet-firebase/src/components/loading/AppLoading.tsx new file mode 100644 index 00000000..23dedc7c --- /dev/null +++ b/with-skeet-firebase/src/components/loading/AppLoading.tsx @@ -0,0 +1,14 @@ +import tw, { colors } from '@/lib/tailwind' +import { View, ActivityIndicator } from 'react-native' + +export default function AppLoading() { + return ( + <> + + + + + ) +} diff --git a/with-skeet-firebase/src/components/loading/ChatMenuLoading.tsx b/with-skeet-firebase/src/components/loading/ChatMenuLoading.tsx new file mode 100644 index 00000000..d70ed8c7 --- /dev/null +++ b/with-skeet-firebase/src/components/loading/ChatMenuLoading.tsx @@ -0,0 +1,18 @@ +import tw from '@/lib/tailwind' +import { View } from 'react-native' + +export default function ChatMenuLoading() { + return ( + <> + + + + + + + + + ) +} diff --git a/with-skeet-firebase/src/components/screens/default/action/ResetPasswordAction.tsx b/with-skeet-firebase/src/components/screens/default/action/ResetPasswordAction.tsx new file mode 100644 index 00000000..894ffd56 --- /dev/null +++ b/with-skeet-firebase/src/components/screens/default/action/ResetPasswordAction.tsx @@ -0,0 +1,174 @@ +import ActionLoading from '@/components/loading/ActionLoading' +import tw from '@/lib/tailwind' +import { View, Text } from 'react-native' +import { useCallback, useEffect, useState, useMemo } from 'react' +import { useNavigation } from '@react-navigation/native' +import { useTranslation } from 'react-i18next' +import Toast from 'react-native-toast-message' +import { auth } from '@/lib/firebase' +import { confirmPasswordReset, verifyPasswordResetCode } from 'firebase/auth' +import Button from '@/components/common/atoms/Button' +import { passwordSchema } from '@/utils/form' +import LogoHorizontal from '@/components/common/atoms/LogoHorizontal' +import { TextInput } from 'react-native-gesture-handler' +import clsx from 'clsx' + +type Props = { + oobCode: string +} + +export default function ResetPasswordAction({ oobCode }: Props) { + const [isLoading, setLoading] = useState(true) + const [isRegisterLoading, setRegisterLoading] = useState(false) + const { t } = useTranslation() + const navigation = useNavigation() + const [email, setEmail] = useState('') + + const [password, setPassword] = useState('') + const [passwordError, setPasswordError] = useState('') + const validatePassword = useCallback(() => { + try { + passwordSchema.parse(password) + setPasswordError('') + } catch (err) { + setPasswordError('passwordErrorText') + } + }, [password, setPasswordError]) + + useEffect(() => { + if (password.length > 0) validatePassword() + }, [password, validatePassword]) + + const verifyEmail = useCallback(async () => { + try { + if (!auth) throw new Error('auth not initialized') + const gotEmail = await verifyPasswordResetCode(auth, oobCode) + setEmail(gotEmail) + setLoading(false) + } catch (err) { + console.error(err) + Toast.show({ + type: 'error', + text1: t('verifyErrorTitle') ?? 'Verify Error', + text2: + t('verifyErrorBody') ?? + 'Something went wrong... Please try it again later.', + }) + navigation.navigate('Login') + } + }, [navigation, t, oobCode]) + + useEffect(() => { + verifyEmail() + }, [verifyEmail]) + + const resetPassword = useCallback(async () => { + try { + setRegisterLoading(true) + if (!auth) throw new Error('auth not initialized') + await confirmPasswordReset(auth, oobCode, password) + Toast.show({ + type: 'success', + text1: t('resetPasswordSuccessTitle') ?? 'Reset Success', + text2: + t('resetPasswordSuccessBody') ?? + 'Your new password has been registered. Please sign in with it.', + }) + navigation.navigate('Login') + } catch (err) { + console.error(err) + Toast.show({ + type: 'error', + text1: t('resetPasswordErrorTitle') ?? 'Reset Error', + text2: + t('resetPasswordErrorBody') ?? + 'Something went wrong... Please try it again later.', + }) + } finally { + setRegisterLoading(false) + } + }, [oobCode, password, t, navigation]) + + const isDisabled = useMemo( + () => + isLoading || + passwordError != '' || + email == '' || + password == '' || + isRegisterLoading, + [isLoading, passwordError, email, password, isRegisterLoading] + ) + + if (isLoading) { + return ( + <> + + + ) + } + + return ( + <> + + + + + {t('inputNewPassword')} + + + {email} + + + + + + + {t('password')} + {passwordError !== '' && ( + + {' : '} + {t(passwordError)} + + )} + + + + + + + + + + + + + ) +} diff --git a/with-skeet-firebase/src/components/screens/default/action/VerifyEmailAction.tsx b/with-skeet-firebase/src/components/screens/default/action/VerifyEmailAction.tsx new file mode 100644 index 00000000..2cc47611 --- /dev/null +++ b/with-skeet-firebase/src/components/screens/default/action/VerifyEmailAction.tsx @@ -0,0 +1,91 @@ +import ActionLoading from '@/components/loading/ActionLoading' +import tw from '@/lib/tailwind' +import { View, Text } from 'react-native' +import { useCallback, useEffect, useState } from 'react' +import { useNavigation } from '@react-navigation/native' +import { useTranslation } from 'react-i18next' +import Toast from 'react-native-toast-message' +import { auth } from '@/lib/firebase' +import { applyActionCode } from 'firebase/auth' +import Button from '@/components/common/atoms/Button' +import { CheckCircleIcon } from 'react-native-heroicons/outline' + +type Props = { + oobCode: string +} + +export default function VerifyEmailAction({ oobCode }: Props) { + const [isLoading, setLoading] = useState(true) + const { t } = useTranslation() + const navigation = useNavigation() + + const verifyUser = useCallback(async () => { + try { + if (!auth) throw new Error('auth not initialized') + await applyActionCode(auth, oobCode) + Toast.show({ + type: 'success', + text1: t('verifySuccessTitle') ?? 'Verify Success', + text2: t('verifySuccessBody') ?? 'Welcome to Skeet App Template🎉', + }) + setLoading(false) + } catch (err) { + console.error(err) + Toast.show({ + type: 'error', + text1: t('verifyErrorTitle') ?? 'Verify Error', + text2: + t('verifyErrorBody') ?? + 'Something went wrong... Please try it again later.', + }) + navigation.navigate('Login') + } + }, [navigation, t, oobCode]) + + useEffect(() => { + verifyUser() + }, [verifyUser]) + + if (isLoading) { + return ( + <> + + + ) + } + + return ( + <> + + + + + {t('confirmDoneTitle')} + + + {t('confirmDoneBody')} + + + + + + + + ) +} diff --git a/with-skeet-firebase/src/components/screens/user/openAiChat/ChatBox.tsx b/with-skeet-firebase/src/components/screens/user/openAiChat/ChatBox.tsx new file mode 100644 index 00000000..cc4bbb32 --- /dev/null +++ b/with-skeet-firebase/src/components/screens/user/openAiChat/ChatBox.tsx @@ -0,0 +1,493 @@ +import tw from '@/lib/tailwind' +import clsx from 'clsx' +import { View, Text, Pressable } from 'react-native' +import { useTranslation } from 'react-i18next' +import { + PaperAirplaneIcon, + PencilSquareIcon, + PlusCircleIcon, + XMarkIcon, +} from 'react-native-heroicons/outline' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { useRecoilValue } from 'recoil' +import { userState } from '@/store/user' +import { auth, db } from '@/lib/firebase' +import { + collection, + doc, + getDoc, + getDocs, + orderBy, + query, +} from 'firebase/firestore' +import { ScrollView, TextInput } from 'react-native-gesture-handler' +import { chatContentSchema } from '@/utils/form' +import Toast from 'react-native-toast-message' +import { fetchSkeetFunctions } from '@/lib/skeet' +import { Image } from 'expo-image' +import { blurhash } from '@/utils/placeholder' +import { ChatRoom } from './ChatMenu' +import { AddStreamUserChatRoomMessageParams } from '@/types/http/openai/addStreamUserChatRoomMessageParams' +import CodeEditor, { + CodeEditorSyntaxStyles, +} from '@rivascva/react-native-code-editor' +import { signOut } from 'firebase/auth' +import { TextDecoder } from 'text-encoding' + +type ChatMessage = { + id: string + role: string + createdAt: string + updatedAt: string + content: string + viewWithCodeEditor: boolean +} + +type Props = { + setNewChatModalOpen: (_value: boolean) => void + currentChatRoomId: string | null + getChatRooms: () => void +} + +export default function ChatBox({ + setNewChatModalOpen, + currentChatRoomId, + getChatRooms, +}: Props) { + const { t } = useTranslation() + const user = useRecoilValue(userState) + const [chatMessages, setChatMessages] = useState([]) + const [chatRoom, setChatRoom] = useState(null) + const scrollViewRef = useRef(null) + const scrollToEnd = useCallback(() => { + if (currentChatRoomId) { + scrollViewRef.current?.scrollToEnd({ animated: false }) + } + }, [scrollViewRef, currentChatRoomId]) + const [isFirstMessage, setFirstMessage] = useState(true) + + const getChatRoom = useCallback(async () => { + if (db && user.uid && currentChatRoomId) { + const docRef = doc( + db, + `User/${user.uid}/UserChatRoom/${currentChatRoomId}` + ) + const docSnap = await getDoc(docRef) + if (docSnap.exists()) { + const data = docSnap.data() + if (data.title !== '') { + setFirstMessage(false) + } + setChatRoom({ id: docSnap.id, ...data } as ChatRoom) + } else { + console.log('No such document!') + } + } + }, [currentChatRoomId, user.uid]) + + useEffect(() => { + getChatRoom() + }, [getChatRoom]) + + const [isSending, setSending] = useState(false) + + const getUserChatRoomMessage = useCallback(async () => { + if (db && user.uid && currentChatRoomId) { + const q = query( + collection( + db, + `User/${user.uid}/UserChatRoom/${currentChatRoomId}/UserChatRoomMessage` + ), + orderBy('createdAt', 'asc') + ) + const querySnapshot = await getDocs(q) + const messages: ChatMessage[] = [] + querySnapshot.forEach((doc) => { + const data = doc.data() + messages.push({ + id: doc.id, + viewWithCodeEditor: false, + ...data, + } as ChatMessage) + }) + setChatMessages(messages) + } + }, [currentChatRoomId, user.uid]) + + useEffect(() => { + getUserChatRoomMessage() + }, [getUserChatRoomMessage]) + + useEffect(() => { + if (chatMessages.length > 0) { + scrollToEnd() + } + }, [chatMessages, scrollToEnd]) + + const [chatContent, setChatContent] = useState('') + const chatContentLines = useMemo(() => { + return (chatContent.match(/\n/g) || []).length + 1 + }, [chatContent]) + const [chatContentError, setChatContentError] = useState('') + const validateChatContent = useCallback(() => { + try { + chatContentSchema.parse(chatContent) + setChatContentError('') + } catch (err) { + setChatContentError('chatContentErrorText') + } + }, [chatContent, setChatContentError]) + + useEffect(() => { + if (chatContent) validateChatContent() + }, [chatContent, validateChatContent]) + + const isChatMessageDisabled = useMemo(() => { + return isSending || chatContent == '' || chatContentError != '' + }, [isSending, chatContent, chatContentError]) + + const chatMessageSubmit = useCallback(async () => { + try { + if (!isChatMessageDisabled && user.uid && currentChatRoomId) { + setSending(true) + setChatMessages((prev) => { + prev.push({ + id: `UserSendingMessage${new Date().toISOString()}`, + role: 'user', + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + content: chatContent, + viewWithCodeEditor: false, + }) + prev.push({ + id: `AssistantAnsweringMessage${new Date().toISOString()}`, + role: 'assistant', + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + content: '', + viewWithCodeEditor: false, + }) + return [...prev] + }) + const res = + await fetchSkeetFunctions( + 'openai', + 'addStreamUserChatRoomMessage', + { + userChatRoomId: currentChatRoomId, + content: chatContent, + isFirstMessage, + } + ) + const reader = await res?.body?.getReader() + const decoder = new TextDecoder('utf-8') + + const readChunk = async () => { + return reader?.read().then(({ value, done }): any => { + try { + if (!done) { + const dataString = decoder.decode(value) + if (dataString != 'Stream done') { + const data = JSON.parse(dataString) + setChatMessages((prev) => { + prev[prev.length - 1].content = + prev[prev.length - 1].content + data.text + return [...prev] + }) + } + } else { + // done + } + } catch (error) { + console.log(error) + } + if (!done) { + return readChunk() + } + }) + } + await readChunk() + + if (chatRoom && chatRoom.title == '') { + await getChatRoom() + await getChatRooms() + } + await getUserChatRoomMessage() + setChatContent('') + setFirstMessage(false) + } else { + throw new Error('validateError') + } + } catch (err) { + console.error(err) + if ( + err instanceof Error && + (err.message.includes('Firebase ID token has expired.') || + err.message.includes('Error: getUserAuth')) + ) { + Toast.show({ + type: 'error', + text1: t('errorTokenExpiredTitle') ?? 'Token Expired.', + text2: t('errorTokenExpiredBody') ?? 'Please sign in again.', + }) + if (auth) { + signOut(auth) + } + } else { + Toast.show({ + type: 'error', + text1: t('errorTitle') ?? 'Error', + text2: + t('errorBody') ?? 'Something went wrong... Please try it again.', + }) + } + } finally { + setSending(false) + } + }, [ + isChatMessageDisabled, + t, + chatContent, + currentChatRoomId, + user.uid, + setFirstMessage, + isFirstMessage, + chatRoom, + getChatRoom, + getUserChatRoomMessage, + getChatRooms, + ]) + + const viewWithCodeEditor = useCallback( + (itemId: string, itemBoolean: boolean) => { + const updatedChatMessages = [...chatMessages] + const updatedItemIndex = updatedChatMessages.findIndex( + (message) => message.id === itemId + ) + if (updatedItemIndex !== -1) { + updatedChatMessages[updatedItemIndex].viewWithCodeEditor = itemBoolean + } + setChatMessages(updatedChatMessages) + }, + [chatMessages] + ) + + return ( + <> + + {!currentChatRoomId && ( + + + + {t('openAiChat.chatGPTCustom')} + + { + setNewChatModalOpen(true) + }} + style={tw`${clsx( + 'flex flex-row items-center gap-4 justify-center w-full px-3 py-2 bg-gray-900 dark:bg-gray-600' + )}`} + > + + + {t('openAiChat.newChat')} + + + + + )} + {currentChatRoomId && ( + + + + + {chatMessages.map((chatMessage) => ( + + + {chatMessage.role === 'user' && ( + + + + )} + {(chatMessage.role === 'assistant' || + chatMessage.role === 'system') && + chatRoom?.model === 'gpt-3.5-turbo' && ( + + + + )} + {(chatMessage.role === 'assistant' || + chatMessage.role === 'system') && + chatRoom?.model === 'gpt-4' && ( + + + + )} + {chatMessage.viewWithCodeEditor ? ( + <> + + + + + + + + ) : ( + <> + + {chatMessage.role === 'system' && ( + + + {chatRoom?.title + ? chatRoom?.title + : t('noTitle')} + + + {chatRoom?.model}: {chatRoom?.maxTokens}{' '} + {t('tokens')} + + + )} + + {chatMessage.content} + + + + )} + + {chatMessage.viewWithCodeEditor ? ( + { + viewWithCodeEditor(chatMessage.id, false) + }} + style={({ pressed }) => + tw`${clsx( + pressed ? 'bg-gray-50 dark:bg-gray-800' : '', + 'w-5 h-5' + )}` + } + > + + + ) : ( + { + viewWithCodeEditor(chatMessage.id, true) + }} + style={({ pressed }) => + tw`${clsx( + pressed ? 'bg-gray-50 dark:bg-gray-800' : '', + 'w-5 h-5' + )}` + } + > + + + )} + + + + ))} + + + + + + 4 + ? 'h-36' + : `h-${10 + (chatContentLines - 1) * 8}}`, + 'flex-1 border-2 border-gray-900 p-1 dark:border-gray-50 text-sm sm:text-lg font-loaded-normal text-gray-900 dark:text-white' + )}`} + inputMode="text" + value={chatContent} + onChangeText={setChatContent} + /> + { + chatMessageSubmit() + }} + disabled={isChatMessageDisabled} + style={tw`${clsx( + 'flex flex-row items-center justify-center px-3 py-2 bg-gray-900 h-10 w-10', + isChatMessageDisabled + ? 'bg-gray-300 dark:bg-gray-800 dark:text-gray-400' + : 'dark:bg-gray-600' + )}`} + > + + + + + )} + + + ) +} diff --git a/with-skeet-firebase/src/components/screens/user/openAiChat/ChatMenu.tsx b/with-skeet-firebase/src/components/screens/user/openAiChat/ChatMenu.tsx new file mode 100644 index 00000000..b1c070b1 --- /dev/null +++ b/with-skeet-firebase/src/components/screens/user/openAiChat/ChatMenu.tsx @@ -0,0 +1,737 @@ +import tw from '@/lib/tailwind' +import { + Pressable, + Text, + View, + Modal, + Platform, + NativeSyntheticEvent, + NativeScrollEvent, +} from 'react-native' +import { useTranslation } from 'react-i18next' +import clsx from 'clsx' +import { + ChatBubbleLeftIcon, + ChevronDownIcon, + PlusCircleIcon, + QueueListIcon, +} from 'react-native-heroicons/outline' +import { XMarkIcon } from 'react-native-heroicons/outline' +import LogoHorizontal from '@/components/common/atoms/LogoHorizontal' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { fetchSkeetFunctions } from '@/lib/skeet' +import { CreateUserChatRoomParams } from '@/types/http/openai/createUserChatRoomParams' +import Toast from 'react-native-toast-message' +import { useRecoilValue } from 'recoil' +import { userState } from '@/store/user' +import { + GPTModel, + allowedGPTModel, + gptModelSchema, + temperatureSchema, + maxTokensSchema, + systemContentSchema, +} from '@/utils/form' +import { + Menu, + MenuOptions, + MenuTrigger, + MenuOption, + MenuProvider, +} from 'react-native-popup-menu' +import { ScrollView, TextInput } from 'react-native-gesture-handler' +import { + DocumentData, + QueryDocumentSnapshot, + Timestamp, + collection, + getDocs, + limit, + orderBy, + query, + startAfter, +} from 'firebase/firestore' +import { db } from '@/lib/firebase' +import { format } from 'date-fns' +import { SafeAreaView } from 'react-native-safe-area-context' +import { auth } from '@/lib/firebase' +import { signOut } from 'firebase/auth' + +export type ChatRoom = { + id: string + createdAt: Timestamp + updatedAt: Timestamp + model: GPTModel + maxTokens: number + temperature: number + title: string +} + +type Props = { + isNewChatModalOpen: boolean + setNewChatModalOpen: (_value: boolean) => void + currentChatRoomId: string | null + setCurrentChatRoomId: (_value: string | null) => void + chatList: ChatRoom[] + setChatList: (_value: ChatRoom[]) => void + lastChat: QueryDocumentSnapshot | null + setLastChat: (_value: QueryDocumentSnapshot | null) => void + isDataLoading: boolean + setDataLoading: (_value: boolean) => void + getChatRooms: () => void +} + +export default function ChatMenu({ + isNewChatModalOpen, + setNewChatModalOpen, + currentChatRoomId, + setCurrentChatRoomId, + chatList, + setChatList, + lastChat, + setLastChat, + isDataLoading, + setDataLoading, + getChatRooms, +}: Props) { + const { t } = useTranslation() + const user = useRecoilValue(userState) + const [isCreateLoading, setCreateLoading] = useState(false) + const [isChatListModalOpen, setChatListModalOpen] = useState(false) + + const [reachLast, setReachLast] = useState(false) + + const queryMore = useCallback(async () => { + if (db && lastChat) { + try { + const q = query( + collection(db, `User/${user.uid}/UserChatRoom`), + orderBy('createdAt', 'desc'), + limit(15), + startAfter(lastChat) + ) + + const querySnapshot = await getDocs(q) + setDataLoading(true) + const list: ChatRoom[] = [] + querySnapshot.forEach((doc) => { + const data = doc.data() + list.push({ id: doc.id, ...data } as ChatRoom) + }) + + if (querySnapshot.docs[querySnapshot.docs.length - 1] === lastChat) { + setReachLast(true) + } else { + setLastChat(querySnapshot.docs[querySnapshot.docs.length - 1]) + setChatList([...chatList, ...list]) + } + setDataLoading(false) + } catch (err) { + console.log(err) + if (err instanceof Error && err.message.includes('permission-denied')) { + Toast.show({ + type: 'error', + text1: t('errorTokenExpiredTitle') ?? 'Token Expired.', + text2: t('errorTokenExpiredBody') ?? 'Please sign in again.', + }) + if (auth) { + signOut(auth) + } + } else { + Toast.show({ + type: 'error', + text1: t('errorTitle') ?? 'Error', + text2: + t('errorBody') ?? 'Something went wrong... Please try it again.', + }) + } + } + } + }, [ + chatList, + lastChat, + t, + user.uid, + setDataLoading, + setLastChat, + setChatList, + ]) + + const scrollViewRef = useRef(null) + const scrollViewRefModal = useRef(null) + + const handleScroll = useCallback( + (event: NativeSyntheticEvent) => { + const { layoutMeasurement, contentOffset, contentSize } = + event.nativeEvent + + const isScrolledToBottom = + layoutMeasurement.height + contentOffset.y >= contentSize.height + + if (isScrolledToBottom && !reachLast) { + queryMore() + } + }, + [queryMore, reachLast] + ) + + const [model, setModel] = useState(allowedGPTModel[0]) + const [modelError, setModelError] = useState('') + const validateModel = useCallback(() => { + try { + gptModelSchema.parse(model) + setModelError('') + } catch (err) { + setModelError('modelErrorText') + } + }, [model, setModelError]) + + useEffect(() => { + if (model.length > 0) validateModel() + }, [model, validateModel]) + + const [maxTokens, setMaxTokens] = useState('1000') + const [maxTokensError, setMaxTokensError] = useState('') + const validateMaxTokens = useCallback(() => { + try { + maxTokensSchema.parse(Number(maxTokens)) + setMaxTokensError('') + } catch (err) { + setMaxTokensError('maxTokensErrorText') + } + }, [maxTokens, setMaxTokensError]) + + useEffect(() => { + if (maxTokens) validateMaxTokens() + }, [maxTokens, validateMaxTokens]) + + const [temperature, setTemperature] = useState('0.0') + const [temperatureError, setTemperatureError] = useState('') + const validateTemperature = useCallback(() => { + try { + temperatureSchema.parse(Number(temperature)) + setTemperatureError('') + } catch (err) { + setTemperatureError('temperatureErrorText') + } + }, [temperature, setTemperatureError]) + + useEffect(() => { + if (temperature) validateTemperature() + }, [temperature, validateTemperature]) + + const [systemContent, setSystemContent] = useState( + t('openAiChat.defaultSystemContent') ?? + 'This is a conversation with an AI assistant. The assistant is helpful, creative, clever, and very friendly.' + ) + const [systemContentError, setSystemContentError] = useState('') + const validateSystemContent = useCallback(() => { + try { + systemContentSchema.parse(systemContent) + setSystemContentError('') + } catch (err) { + setSystemContentError('systemContentErrorText') + } + }, [systemContent, setSystemContentError]) + + useEffect(() => { + if (systemContent) validateSystemContent() + }, [systemContent, validateSystemContent]) + + const isNewChatDisabled = useMemo(() => { + return ( + isCreateLoading || + modelError != '' || + maxTokensError != '' || + temperatureError != '' || + systemContentError != '' || + systemContent == '' + ) + }, [ + modelError, + isCreateLoading, + maxTokensError, + temperatureError, + systemContentError, + systemContent, + ]) + + const newChatSubmit = useCallback(async () => { + try { + setCreateLoading(true) + if (!isNewChatDisabled) { + const res = await fetchSkeetFunctions( + 'openai', + 'createUserChatRoom', + { + model, + systemContent, + maxTokens: Number(maxTokens), + temperature: Number(temperature), + stream: true, + } + ) + const data = await res?.json() + if (data.status == 'error') { + throw new Error(data.message) + } + Toast.show({ + type: 'success', + text1: + t('openAiChat.chatRoomCreatedSuccessTitle') ?? 'Chat Room Created', + text2: + t('openAiChat.chatRoomCreatedSuccessBody') ?? + 'Chat room has been created successfully.', + }) + setCurrentChatRoomId(data.userChatRoomRef.id) + } else { + throw new Error('validateError') + } + } catch (err) { + console.error(err) + if ( + err instanceof Error && + (err.message.includes('Firebase ID token has expired.') || + err.message.includes('Error: getUserAuth')) + ) { + Toast.show({ + type: 'error', + text1: t('errorTokenExpiredTitle') ?? 'Token Expired.', + text2: t('errorTokenExpiredBody') ?? 'Please sign in again.', + }) + if (auth) { + signOut(auth) + } + } else { + Toast.show({ + type: 'error', + text1: t('errorTitle') ?? 'Error', + text2: + t('errorBody') ?? 'Something went wrong... Please try it again.', + }) + } + } finally { + setNewChatModalOpen(false) + setCreateLoading(false) + await getChatRooms() + } + }, [ + setNewChatModalOpen, + model, + systemContent, + maxTokens, + temperature, + t, + setCreateLoading, + isNewChatDisabled, + setCurrentChatRoomId, + getChatRooms, + ]) + + return ( + <> + + + + { + setChatListModalOpen(true) + }} + style={tw`${clsx('flex flex-row items-center justify-center')}`} + > + + + + + {t('openAiChat.title')} + + + { + setNewChatModalOpen(true) + }} + style={tw`${clsx('flex flex-row items-center justify-center')}`} + > + + + + + + + + { + setNewChatModalOpen(true) + }} + style={tw`${clsx( + 'flex flex-row items-center justify-center w-full px-3 py-2 bg-gray-900 dark:bg-gray-600' + )}`} + > + + + {t('openAiChat.newChat')} + + + + {chatList.map((chat) => ( + { + setCurrentChatRoomId(chat.id) + }} + key={`ChatMenu Desktop ${chat.id}`} + style={tw`${clsx( + currentChatRoomId === chat.id && + 'border-2 border-gray-900 dark:border-gray-50', + 'p-2 bg-gray-50 dark:bg-gray-800 flex flex-row items-start justify-start gap-2' + )}`} + > + + + {chat.title !== '' ? ( + + {chat.title} + + ) : ( + + {t('noTitle')} + + )} + + + {format(chat.createdAt.toDate(), 'yyyy-MM-dd HH:mm')} + + + + ))} + + + + + + { + setNewChatModalOpen(false) + }} + style={tw`z-10 relative`} + > + + + + + + + + { + setNewChatModalOpen(false) + }} + style={({ pressed }) => + tw`${clsx( + pressed ? 'bg-gray-50 dark:bg-gray-800' : '', + 'w-5 h-5' + )}` + } + > + + + + + + {t('openAiChat.newChat')} + + + + + + {t('openAiChat.model')} + {modelError !== '' && ( + + {' : '} + {t(`openAiChat.${modelError}`)} + + )} + + + + + + + {model} + + + + + + + + {allowedGPTModel.map((allowedModel) => ( + { + setModel(allowedModel) + }} + key={`Menu Option ${allowedModel}`} + style={tw`p-3 border-t-gray-50 dark:border-t-gray-800 border-t`} + > + + {allowedModel} + + + ))} + + + + + + + + {t('openAiChat.maxTokens')} + {maxTokensError !== '' && ( + + {' : '} + {t(`openAiChat.${maxTokensError}`)} + + )} + + + + + + + + {t('openAiChat.temperature')} + {temperatureError !== '' && ( + + {' : '} + {t(`openAiChat.${temperatureError}`)} + + )} + + + + + + + + {t('openAiChat.systemContent')} + {systemContentError !== '' && ( + + {' : '} + {t(`openAiChat.${systemContentError}`)} + + )} + + + + + + + + { + newChatSubmit() + }} + disabled={isNewChatDisabled} + style={tw`${clsx( + 'flex flex-row items-center justify-center w-full px-3 py-2 bg-gray-900 dark:bg-gray-600', + isNewChatDisabled + ? 'bg-gray-300 dark:bg-gray-800 dark:text-gray-400' + : '' + )}`} + > + + + {t('openAiChat.createChatRoom')} + + + + + + + + + + + + { + setChatListModalOpen(false) + }} + > + + + + + + + { + setChatListModalOpen(false) + }} + style={({ pressed }) => + tw`${clsx( + pressed ? 'bg-gray-50 dark:bg-gray-800' : '', + 'w-5 h-5' + )}` + } + > + + + + + + {t('openAiChat.chatList')} + + + + {chatList.map((chat) => ( + { + setCurrentChatRoomId(chat.id) + setChatListModalOpen(false) + }} + key={`ChatMenu Mobile ${chat.id}`} + style={tw`${clsx( + currentChatRoomId === chat.id && + 'border-2 border-gray-900 dark:border-gray-50', + 'p-2 bg-gray-50 dark:bg-gray-800 flex flex-row items-start justify-start gap-2' + )}`} + > + + + {chat.title !== '' ? ( + + {chat.title} + + ) : ( + + {t('noTitle')} + + )} + + + {format( + chat.createdAt.toDate(), + 'yyyy-MM-dd HH:mm' + )} + + + + ))} + + + + + + + + + ) +} diff --git a/with-skeet-firebase/src/components/screens/user/settings/EditUserIconUrl.tsx b/with-skeet-firebase/src/components/screens/user/settings/EditUserIconUrl.tsx new file mode 100644 index 00000000..91ac9d95 --- /dev/null +++ b/with-skeet-firebase/src/components/screens/user/settings/EditUserIconUrl.tsx @@ -0,0 +1,120 @@ +import tw from '@/lib/tailwind' +import clsx from 'clsx' +import { Image } from 'expo-image' +import { View, Pressable, Text } from 'react-native' +import { PencilSquareIcon } from 'react-native-heroicons/outline' +import { useTranslation } from 'react-i18next' +import { useCallback } from 'react' +import * as ImagePicker from 'expo-image-picker' +import { doc, updateDoc } from 'firebase/firestore' +import { useRecoilState } from 'recoil' +import { userState } from '@/store/user' +import { auth, db, storage } from '@/lib/firebase' +import { getDownloadURL, ref, uploadBytes } from 'firebase/storage' +import { blurhash } from '@/utils/placeholder' +import { getImageBlob } from '@/utils/storage' +import Toast from 'react-native-toast-message' +import { signOut } from 'firebase/auth' + +export default function EditUserIconUrl() { + const { t } = useTranslation() + const [user, setUser] = useRecoilState(userState) + + const pickImage = useCallback(async () => { + try { + const result = await ImagePicker.launchImageLibraryAsync({ + mediaTypes: ImagePicker.MediaTypeOptions.Images, + allowsEditing: false, + quality: 1, + exif: false, + allowsMultipleSelection: false, + }) + + if ( + !result.canceled && + result.assets[0] && + storage && + user.uid !== '' && + db + ) { + const blob: Blob = (await getImageBlob(result.assets[0].uri)) as Blob + const newProfileIconRef = ref( + storage, + `User/${user.uid}/profileIcon/profile.${blob.type.split('/')[1]}` + ) + await uploadBytes(newProfileIconRef, blob) + + const downloadUrl = await getDownloadURL(newProfileIconRef) + + const docRef = doc(db, 'User', user.uid) + await updateDoc(docRef, { iconUrl: downloadUrl }) + setUser({ + ...user, + iconUrl: downloadUrl, + }) + + Toast.show({ + type: 'success', + text1: t('settings.avatarUpdated') ?? 'Avatar Updated.', + text2: + t('settings.avatarUpdatedMessage') ?? + 'Successfully updated your avatar.', + }) + } + } catch (err) { + console.error(err) + if ( + err instanceof Error && + (err.message.includes('Firebase ID token has expired.') || + err.message.includes('Error: getUserAuth')) + ) { + Toast.show({ + type: 'error', + text1: t('errorTokenExpiredTitle') ?? 'Token Expired.', + text2: t('errorTokenExpiredBody') ?? 'Please sign in again.', + }) + if (auth) { + signOut(auth) + } + } else { + Toast.show({ + type: 'error', + text1: t('settings.avatarUpdatedError') ?? 'Avatar Update Error.', + text2: + t('settings.avatarUpdatedErrorMessage') ?? + 'Something went wrong... Please try it again later.', + }) + } + } + }, [user, setUser, t]) + + return ( + <> + + + + + { + pickImage() + }} + > + + + {t('settings.editIconUrl')} + + + + + ) +} diff --git a/with-skeet-firebase/src/components/screens/user/settings/EditUserProfile.tsx b/with-skeet-firebase/src/components/screens/user/settings/EditUserProfile.tsx new file mode 100644 index 00000000..0a882a74 --- /dev/null +++ b/with-skeet-firebase/src/components/screens/user/settings/EditUserProfile.tsx @@ -0,0 +1,212 @@ +import tw from '@/lib/tailwind' +import clsx from 'clsx' +import { View, Pressable, Text, Modal, Platform } from 'react-native' +import { PencilSquareIcon, XMarkIcon } from 'react-native-heroicons/outline' +import { useTranslation } from 'react-i18next' +import { useState, useCallback, useEffect, useMemo } from 'react' +import LogoHorizontal from '@/components/common/atoms/LogoHorizontal' +import { useRecoilState } from 'recoil' +import { userState } from '@/store/user' +import Button from '@/components/common/atoms/Button' +import { usernameSchema } from '@/utils/form' +import { TextInput } from 'react-native-gesture-handler' +import { auth, db } from '@/lib/firebase' +import Toast from 'react-native-toast-message' +import { doc, updateDoc } from 'firebase/firestore' +import { SafeAreaView } from 'react-native-safe-area-context' +import { signOut } from 'firebase/auth' + +export default function EditUserProfile() { + const { t } = useTranslation() + const [isModalOpen, setIsModalOpen] = useState(false) + const [isLoading, setLoading] = useState(false) + const [user, setUser] = useRecoilState(userState) + + const [username, setUsername] = useState('') + const [usernameError, setUsernameError] = useState('') + const validateUsername = useCallback(() => { + try { + usernameSchema.parse(username) + setUsernameError('') + } catch (err) { + setUsernameError('usernameErrorText') + } + }, [username, setUsernameError]) + + useEffect(() => { + if (username.length > 0) validateUsername() + }, [username, validateUsername]) + + const submit = useCallback(async () => { + if (db && usernameError == '') { + try { + setLoading(true) + const docRef = doc(db, 'User', user.uid) + await updateDoc(docRef, { username }) + setUser({ + ...user, + username, + }) + Toast.show({ + type: 'success', + text1: t('settings.updateProfileSuccess') ?? 'Profile Updated', + text2: + t('settings.updateProfileSuccessMessage') ?? + 'Successfully updated your profile.', + }) + } catch (err) { + console.error(err) + if ( + err instanceof Error && + (err.message.includes('Firebase ID token has expired.') || + err.message.includes('Error: getUserAuth')) + ) { + Toast.show({ + type: 'error', + text1: t('errorTokenExpiredTitle') ?? 'Token Expired.', + text2: t('errorTokenExpiredBody') ?? 'Please sign in again.', + }) + if (auth) { + signOut(auth) + } + } else { + Toast.show({ + type: 'error', + text1: t('settings.updateProfileError') ?? 'Update Error', + text2: + t('settings.updateProfileErrorMessage') ?? + 'Something went wrong... Please try again later.', + }) + } + } finally { + setIsModalOpen(false) + setLoading(false) + } + } + }, [t, username, usernameError, user, setUser]) + + const isDisabled = useMemo( + () => !(username.length > 0) || isLoading || usernameError != '', + [username, isLoading, usernameError] + ) + + return ( + <> + + + {user.username} + + + {user.email} + + + + { + setIsModalOpen(true) + }} + > + + + {t('settings.editProfile')} + + + + { + setIsModalOpen(false) + }} + > + + + + + + { + setIsModalOpen(false) + }} + style={({ pressed }) => + tw`${clsx( + pressed ? 'bg-gray-50 dark:bg-gray-800' : '', + 'w-5 h-5' + )}` + } + > + + + + + + {t('settings.editProfile')} + + + + + + {t('username')} + {usernameError !== '' && ( + + {' : '} + {t(usernameError)} + + )} + + + + + + + + + + + + + + + + + ) +} diff --git a/with-skeet-firebase/src/components/utils/ColorModeChanger.tsx b/with-skeet-firebase/src/components/utils/ColorModeChanger.tsx new file mode 100644 index 00000000..e28dfe02 --- /dev/null +++ b/with-skeet-firebase/src/components/utils/ColorModeChanger.tsx @@ -0,0 +1,33 @@ +import tw from '@/lib/tailwind' +import { Pressable } from 'react-native' +import { useAppColorScheme } from 'twrnc' +import { MoonIcon, SunIcon } from 'react-native-heroicons/outline' +import { useRecoilState } from 'recoil' +import { colorModeRefreshState } from '@/store/colorModeRefresh' + +export default function ColorModeChanger() { + const [_, __, setColorScheme] = useAppColorScheme(tw) + const [_refresh, setRefresh] = useRecoilState(colorModeRefreshState) + return ( + <> + { + setColorScheme('light') + setRefresh({ mode: 'light' }) + }} + style={tw`hidden dark:flex hover:dark:text-gray-200`} + > + + + { + setColorScheme('dark') + setRefresh({ mode: 'dark' }) + }} + style={tw`dark:hidden hover:text-gray-900`} + > + + + + ) +} diff --git a/with-skeet-firebase/src/components/utils/LanguageChanger.tsx b/with-skeet-firebase/src/components/utils/LanguageChanger.tsx new file mode 100644 index 00000000..ecd71cfd --- /dev/null +++ b/with-skeet-firebase/src/components/utils/LanguageChanger.tsx @@ -0,0 +1,63 @@ +import tw from '@/lib/tailwind' +import { useCallback } from 'react' +import { Text, View } from 'react-native' +import { useTranslation } from 'react-i18next' +import { LanguageIcon } from 'react-native-heroicons/outline' +import { + Menu, + MenuOptions, + MenuTrigger, + MenuOption, +} from 'react-native-popup-menu' + +export default function LanguageChanger() { + const { i18n } = useTranslation() + + const changeLanguage = useCallback( + async (lang: string) => { + await i18n.changeLanguage(lang) + }, + [i18n] + ) + return ( + <> + + + + + + + { + changeLanguage('en-US') + }} + style={tw`p-3`} + > + + English + + + { + changeLanguage('ja-JP') + }} + style={tw`p-3 border-t-gray-50 dark:border-t-gray-800 border-t`} + > + + 日本語 + + + + + + + ) +} diff --git a/with-skeet-firebase/src/hooks/useAnalytics.ts b/with-skeet-firebase/src/hooks/useAnalytics.ts new file mode 100644 index 00000000..f598811d --- /dev/null +++ b/with-skeet-firebase/src/hooks/useAnalytics.ts @@ -0,0 +1,21 @@ +import { useEffect } from 'react' +import { useRoute } from '@react-navigation/native' +import { logEvent } from 'firebase/analytics' +import { useTranslation } from 'react-i18next' +import { analytics } from '@/lib/firebase' + +export default function useAnalytics() { + const { t } = useTranslation() + + const route = useRoute() + + useEffect(() => { + if (analytics && route.name) { + logEvent(analytics, 'page_view', { + page_title: t(`routes.${route.name}`) ?? route.name, + page_location: route.name, + page_path: `/${route.name}`, + }) + } + }, [route.name, t]) +} diff --git a/with-skeet-firebase/src/hooks/useColorModeRefresh.ts b/with-skeet-firebase/src/hooks/useColorModeRefresh.ts new file mode 100644 index 00000000..4e3663bf --- /dev/null +++ b/with-skeet-firebase/src/hooks/useColorModeRefresh.ts @@ -0,0 +1,6 @@ +import { useRecoilValue } from 'recoil' +import { colorModeRefreshState } from '@/store/colorModeRefresh' + +export default function useColorModeRefresh() { + const _refresh = useRecoilValue(colorModeRefreshState) +} diff --git a/with-skeet-firebase/src/hooks/useLogout.ts b/with-skeet-firebase/src/hooks/useLogout.ts new file mode 100644 index 00000000..1cc39aa8 --- /dev/null +++ b/with-skeet-firebase/src/hooks/useLogout.ts @@ -0,0 +1,26 @@ +import { useRecoilCallback } from 'recoil' +import { userState } from '@/store/user' +import Toast from 'react-native-toast-message' +import { useTranslation } from 'react-i18next' +import { auth } from '@/lib/firebase' +import { signOut } from 'firebase/auth' + +export default function useLogout() { + const { t } = useTranslation() + const logout = useRecoilCallback( + ({ reset }) => + async () => { + if (auth) { + reset(userState) + await signOut(auth) + Toast.show({ + type: 'success', + text1: t('succeedLogout') ?? 'Succeed to sign out', + text2: t('seeYouSoon') ?? 'See you soon👋', + }) + } + }, + [t] + ) + return logout +} diff --git a/with-skeet-firebase/src/hooks/useScreens.ts b/with-skeet-firebase/src/hooks/useScreens.ts new file mode 100644 index 00000000..e48ff409 --- /dev/null +++ b/with-skeet-firebase/src/hooks/useScreens.ts @@ -0,0 +1,31 @@ +import { useMemo } from 'react' +import { defaultRoutes } from '@/routes/DefaultRoutes' +import { userRoutes } from '@/routes/UserRoutes' +import { toKebabCase } from '@/utils/character' + +type Screens = { + [key: string]: string +} + +export default function useScreens() { + const defaultScreens = useMemo(() => { + const screens: Screens = {} + defaultRoutes.forEach((route) => { + screens[route.name] = toKebabCase(route.name) + }) + return screens + }, []) + + const userScreens = useMemo(() => { + const screens: Screens = {} + userRoutes.forEach((route) => { + screens[route.name] = toKebabCase(route.name) + }) + return screens + }, []) + + return { + defaultScreens, + userScreens, + } +} diff --git a/with-skeet-firebase/src/layouts/default/DefaultLayout.tsx b/with-skeet-firebase/src/layouts/default/DefaultLayout.tsx new file mode 100644 index 00000000..2e90759b --- /dev/null +++ b/with-skeet-firebase/src/layouts/default/DefaultLayout.tsx @@ -0,0 +1,65 @@ +import LanguageChanger from '@/components/utils/LanguageChanger' +import tw from '@/lib/tailwind' +import type { ReactNode } from 'react' +import { KeyboardAvoidingView, Pressable, Platform, View } from 'react-native' +import { SafeAreaView } from 'react-native-safe-area-context' +import ColorModeChanger from '@/components/utils/ColorModeChanger' +import useColorModeRefresh from '@/hooks/useColorModeRefresh' +import { useNavigation } from '@react-navigation/native' +import { ArrowLeftIcon } from 'react-native-heroicons/outline' +import clsx from 'clsx' + +type Props = { + children: ReactNode +} +export default function DefaultLayout({ children }: Props) { + useColorModeRefresh() + const navigation = useNavigation() + + return ( + <> + + + + + + + {navigation.canGoBack() && ( + <> + { + navigation.goBack() + }} + style={({ pressed }) => + tw`${clsx( + pressed ? 'bg-gray-50 dark:bg-gray-800' : '', + 'w-5 h-5' + )}` + } + > + + + + )} + + + + + + + + {children} + + + + + ) +} diff --git a/with-skeet-firebase/src/layouts/user/UserLayout.tsx b/with-skeet-firebase/src/layouts/user/UserLayout.tsx new file mode 100644 index 00000000..f19f37c0 --- /dev/null +++ b/with-skeet-firebase/src/layouts/user/UserLayout.tsx @@ -0,0 +1,205 @@ +import tw from '@/lib/tailwind' +import { ReactNode } from 'react' +import { useState } from 'react' +import { + Text, + Modal, + Pressable, + View, + Platform, + KeyboardAvoidingView, +} from 'react-native' +import { SafeAreaView } from 'react-native-safe-area-context' +import useColorModeRefresh from '@/hooks/useColorModeRefresh' +import { useNavigation, useRoute } from '@react-navigation/native' +import { Bars3Icon, XMarkIcon } from 'react-native-heroicons/outline' +import clsx from 'clsx' +import UserMenu from './UserMenu' +import { useTranslation } from 'react-i18next' +import { userRoutes } from '@/routes/UserRoutes' +import LogoHorizontal from '@/components/common/atoms/LogoHorizontal' + +type Props = { + children: ReactNode +} + +export default function UserLayout({ children }: Props) { + useColorModeRefresh() + const navigation = useNavigation() + const route = useRoute() + const [isMenuOpen, setIsMenuOpen] = useState(false) + const { t } = useTranslation() + + return ( + <> + + + + { + setIsMenuOpen(false) + }} + > + + + + + + { + setIsMenuOpen(false) + }} + style={({ pressed }) => + tw`${clsx( + pressed ? 'bg-gray-50 dark:bg-gray-800' : '', + 'w-5 h-5' + )}` + } + > + + + + + + {userRoutes.map((item) => ( + { + navigation.navigate(item.name) + setIsMenuOpen(false) + }} + > + + + {t(`routes.${item.name}`)} + + + ))} + + + + + + + + + + + + + + {userRoutes.map((item) => ( + { + navigation.navigate(item.name) + setIsMenuOpen(false) + }} + > + + + {t(`routes.${item.name}`)} + + + ))} + + + + + + + + + + { + setIsMenuOpen(true) + }} + style={({ pressed }) => + tw`${clsx( + pressed ? 'bg-gray-50 dark:bg-gray-800' : '', + 'w-5 h-5 lg:hidden' + )}` + } + > + + + + + + + + + + + + + {children} + + + + + + + ) +} diff --git a/with-skeet-firebase/src/layouts/user/UserMenu.tsx b/with-skeet-firebase/src/layouts/user/UserMenu.tsx new file mode 100644 index 00000000..d5744a97 --- /dev/null +++ b/with-skeet-firebase/src/layouts/user/UserMenu.tsx @@ -0,0 +1,66 @@ +import tw from '@/lib/tailwind' +import { Text, View } from 'react-native' +import { useTranslation } from 'react-i18next' +import { + Menu, + MenuOptions, + MenuTrigger, + MenuOption, +} from 'react-native-popup-menu' +import { useNavigation } from '@react-navigation/native' +import useLogout from '@/hooks/useLogout' +import { useRecoilValue } from 'recoil' +import { userState } from '@/store/user' +import { Image } from 'expo-image' +import { blurhash } from '@/utils/placeholder' + +export default function UserMenu() { + const { t } = useTranslation() + const navigation = useNavigation() + const logout = useLogout() + + const { iconUrl } = useRecoilValue(userState) + + return ( + <> + + + + + + + { + navigation.navigate('Settings') + }} + style={tw`p-3`} + > + + {t('settings.title')} + + + { + await logout() + }} + style={tw`p-3 border-t-gray-50 dark:border-t-gray-800 border-t`} + > + + {t('logout')} + + + + + + + ) +} diff --git a/with-skeet-firebase/src/lib/firebase.ts b/with-skeet-firebase/src/lib/firebase.ts new file mode 100644 index 00000000..15b324fe --- /dev/null +++ b/with-skeet-firebase/src/lib/firebase.ts @@ -0,0 +1,63 @@ +import firebaseConfig from '@lib/firebaseConfig' +import { getAnalytics } from 'firebase/analytics' +import { initializeApp, getApp, getApps } from 'firebase/app' +import { connectAuthEmulator, getAuth } from 'firebase/auth' +import { getStorage, connectStorageEmulator } from 'firebase/storage' +import { + connectFirestoreEmulator, + initializeFirestore, +} from 'firebase/firestore' +import { Platform } from 'react-native' + +export const firebaseApp = !getApps().length + ? initializeApp(firebaseConfig) + : getApp() + +export const platformDevIP = + Platform.OS === 'web' + ? '127.0.0.1' + : Platform.OS === 'android' + ? '10.0.2.2' + : '0.0.0.0' + +const getFirebaseAuth = () => { + const firebaseAuth = getAuth(firebaseApp) + if (process.env.NODE_ENV !== 'production') { + connectAuthEmulator(firebaseAuth, `http://${platformDevIP}:9099`, { + disableWarnings: true, + }) + } + return firebaseAuth +} + +export const auth = firebaseApp ? getFirebaseAuth() : undefined + +const getFirebaseStorage = () => { + const firebaseStorage = getStorage(firebaseApp) + if (process.env.NODE_ENV !== 'production') { + connectStorageEmulator(firebaseStorage, platformDevIP, 9199) + } + + return firebaseStorage +} + +export const storage = firebaseApp ? getFirebaseStorage() : undefined + +const getFirebaseFirestore = () => { + const firestoreDb = initializeFirestore(firebaseApp, { + experimentalForceLongPolling: true, + }) + if (process.env.NODE_ENV !== 'production') { + connectFirestoreEmulator(firestoreDb, platformDevIP, 8080) + } + return firestoreDb +} + +export const db = firebaseApp ? getFirebaseFirestore() : undefined + +export const analytics = + typeof window !== 'undefined' && + process.env.NODE_ENV === 'production' && + firebaseApp + ? getAnalytics(firebaseApp) + : undefined diff --git a/with-skeet-firebase/src/lib/i18n.ts b/with-skeet-firebase/src/lib/i18n.ts new file mode 100644 index 00000000..481578b5 --- /dev/null +++ b/with-skeet-firebase/src/lib/i18n.ts @@ -0,0 +1,20 @@ +import i18n from 'i18next' +import { initReactI18next } from 'react-i18next' + +import translationEN from '@/locales/en-US/translation' +import translationJA from '@/locales/ja-JP/translation' + +i18n.use(initReactI18next).init({ + compatibilityJSON: 'v3', + resources: { + 'en-US': { ...translationEN }, + 'ja-JP': { ...translationJA }, + }, + lng: 'en-US', + fallbackLng: 'en-US', + interpolation: { + escapeValue: false, + }, +}) + +export default i18n diff --git a/with-skeet-firebase/src/lib/skeet.ts b/with-skeet-firebase/src/lib/skeet.ts new file mode 100644 index 00000000..757c3932 --- /dev/null +++ b/with-skeet-firebase/src/lib/skeet.ts @@ -0,0 +1,40 @@ +import skeetCloudConfig from '@root/skeet-cloud.config.json' +import { toKebabCase } from '@/utils/character' +import { auth, platformDevIP } from '@/lib/firebase' +import { signOut } from 'firebase/auth' + +export const fetchSkeetFunctions = async ( + functionName: string, + methodName: string, + params: T +) => { + try { + const url = + process.env.NODE_ENV === 'production' + ? `https://${ + skeetCloudConfig.app.functionsDomain + }/${functionName}/${toKebabCase(methodName)}` + : `http://${platformDevIP}:5001/${skeetCloudConfig.app.projectId}/${skeetCloudConfig.app.region}/${methodName}` + const skeetToken = await auth?.currentUser?.getIdToken() + const res = await fetch(`${url}`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${skeetToken}`, + }, + body: JSON.stringify(params), + }) + return res + } catch (err) { + console.error(err) + if ( + err instanceof Error && + (err.message.includes('Firebase ID token has expired.') || + err.message.includes('Error: getUserAuth')) + ) { + if (auth) { + signOut(auth) + } + } + } +} diff --git a/with-skeet-firebase/src/lib/tailwind.ts b/with-skeet-firebase/src/lib/tailwind.ts new file mode 100644 index 00000000..77a3d060 --- /dev/null +++ b/with-skeet-firebase/src/lib/tailwind.ts @@ -0,0 +1,14 @@ +import { create } from 'twrnc' +import themeColors from 'tailwindcss/colors' +const tw = create(require(`../../tailwind.config.js`)) +export default tw + +export const colors: Colors = { + ...themeColors, +} as Colors + +type Colors = { + [key: string]: { + [key: number]: string + } +} diff --git a/with-skeet-firebase/src/lib/toast.tsx b/with-skeet-firebase/src/lib/toast.tsx new file mode 100644 index 00000000..752c198a --- /dev/null +++ b/with-skeet-firebase/src/lib/toast.tsx @@ -0,0 +1,46 @@ +import { BaseToastProps } from 'react-native-toast-message' +import { BaseToast } from 'react-native-toast-message' +import tw from '@/lib/tailwind' + +export const toastConfig = { + primary: (props: BaseToastProps) => ( + + ), + success: (props: BaseToastProps) => ( + + ), + error: (props: BaseToastProps) => ( + + ), + info: (props: BaseToastProps) => ( + + ), + warning: (props: BaseToastProps) => ( + + ), +} diff --git a/with-skeet-firebase/src/locales/en-US/default.ts b/with-skeet-firebase/src/locales/en-US/default.ts new file mode 100644 index 00000000..c4b6d146 --- /dev/null +++ b/with-skeet-firebase/src/locales/en-US/default.ts @@ -0,0 +1,73 @@ +const defaultEN = { + appTitle: 'Skeet App Template', + loginToYourAccount: 'Sign in to your account', + or: 'Or', + registerYourAccount: 'Register your account', + email: 'Email', + username: 'Username', + password: 'Password', + forgotYourPassword: 'Forgot your password?', + login: 'Sign in', + register: 'Register', + agreeOn: 'Agree on', + terms: 'Terms', + privacy: 'Privacy Policy', + linkError: 'Link Error', + urlError: 'Could not open the link', + resetYourPassword: 'Reset your password', + reset: 'Reset', + sentResetPasswordRequest: 'Succeed Reset Password Request', + confirmEmail: 'Please check your email', + resetRequestErrorTitle: 'Reset Error', + resetRequestErrorBody: + 'Failed to reset password. Please check your email address.', + inputNewPassword: 'Please input your new password.', + resetPasswordSuccessTitle: 'Registered your new password🎉', + resetPasswordSuccessBody: + 'Your new password has been registered. Please sign in with it.', + resetPasswordErrorTitle: 'Reset Error', + resetPasswordErrorBody: + 'Failed to reset password. Please try it again later.', + sentConfirmEmailTitle: 'Sent confirmation email', + sentConfirmEmailBody: + 'Thank you for your registration. Please check your email.', + thanksForRequest: + 'Thank you. We sent the email for your confirmation so please check your registered email.', + backToLogin: 'Back to Login', + succeedLogin: 'Succeed to sign in🎉', + howdy: 'Howdy?', + errorLoginTitle: 'Failed to sign in.', + errorLoginBody: 'Something went wrong... Please try it again.', + emailErrorText: 'Please input email address.', + usernameErrorText: 'Please input username (1~20 characters).', + passwordErrorText: 'Please enter a password of at least 8 characters.', + errorNotVerifiedTitle: 'Not verified.', + errorNotVerifiedBody: 'Sent email to verify. Please check your email box.', + succeedLogout: 'Succeed to sign out', + seeYouSoon: 'See you soon👋', + logout: 'Sign out', + invalidParamsErrorTitle: 'Invalid URL', + invalidParamsErrorBody: + 'Sorry, Something went wrong... Please try it again later.', + verifySuccessTitle: 'Verify Success🎉', + verifySuccessBody: 'Welcome to Skeet App Template', + verifyErrorTitle: 'Verify Error', + verifyErrorBody: 'Something went wrong... Please try it again later.', + confirmDoneTitle: 'Confirmation completed!', + confirmDoneBody: + 'Thank you for the confirmation. Welcome to Skeet App Template🙌', + alreadyExistTitle: 'Already exist', + alreadyExistBody: + 'This email address is already exist. Please try to sign in.', + errorTokenExpiredTitle: 'Login expired', + errorTokenExpiredBody: 'Please sign in again.', + errorTitle: 'Error', + errorBody: 'Something went wrong... Please try it again later.', + noTitle: 'No title', + tokens: 'tokens', + userNotFoundTitle: 'User not found', + userNotFoundBody: + 'This email address is not registered. Please try to sign up.', +} + +export default defaultEN diff --git a/with-skeet-firebase/src/locales/en-US/openAiChat.ts b/with-skeet-firebase/src/locales/en-US/openAiChat.ts new file mode 100644 index 00000000..24562a01 --- /dev/null +++ b/with-skeet-firebase/src/locales/en-US/openAiChat.ts @@ -0,0 +1,23 @@ +const openAiChatEN = { + title: 'Open AI Chat', + newChat: 'New Chat', + chatList: 'Chat List', + createChatRoom: 'Create New Chat', + chatRoomCreatedSuccessTitle: 'Chat Room Created', + chatRoomCreatedSuccessBody: 'Chat room has been created successfully.', + model: 'Model', + modelErrorText: 'Please select a model from the list.', + maxTokens: 'Max Tokens (100 ~ 4096: more thinking)', + maxTokensErrorText: 'Please enter a number between 100 and 4096.', + temperature: 'Temperature (0: most conservative ~ 2: most creative)', + temperatureErrorText: 'Please enter a number between 0 and 2.', + defaultSystemContent: + 'This is a conversation with an AI assistant. The assistant is helpful, creative, clever, and very friendly.', + systemContent: 'AI Character Setting', + systemContentErrorText: + 'Please enter AI character setting in 1000 characters.', + chatGPTCustom: 'Chat GPT Custom with API', + chatMessageSubmit: 'Submit', +} + +export default openAiChatEN diff --git a/with-skeet-firebase/src/locales/en-US/routes.ts b/with-skeet-firebase/src/locales/en-US/routes.ts new file mode 100644 index 00000000..f6c1e023 --- /dev/null +++ b/with-skeet-firebase/src/locales/en-US/routes.ts @@ -0,0 +1,11 @@ +const routesEN = { + Login: 'Sign In', + Register: 'Register', + ResetPassword: 'Reset Password', + CheckEmail: 'Check the email from us', + Action: 'Account Action', + OpenAiChat: 'Open AI Chat', + Settings: 'Settings', +} + +export default routesEN diff --git a/with-skeet-firebase/src/locales/en-US/settings.ts b/with-skeet-firebase/src/locales/en-US/settings.ts new file mode 100644 index 00000000..1e100275 --- /dev/null +++ b/with-skeet-firebase/src/locales/en-US/settings.ts @@ -0,0 +1,17 @@ +const settingsEN = { + title: 'Settings', + editIconUrl: 'Edit avatar image', + editProfile: 'Edit profile', + avatarUpdated: 'Avatar Updated', + avatarUpdatedMessage: 'Successfully updated your avatar image🎉', + avatarUpdatedError: 'Avatar Update Failed', + avatarUpdatedErrorMessage: + 'Something went wrong... Please try it again later.', + updateProfileSuccess: 'Profile Updated', + updateProfileSuccessMessage: 'Successfully updated your profile🎉', + updateProfileError: 'Profile Update Failed', + updateProfileErrorMessage: + 'Something went wrong... Please try it again later.', +} + +export default settingsEN diff --git a/with-skeet-firebase/src/locales/en-US/translation.ts b/with-skeet-firebase/src/locales/en-US/translation.ts new file mode 100644 index 00000000..cd9d9f11 --- /dev/null +++ b/with-skeet-firebase/src/locales/en-US/translation.ts @@ -0,0 +1,17 @@ +import defaultEN from './default' +import openAiChat from './openAiChat' +import routes from './routes' +import settings from './settings' +import users from './users' + +const translationEN = { + translation: { + ...defaultEN, + openAiChat, + routes, + settings, + users, + }, +} + +export default translationEN diff --git a/with-skeet-firebase/src/locales/en-US/users.ts b/with-skeet-firebase/src/locales/en-US/users.ts new file mode 100644 index 00000000..257c4b63 --- /dev/null +++ b/with-skeet-firebase/src/locales/en-US/users.ts @@ -0,0 +1,3 @@ +const userEN = {} + +export default userEN diff --git a/with-skeet-firebase/src/locales/ja-JP/default.ts b/with-skeet-firebase/src/locales/ja-JP/default.ts new file mode 100644 index 00000000..ea4e3b5d --- /dev/null +++ b/with-skeet-firebase/src/locales/ja-JP/default.ts @@ -0,0 +1,74 @@ +const defaultJA = { + appTitle: 'Skeet App テンプレート', + loginToYourAccount: 'アカウントにログイン', + or: 'もしくは', + registerYourAccount: 'アカウントを作成', + email: 'メールアドレス', + username: 'ユーザー名', + password: 'パスワード', + forgotYourPassword: 'パスワードをお忘れですか?', + login: 'ログイン', + register: '登録', + agreeOn: '次に同意:', + terms: '利用規約', + privacy: 'プライバシーポリシー', + linkError: 'リンクエラー', + urlError: '開けないURLです', + resetYourPassword: 'パスワードのリセット', + reset: 'リセット', + sentResetPasswordRequest: 'リセットリクエストを送信しました', + confirmEmail: 'メールを確認してください', + resetRequestErrorTitle: 'リセットエラー', + resetRequestErrorBody: + 'パスワードリセットに失敗しました。メールアドレスを確認してください。', + inputNewPassword: '新しいパスワードを入力してください。', + resetPasswordSuccessTitle: 'パスワードが新しくなりました🎉', + resetPasswordSuccessBody: + '新しいパスワードを登録しました。早速ログインしてみてください。', + resetPasswordErrorTitle: 'リセットエラー', + resetPasswordErrorBody: + 'パスワードリセットに失敗しました。後ほどもう一度お試しください。', + sentConfirmEmailTitle: '確認メールを送信しました', + sentConfirmEmailBody: + 'ご登録ありがとうございます。メールボックスをご確認ください。', + thanksForRequest: + 'ありがとうございます。確認のためメールをお送りしましたので、ご登録のメールアドレスをご確認ください。', + backToLogin: 'ログイン画面に戻る', + succeedLogin: 'ログイン成功🎉', + howdy: 'おつかれさまです', + errorLoginTitle: 'ログイン失敗', + errorLoginBody: '大変お手数おかけしますが、もう一度お試しください。', + emailErrorText: 'メールアドレスを入力してください。', + usernameErrorText: '1~20文字のユーザー名を入力してください。', + passwordErrorText: '8文字以上のパスワードを入力してください。', + errorNotVerifiedTitle: 'まだ認証されていません', + errorNotVerifiedBody: + '認証メールをお送りいたしました。メールボックスを確認してください。', + succeedLogout: 'ログアウトしました', + seeYouSoon: 'では、また👋', + logout: 'ログアウト', + invalidParamsErrorTitle: '不正なURLです', + invalidParamsErrorBody: + 'すみません、なにか問題が発生しました。もう一度お試しください。', + verifySuccessTitle: '認証成功🎉', + verifySuccessBody: 'Skeet App Templateへようこそ', + verifyErrorTitle: '認証エラー', + verifyErrorBody: 'エラーが発生しました。もう一度お試しください。', + confirmDoneTitle: '確認完了!', + confirmDoneBody: + 'ご確認ありがとうございます。そしてSkeet App Templateへようこそ🙌', + alreadyExistTitle: 'すでに登録されています。', + alreadyExistBody: + 'こちらのメールアドレスはすでに登録されています。ログインをお試しください。', + errorTokenExpiredTitle: 'ログイン期限切れ', + errorTokenExpiredBody: 'もう一度ログインしてください。', + errorTitle: 'エラーです', + errorBody: '大変お手数おかけしますが、もう一度お試しください。', + noTitle: '無題', + tokens: 'トークン', + userNotFoundTitle: 'ユーザーが見つかりません', + userNotFoundBody: + 'このメールアドレスはまだ登録されていません。アカウントを作成してください。', +} + +export default defaultJA diff --git a/with-skeet-firebase/src/locales/ja-JP/openAiChat.ts b/with-skeet-firebase/src/locales/ja-JP/openAiChat.ts new file mode 100644 index 00000000..e450d3d4 --- /dev/null +++ b/with-skeet-firebase/src/locales/ja-JP/openAiChat.ts @@ -0,0 +1,22 @@ +const openAiChatJA = { + title: 'オープンAIチャット', + newChat: 'チャットルーム作成', + chatList: 'チャットルーム一覧', + createChatRoom: '新しいチャットを開始', + chatRoomCreatedSuccessTitle: 'チャットルーム作成', + chatRoomCreatedSuccessBody: 'チャットルームが作成されました。', + model: 'モデル', + modelErrorText: 'モデルをリストから選択してください。', + maxTokens: '最大トークン数 (100 ~ 4096:より多く考える)', + maxTokensErrorText: '100から4096までの数値を入力してください。', + temperature: '確度 (0:最も固い ~ 2:奇抜な発想)', + temperatureErrorText: '0から2までの数値を入力してください。', + defaultSystemContent: + 'これはAIアシスタントとの会話です。アシスタントは親切で、創造的で、賢くて、とてもフレンドリーです。', + systemContent: 'AIのキャラ設定', + systemContentErrorText: 'AIのキャラ設定を入力してください。(1000文字まで)', + chatGPTCustom: 'カスタム Chat GPT with API', + chatMessageSubmit: '送信', +} + +export default openAiChatJA diff --git a/with-skeet-firebase/src/locales/ja-JP/routes.ts b/with-skeet-firebase/src/locales/ja-JP/routes.ts new file mode 100644 index 00000000..13cb8ed2 --- /dev/null +++ b/with-skeet-firebase/src/locales/ja-JP/routes.ts @@ -0,0 +1,11 @@ +const routesJA = { + Login: 'ログイン', + Register: 'アカウント作成', + ResetPassword: 'パスワードリセット', + CheckEmail: 'メールを確認してください', + Action: 'アカウントアクション', + OpenAiChat: 'OpenAIチャット', + Settings: '設定', +} + +export default routesJA diff --git a/with-skeet-firebase/src/locales/ja-JP/settings.ts b/with-skeet-firebase/src/locales/ja-JP/settings.ts new file mode 100644 index 00000000..ce9b4d06 --- /dev/null +++ b/with-skeet-firebase/src/locales/ja-JP/settings.ts @@ -0,0 +1,17 @@ +const settingsJA = { + title: '設定', + editIconUrl: 'アバター画像を変更', + editProfile: 'プロフィールを編集', + avatarUpdated: 'アバター更新成功', + avatarUpdatedMessage: '正常にアバター画像を更新できました🎉', + avatarUpdatedError: 'アバター更新失敗', + avatarUpdatedErrorMessage: + 'アバター画像の更新に失敗しました😢もう一度お試しください', + updateProfileSuccess: 'プロフィール更新成功', + updateProfileSuccessMessage: '正常にプロフィールを更新できました🎉', + updateProfileError: 'プロフィール更新失敗', + updateProfileErrorMessage: + 'プロフィールの更新に失敗しました😢もう一度お試しください', +} + +export default settingsJA diff --git a/with-skeet-firebase/src/locales/ja-JP/translation.ts b/with-skeet-firebase/src/locales/ja-JP/translation.ts new file mode 100644 index 00000000..1f1c5426 --- /dev/null +++ b/with-skeet-firebase/src/locales/ja-JP/translation.ts @@ -0,0 +1,17 @@ +import defaultJA from './default' +import openAiChat from './openAiChat' +import routes from './routes' +import settings from './settings' +import users from './users' + +const translationJA = { + translation: { + ...defaultJA, + openAiChat, + routes, + settings, + users, + }, +} + +export default translationJA diff --git a/with-skeet-firebase/src/locales/ja-JP/users.ts b/with-skeet-firebase/src/locales/ja-JP/users.ts new file mode 100644 index 00000000..294e5a80 --- /dev/null +++ b/with-skeet-firebase/src/locales/ja-JP/users.ts @@ -0,0 +1,3 @@ +const userJA = {} + +export default userJA diff --git a/with-skeet-firebase/src/routes/DefaultRoutes.tsx b/with-skeet-firebase/src/routes/DefaultRoutes.tsx new file mode 100644 index 00000000..5ee3ae98 --- /dev/null +++ b/with-skeet-firebase/src/routes/DefaultRoutes.tsx @@ -0,0 +1,50 @@ +import ActionScreen from '@/screens/ActionScreen' +import LoginScreen from '@/screens/LoginScreen' +import RegisterScreen from '@/screens/RegisterScreen' +import ResetPasswordScreen from '@/screens/ResetPasswordScreen' +import CheckEmailScreen from '@/screens/CheckEmailScreen' +import { createNativeStackNavigator } from '@react-navigation/native-stack' + +export const defaultRoutes = [ + { + name: 'Login', + component: LoginScreen, + }, + { + name: 'Register', + component: RegisterScreen, + }, + { + name: 'ResetPassword', + component: ResetPasswordScreen, + }, + { + name: 'CheckEmail', + component: CheckEmailScreen, + }, + { + name: 'Action', + component: ActionScreen, + }, +] + +const Stack = createNativeStackNavigator() + +export default function DefaultRoutes() { + return ( + <> + + {defaultRoutes.map((route) => ( + + ))} + + + ) +} diff --git a/with-skeet-firebase/src/routes/Routes.tsx b/with-skeet-firebase/src/routes/Routes.tsx new file mode 100644 index 00000000..c6d83469 --- /dev/null +++ b/with-skeet-firebase/src/routes/Routes.tsx @@ -0,0 +1,118 @@ +import { defaultUser, userState } from '@/store/user' +import { useRecoilState } from 'recoil' +import { useMemo, useEffect, useState, useCallback } from 'react' +import { createNativeStackNavigator } from '@react-navigation/native-stack' +import UserRoutes from './UserRoutes' +import DefaultRoutes from './DefaultRoutes' +import { NavigationContainer } from '@react-navigation/native' +import AppLoading from '@/components/loading/AppLoading' +import { useTranslation } from 'react-i18next' +import * as Linking from 'expo-linking' +import skeetCloudConfig from '@root/skeet-cloud.config.json' +import useScreens from '@/hooks/useScreens' +import { auth, db } from '@/lib/firebase' +import { signOut, User } from 'firebase/auth' +import { doc, getDoc } from 'firebase/firestore' + +const Stack = createNativeStackNavigator() +const prefix = Linking.createURL('/') + +export type RootStackParamList = { + Action: { + mode?: string | undefined + oobCode?: string | undefined + } +} + +export default function Routes() { + const { t } = useTranslation() + const [initializing, setInitializing] = useState(true) + const [user, setUser] = useRecoilState(userState) + + const onAuthStateChanged = useCallback( + async (user: User | null) => { + if (initializing) setInitializing(false) + if (auth && db && user) { + if (!user?.emailVerified) { + await signOut(auth) + setUser(defaultUser) + } + const docRef = doc(db, 'User', user.uid) + const docSnap = await getDoc(docRef) + if (docSnap.exists()) { + setUser({ + uid: user.uid, + email: user.email ?? '', + username: docSnap.data().username, + iconUrl: docSnap.data().iconUrl, + emailVerified: user.emailVerified, + }) + } else { + await signOut(auth) + setUser(defaultUser) + } + } else { + setUser(defaultUser) + } + }, + [setUser, initializing, setInitializing] + ) + + useEffect(() => { + let subscriber = () => {} + + if (auth) { + subscriber = auth.onAuthStateChanged(onAuthStateChanged) + } + return () => subscriber() + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + const { defaultScreens, userScreens } = useScreens() + + const linking = useMemo(() => { + return { + prefixes: [prefix, `https://${skeetCloudConfig.app.appDomain}/`], + config: { + screens: { + Default: { + path: '', + screens: defaultScreens, + }, + User: { + path: 'user', + screens: userScreens, + }, + }, + }, + } + }, [defaultScreens, userScreens]) + + return ( + <> + + `${t(`routes.${route?.name}`)} - ${t('appTitle')}`, + }} + fallback={} + linking={linking} + > + + {auth?.currentUser?.emailVerified && user.emailVerified ? ( + + ) : ( + + )} + + + + ) +} diff --git a/with-skeet-firebase/src/routes/UserRoutes.tsx b/with-skeet-firebase/src/routes/UserRoutes.tsx new file mode 100644 index 00000000..57f4ad65 --- /dev/null +++ b/with-skeet-firebase/src/routes/UserRoutes.tsx @@ -0,0 +1,41 @@ +import UserOpenAiChatScreen from '@/screens/user/UserOpenAiChatScreen' +import UserSettingsScreen from '@/screens/user/UserSettingsScreen' +import { createNativeStackNavigator } from '@react-navigation/native-stack' +import { + ChatBubbleLeftRightIcon, + Cog8ToothIcon, +} from 'react-native-heroicons/outline' + +export const userRoutes = [ + { + name: 'OpenAiChat', + component: UserOpenAiChatScreen, + icon: ChatBubbleLeftRightIcon, + }, + { + name: 'Settings', + component: UserSettingsScreen, + icon: Cog8ToothIcon, + }, +] + +const Stack = createNativeStackNavigator() + +export default function UserRoutes() { + return ( + <> + + {userRoutes.map((route) => ( + + ))} + + + ) +} diff --git a/with-skeet-firebase/src/screens/ActionScreen.tsx b/with-skeet-firebase/src/screens/ActionScreen.tsx new file mode 100644 index 00000000..ef4febba --- /dev/null +++ b/with-skeet-firebase/src/screens/ActionScreen.tsx @@ -0,0 +1,61 @@ +import { Suspense, useMemo } from 'react' +import DefaultLayout from '@/layouts/default/DefaultLayout' +import useAnalytics from '@/hooks/useAnalytics' +import { RouteProp, useRoute } from '@react-navigation/native' +import { RootStackParamList } from '@/routes/Routes' +import InvalidParamsError from '@/components/error/InvalidParamsError' +import ResetPasswordAction from '@/components/screens/default/action/ResetPasswordAction' +import VerifyEmailAction from '@/components/screens/default/action/VerifyEmailAction' +import useColorModeRefresh from '@/hooks/useColorModeRefresh' + +type ActionScreenRouteProp = RouteProp + +export default function ActionScreen() { + useColorModeRefresh() + useAnalytics() + const route: ActionScreenRouteProp = useRoute() + const mode = useMemo(() => route.params?.mode ?? undefined, [route.params]) + const oobCode = useMemo( + () => route.params?.oobCode ?? undefined, + [route.params] + ) + + if (!mode || !oobCode) { + return ( + <> + + + + + ) + } + + if (mode !== 'resetPassword' && mode !== 'verifyEmail') { + return ( + <> + + + + + ) + } + + return ( + <> + {mode === 'resetPassword' && ( + + + + + + )} + {mode === 'verifyEmail' && ( + + + + + + )} + + ) +} diff --git a/with-skeet-firebase/src/screens/CheckEmailScreen.tsx b/with-skeet-firebase/src/screens/CheckEmailScreen.tsx new file mode 100644 index 00000000..48be7e7a --- /dev/null +++ b/with-skeet-firebase/src/screens/CheckEmailScreen.tsx @@ -0,0 +1,51 @@ +import { Pressable, Text, View } from 'react-native' +import DefaultLayout from '@/layouts/default/DefaultLayout' +import tw from '@/lib/tailwind' +import { useTranslation } from 'react-i18next' +import useColorModeRefresh from '@/hooks/useColorModeRefresh' +import { useNavigation } from '@react-navigation/native' +import { EnvelopeIcon } from 'react-native-heroicons/outline' +import useAnalytics from '@/hooks/useAnalytics' + +export default function CheckEmailScreen() { + useColorModeRefresh() + useAnalytics() + const { t } = useTranslation() + const navigation = useNavigation() + return ( + <> + + + + + + {t('confirmEmail')} + + + {t('thanksForRequest')} + + { + navigation.navigate('Login') + }} + > + + {t('backToLogin')} + + + + + + + ) +} diff --git a/with-skeet-firebase/src/screens/LoginScreen.tsx b/with-skeet-firebase/src/screens/LoginScreen.tsx new file mode 100644 index 00000000..3469b82e --- /dev/null +++ b/with-skeet-firebase/src/screens/LoginScreen.tsx @@ -0,0 +1,247 @@ +import { Pressable, Text, View } from 'react-native' +import DefaultLayout from '@/layouts/default/DefaultLayout' +import tw from '@/lib/tailwind' +import { useTranslation } from 'react-i18next' +import useColorModeRefresh from '@/hooks/useColorModeRefresh' +import LogoHorizontal from '@/components/common/atoms/LogoHorizontal' +import { useNavigation } from '@react-navigation/native' +import { TextInput } from 'react-native-gesture-handler' +import { useCallback, useState, useEffect, useMemo } from 'react' +import Toast from 'react-native-toast-message' +import useAnalytics from '@/hooks/useAnalytics' +import { + signInWithEmailAndPassword, + sendEmailVerification, + signOut, +} from 'firebase/auth' +import { emailSchema, passwordSchema } from '@/utils/form' +import { auth, db } from '@/lib/firebase' +import Button from '@/components/common/atoms/Button' +import clsx from 'clsx' + +export default function LoginScreen() { + useColorModeRefresh() + useAnalytics() + const { t } = useTranslation() + const navigation = useNavigation() + const [isLoading, setLoading] = useState(false) + const [email, setEmail] = useState('') + const [emailError, setEmailError] = useState('') + const validateEmail = useCallback(() => { + try { + emailSchema.parse(email) + setEmailError('') + } catch (err) { + setEmailError('emailErrorText') + } + }, [email, setEmailError]) + + const [password, setPassword] = useState('') + const [passwordError, setPasswordError] = useState('') + const validatePassword = useCallback(() => { + try { + passwordSchema.parse(password) + setPasswordError('') + } catch (err) { + setPasswordError('passwordErrorText') + } + }, [password, setPasswordError]) + + useEffect(() => { + if (email.length > 0) validateEmail() + }, [email, validateEmail]) + + useEffect(() => { + if (password.length > 0) validatePassword() + }, [password, validatePassword]) + + const login = useCallback(async () => { + if (auth && emailError === '' && passwordError === '' && db) { + try { + setLoading(true) + const userCredential = await signInWithEmailAndPassword( + auth, + email, + password + ) + + if (!userCredential.user.emailVerified) { + await sendEmailVerification(userCredential.user) + await signOut(auth) + throw new Error('Not verified') + } + + Toast.show({ + type: 'success', + text1: t('succeedLogin') ?? 'Succeed to sign in🎉', + text2: t('howdy') ?? 'Howdy?', + }) + } catch (err) { + console.error(err) + if (err instanceof Error && err.message === 'Not verified') { + Toast.show({ + type: 'error', + text1: t('errorNotVerifiedTitle') ?? 'Not verified.', + text2: + t('errorNotVerifiedBody') ?? + 'Sent email to verify. Please check your email box.', + }) + } else if ( + err instanceof Error && + err.message.includes('auth/user-not-found') + ) { + Toast.show({ + type: 'error', + text1: t('userNotFoundTitle') ?? 'User not found', + text2: + t('userNotFoundBody') ?? + 'This email address is not registered. Please try to sign up.', + }) + } else { + Toast.show({ + type: 'error', + text1: t('errorLoginTitle') ?? 'Failed to sign in.', + text2: + t('errorLoginBody') ?? + 'Something went wrong... Please try it again.', + }) + } + if (auth?.currentUser) { + signOut(auth) + } + } finally { + setLoading(false) + } + } + }, [t, email, password, emailError, passwordError]) + + const isDisabled = useMemo( + () => + isLoading || + emailError != '' || + passwordError != '' || + email == '' || + password == '', + [isLoading, emailError, passwordError, email, password] + ) + + return ( + <> + + + + + + + + {t('loginToYourAccount')} + + { + navigation.navigate('Register') + }} + > + + {t('or')}{' '} + + {t('registerYourAccount')} + + + + + + + + + {t('email')} + {emailError !== '' && ( + + {' : '} + {t(emailError)} + + )} + + + + + + + + {t('password')} + {passwordError !== '' && ( + + {' : '} + {t(passwordError)} + + )} + + + + + + + + + + { + navigation.navigate('ResetPassword') + }} + > + + {t('forgotYourPassword')} + + + + + + + + + + + + + ) +} diff --git a/with-skeet-firebase/src/screens/RegisterScreen.tsx b/with-skeet-firebase/src/screens/RegisterScreen.tsx new file mode 100644 index 00000000..8cb78791 --- /dev/null +++ b/with-skeet-firebase/src/screens/RegisterScreen.tsx @@ -0,0 +1,254 @@ +import { Pressable, Text, View } from 'react-native' +import DefaultLayout from '@/layouts/default/DefaultLayout' +import tw, { colors } from '@/lib/tailwind' +import { useTranslation } from 'react-i18next' +import useColorModeRefresh from '@/hooks/useColorModeRefresh' +import LogoHorizontal from '@/components/common/atoms/LogoHorizontal' +import { useNavigation } from '@react-navigation/native' +import { TextInput } from 'react-native-gesture-handler' +import clsx from 'clsx' +import { useCallback, useMemo, useState, useEffect } from 'react' +import { openUrl } from '@/utils/link' +import Checkbox from 'expo-checkbox' +import useAnalytics from '@/hooks/useAnalytics' +import { + createUserWithEmailAndPassword, + sendEmailVerification, + signOut, +} from 'firebase/auth' +import Toast from 'react-native-toast-message' +import { emailSchema, passwordSchema } from '@/utils/form' +import { auth } from '@/lib/firebase' +import Button from '@/components/common/atoms/Button' + +export default function RegisterScreen() { + useColorModeRefresh() + useAnalytics() + const { t, i18n } = useTranslation() + const navigation = useNavigation() + const [isChecked, setChecked] = useState(false) + const [isLoading, setLoading] = useState(false) + const [email, setEmail] = useState('') + const [emailError, setEmailError] = useState('') + const validateEmail = useCallback(() => { + try { + emailSchema.parse(email) + setEmailError('') + } catch (err) { + setEmailError('emailErrorText') + } + }, [email, setEmailError]) + + const [password, setPassword] = useState('') + const [passwordError, setPasswordError] = useState('') + const validatePassword = useCallback(() => { + try { + passwordSchema.parse(password) + setPasswordError('') + } catch (err) { + setPasswordError('passwordErrorText') + } + }, [password, setPasswordError]) + + useEffect(() => { + if (email.length > 0) validateEmail() + }, [email, validateEmail]) + + useEffect(() => { + if (password.length > 0) validatePassword() + }, [password, validatePassword]) + + const signUp = useCallback(async () => { + if (auth && emailError === '' && passwordError === '') { + try { + setLoading(true) + auth.languageCode = i18n.language === 'ja-JP' ? 'ja' : 'en' + const userCredential = await createUserWithEmailAndPassword( + auth, + email, + password + ) + await sendEmailVerification(userCredential.user) + await signOut(auth) + + Toast.show({ + type: 'success', + text1: t('sentConfirmEmailTitle') ?? 'Sent confirmation email', + text2: + t('sentConfirmEmailBody') ?? + 'Thank you for your registration. Please check your email.', + }) + navigation.navigate('CheckEmail') + } catch (err) { + console.error(err) + + if ( + err instanceof Error && + err.message.includes('Firebase: Error (auth/email-already-in-use).') + ) { + Toast.show({ + type: 'error', + text1: t('alreadyExistTitle') ?? 'Already exist', + text2: + t('alreadyExistBody') ?? + 'This email address is already exist. Please try to sign in.', + }) + } else { + Toast.show({ + type: 'error', + text1: t('errorLoginTitle') ?? 'Failed to sign in.', + text2: + t('errorLoginBody') ?? + 'Something went wrong... Please try it again.', + }) + } + } finally { + setLoading(false) + } + } + }, [emailError, passwordError, t, email, password, navigation, i18n.language]) + + const isDisabled = useMemo( + () => + !isChecked || + isLoading || + emailError !== '' || + passwordError !== '' || + email === '' || + password === '', + [isChecked, isLoading, emailError, passwordError, email, password] + ) + + return ( + <> + + + + + + + + + {t('registerYourAccount')} + + { + navigation.navigate('Login') + }} + > + + {t('or')}{' '} + + {t('loginToYourAccount')} + + + + + + + + + {t('email')} + {emailError !== '' && ( + + {' : '} + {t(emailError)} + + )} + + + + + + + + {t('password')} + {passwordError !== '' && ( + + {' : '} + {t(passwordError)} + + )} + + + + + + + + + + + { + openUrl('https://skeet.dev/legal/privacy-policy') + }} + > + + + {t('agreeOn')}{' '} + + + {t('privacy')} + + + + + + + + + + + + + + ) +} diff --git a/with-skeet-firebase/src/screens/ResetPasswordScreen.tsx b/with-skeet-firebase/src/screens/ResetPasswordScreen.tsx new file mode 100644 index 00000000..abbafb8d --- /dev/null +++ b/with-skeet-firebase/src/screens/ResetPasswordScreen.tsx @@ -0,0 +1,173 @@ +import { Pressable, Text, View } from 'react-native' +import DefaultLayout from '@/layouts/default/DefaultLayout' +import tw from '@/lib/tailwind' +import { useTranslation } from 'react-i18next' +import useColorModeRefresh from '@/hooks/useColorModeRefresh' +import LogoHorizontal from '@/components/common/atoms/LogoHorizontal' +import { useNavigation } from '@react-navigation/native' +import { TextInput } from 'react-native-gesture-handler' +import { useCallback, useState, useEffect, useMemo } from 'react' +import Toast from 'react-native-toast-message' +import useAnalytics from '@/hooks/useAnalytics' +import Button from '@/components/common/atoms/Button' +import { emailSchema } from '@/utils/form' +import clsx from 'clsx' +import { auth } from '@/lib/firebase' +import { sendPasswordResetEmail } from 'firebase/auth' + +export default function ResetPasswordScreen() { + useColorModeRefresh() + useAnalytics() + const { t } = useTranslation() + const navigation = useNavigation() + const [isLoading, setLoading] = useState(false) + const [email, setEmail] = useState('') + const [emailError, setEmailError] = useState('') + const validateEmail = useCallback(() => { + try { + emailSchema.parse(email) + setEmailError('') + } catch (err) { + setEmailError('emailErrorText') + } + }, [email, setEmailError]) + + useEffect(() => { + if (email.length > 0) validateEmail() + }, [email, validateEmail]) + + const resetPassword = useCallback(async () => { + if (auth && emailError === '') { + try { + setLoading(true) + await sendPasswordResetEmail(auth, email) + Toast.show({ + type: 'success', + text1: + t('sentResetPasswordRequest') ?? 'Succeed Reset Password Request', + text2: t('confirmEmail') ?? 'Check your email', + }) + navigation.navigate('CheckEmail') + } catch (err) { + console.error(err) + if (err instanceof Error && err.message === 'Not verified') { + Toast.show({ + type: 'error', + text1: t('errorNotVerifiedTitle') ?? 'Not verified.', + text2: + t('errorNotVerifiedBody') ?? + 'Sent email to verify. Please check your email box.', + }) + } else if ( + err instanceof Error && + err.message.includes('auth/user-not-found') + ) { + Toast.show({ + type: 'error', + text1: t('userNotFoundTitle') ?? 'User not found', + text2: + t('userNotFoundBody') ?? + 'This email address is not registered. Please try to sign up.', + }) + } else { + Toast.show({ + type: 'error', + text1: t('errorLoginTitle') ?? 'Failed to sign in.', + text2: + t('errorLoginBody') ?? + 'Something went wrong... Please try it again.', + }) + } + } finally { + setLoading(false) + } + } + }, [navigation, t, emailError, email]) + + const isDisabled = useMemo( + () => isLoading || emailError !== '' || email == '', + [isLoading, email, emailError] + ) + + return ( + <> + + + + + + + + {t('resetYourPassword')} + + { + navigation.navigate('Register') + }} + > + + {t('or')}{' '} + + {t('registerYourAccount')} + + + + + + + + + {t('email')} + {emailError !== '' && ( + + {' : '} + {t(emailError)} + + )} + + + + + + + + + + + + + + ) +} diff --git a/with-skeet-firebase/src/screens/user/UserOpenAiChatScreen.tsx b/with-skeet-firebase/src/screens/user/UserOpenAiChatScreen.tsx new file mode 100644 index 00000000..ad2109ae --- /dev/null +++ b/with-skeet-firebase/src/screens/user/UserOpenAiChatScreen.tsx @@ -0,0 +1,113 @@ +import { View } from 'react-native' +import UserLayout from '@/layouts/user/UserLayout' +import tw from '@/lib/tailwind' + +import useColorModeRefresh from '@/hooks/useColorModeRefresh' +import useAnalytics from '@/hooks/useAnalytics' +import ChatMenu, { + ChatRoom, +} from '@/components/screens/user/openAiChat/ChatMenu' +import ChatBox from '@/components/screens/user/openAiChat/ChatBox' +import { useCallback, useEffect, useState } from 'react' +import { useRecoilValue } from 'recoil' +import { userState } from '@/store/user' +import { useTranslation } from 'react-i18next' +import { + DocumentData, + QueryDocumentSnapshot, + collection, + getDocs, + limit, + orderBy, + query, +} from 'firebase/firestore' +import { db } from '@/lib/firebase' +import Toast from 'react-native-toast-message' + +export default function UserOpenAiChatScreen() { + useColorModeRefresh() + useAnalytics() + const { t } = useTranslation() + + const [isNewChatModalOpen, setNewChatModalOpen] = useState(false) + const [currentChatRoomId, setCurrentChatRoomId] = useState( + null + ) + + const user = useRecoilValue(userState) + + const [chatList, setChatList] = useState([]) + const [lastChat, setLastChat] = + useState | null>(null) + const [isDataLoading, setDataLoading] = useState(false) + + const getChatRooms = useCallback(async () => { + if (db) { + try { + setDataLoading(true) + const q = query( + collection(db, `User/${user.uid}/UserChatRoom`), + orderBy('createdAt', 'desc'), + limit(15) + ) + + const querySnapshot = await getDocs(q) + const list: ChatRoom[] = [] + querySnapshot.forEach((doc) => { + const data = doc.data() + list.push({ id: doc.id, ...data } as ChatRoom) + }) + setChatList(list) + setLastChat(querySnapshot.docs[querySnapshot.docs.length - 1]) + setDataLoading(false) + } catch (err) { + console.log(err) + if (err instanceof Error && err.message.includes('permission-denied')) { + Toast.show({ + type: 'error', + text1: t('errorTokenExpiredTitle') ?? 'Token Expired.', + text2: t('errorTokenExpiredBody') ?? 'Please sign in again.', + }) + } else { + Toast.show({ + type: 'error', + text1: t('errorTitle') ?? 'Error', + text2: + t('errorBody') ?? 'Something went wrong... Please try it again.', + }) + } + } + } + }, [user.uid, t, setDataLoading, setChatList, setLastChat]) + + useEffect(() => { + getChatRooms() + }, [getChatRooms]) + + return ( + <> + + + + + + + + ) +} diff --git a/with-skeet-firebase/src/screens/user/UserSettingsScreen.tsx b/with-skeet-firebase/src/screens/user/UserSettingsScreen.tsx new file mode 100644 index 00000000..bf19ddc8 --- /dev/null +++ b/with-skeet-firebase/src/screens/user/UserSettingsScreen.tsx @@ -0,0 +1,59 @@ +import LanguageChanger from '@/components/utils/LanguageChanger' +import tw from '@/lib/tailwind' +import { Text, View } from 'react-native' +import ColorModeChanger from '@/components/utils/ColorModeChanger' +import useColorModeRefresh from '@/hooks/useColorModeRefresh' +import { useTranslation } from 'react-i18next' +import UserLayout from '@/layouts/user/UserLayout' +import useAnalytics from '@/hooks/useAnalytics' +import EditUserIconUrl from '@/components/screens/user/settings/EditUserIconUrl' +import EditUserProfile from '@/components/screens/user/settings/EditUserProfile' +import { ScrollView } from 'react-native-gesture-handler' + +export default function UserSettingsScreen() { + useColorModeRefresh() + useAnalytics() + const { t } = useTranslation() + + return ( + <> + + + + + + + {t('settings.title')} + + + + + + + + + + + + + + + + + + + + + + ) +} diff --git a/with-skeet-firebase/src/store/colorModeRefresh.ts b/with-skeet-firebase/src/store/colorModeRefresh.ts new file mode 100644 index 00000000..a6a1df50 --- /dev/null +++ b/with-skeet-firebase/src/store/colorModeRefresh.ts @@ -0,0 +1,12 @@ +import { atom } from 'recoil' + +export type ColorModeRefreshState = { + mode: 'light' | 'dark' | undefined +} + +export const colorModeRefreshState = atom({ + key: 'colorModeRefreshState', + default: { + mode: undefined, + }, +}) diff --git a/with-skeet-firebase/src/store/user.ts b/with-skeet-firebase/src/store/user.ts new file mode 100644 index 00000000..a8089d43 --- /dev/null +++ b/with-skeet-firebase/src/store/user.ts @@ -0,0 +1,22 @@ +import { atom } from 'recoil' + +export type UserState = { + uid: string + email: string + username: string + iconUrl: string + emailVerified: boolean +} + +export const defaultUser = { + uid: '', + email: '', + username: '', + iconUrl: '', + emailVerified: false, +} + +export const userState = atom({ + key: 'userState', + default: defaultUser, +}) diff --git a/with-skeet-firebase/src/types/http/openai/addStreamUserChatRoomMessageParams.ts b/with-skeet-firebase/src/types/http/openai/addStreamUserChatRoomMessageParams.ts new file mode 100644 index 00000000..4dcc5417 --- /dev/null +++ b/with-skeet-firebase/src/types/http/openai/addStreamUserChatRoomMessageParams.ts @@ -0,0 +1,5 @@ +export type AddStreamUserChatRoomMessageParams = { + userChatRoomId: string + content: string + isFirstMessage: boolean +} diff --git a/with-skeet-firebase/src/types/http/openai/addUserChatRoomMessageParams.ts b/with-skeet-firebase/src/types/http/openai/addUserChatRoomMessageParams.ts new file mode 100644 index 00000000..0b654672 --- /dev/null +++ b/with-skeet-firebase/src/types/http/openai/addUserChatRoomMessageParams.ts @@ -0,0 +1,6 @@ +// default: {userChatRoomId: 'rooomid', content: 'message'} +export type AddUserChatRoomMessageParams = { + userChatRoomId: string + content: string + isFirstMessage: boolean +} diff --git a/with-skeet-firebase/src/types/http/openai/createUserChatRoomParams.ts b/with-skeet-firebase/src/types/http/openai/createUserChatRoomParams.ts new file mode 100644 index 00000000..5acc3baa --- /dev/null +++ b/with-skeet-firebase/src/types/http/openai/createUserChatRoomParams.ts @@ -0,0 +1,7 @@ +export type CreateUserChatRoomParams = { + model?: string + systemContent?: string + maxTokens?: number + temperature?: number + stream?: boolean +} diff --git a/with-skeet-firebase/src/types/http/openai/getUserChatRoomParams.ts b/with-skeet-firebase/src/types/http/openai/getUserChatRoomParams.ts new file mode 100644 index 00000000..0d2926fd --- /dev/null +++ b/with-skeet-firebase/src/types/http/openai/getUserChatRoomParams.ts @@ -0,0 +1,3 @@ +export type GetUserChatRoomMessagesParams = { + userChatRoomId: string +} diff --git a/with-skeet-firebase/src/types/http/openai/rootParams.ts b/with-skeet-firebase/src/types/http/openai/rootParams.ts new file mode 100644 index 00000000..843ede34 --- /dev/null +++ b/with-skeet-firebase/src/types/http/openai/rootParams.ts @@ -0,0 +1,3 @@ +export type RootParams = { + name?: string +} diff --git a/with-skeet-firebase/src/types/models/index.ts b/with-skeet-firebase/src/types/models/index.ts new file mode 100644 index 00000000..aa3df742 --- /dev/null +++ b/with-skeet-firebase/src/types/models/index.ts @@ -0,0 +1 @@ +export * from './userModels' diff --git a/with-skeet-firebase/src/types/models/userModels.ts b/with-skeet-firebase/src/types/models/userModels.ts new file mode 100644 index 00000000..2cac836e --- /dev/null +++ b/with-skeet-firebase/src/types/models/userModels.ts @@ -0,0 +1,35 @@ +import { Ref, Timestamp } from '@skeet-framework/firestore' + +// CollectionId: User +// DocumentId: uid +export type User = { + uid: string + username: string + email: string + iconUrl: string + createdAt?: Timestamp + updatedAt?: Timestamp +} + +// CollectionId: UserChatRoom +// DocumentId: auto +export type UserChatRoom = { + userRef: Ref + title: string + model: string + maxTokens: number + temperature: number + stream: boolean + createdAt?: Timestamp + updatedAt?: Timestamp +} + +// CollectionId: UserChatRoomMessage +// DocumentId: auto +export type UserChatRoomMessage = { + userChatRoomRef: Ref + role: string + content: string + createdAt?: Timestamp + updatedAt?: Timestamp +} diff --git a/with-skeet-firebase/src/utils/character.ts b/with-skeet-firebase/src/utils/character.ts new file mode 100644 index 00000000..d5f973c5 --- /dev/null +++ b/with-skeet-firebase/src/utils/character.ts @@ -0,0 +1,27 @@ +export function toCamelCase(str: string): string { + return str + .replace(/[-_\s](\w)/g, (_, char) => { + return char.toUpperCase() + }) + .replace(/^\w/, (firstChar) => { + return firstChar.toLowerCase() + }) +} + +export function toKebabCase(str: string): string { + return str + .replace(/([a-z])([A-Z])/g, '$1-$2') + .replace(/\s+/g, '-') + .replace(/_/g, '-') + .toLowerCase() +} + +export function toPascalCase(str: string): string { + return str.replace(/[-_\s](\w)|(\w+)/g, (_, char, word) => { + if (word) { + return word.charAt(0).toUpperCase() + word.slice(1).toLowerCase() + } else { + return char.toUpperCase() + } + }) +} diff --git a/with-skeet-firebase/src/utils/form.ts b/with-skeet-firebase/src/utils/form.ts new file mode 100644 index 00000000..4720ee5f --- /dev/null +++ b/with-skeet-firebase/src/utils/form.ts @@ -0,0 +1,17 @@ +import * as z from 'zod' + +export const emailSchema = z.string().email() +export const passwordSchema = z.string().min(8) +export const usernameSchema = z.string().min(1).max(20) + +export type GPTModel = 'gpt-3.5-turbo' | 'gpt-4' +export const allowedGPTModel: GPTModel[] = ['gpt-3.5-turbo', 'gpt-4'] +export const gptModelSchema = z.union([ + z.literal('gpt-3.5-turbo'), + z.literal('gpt-4'), +]) + +export const maxTokensSchema = z.number().int().min(100).max(4096) +export const temperatureSchema = z.number().min(0).max(2) +export const systemContentSchema = z.string().min(1).max(1000) +export const chatContentSchema = z.string().min(1).max(100000) diff --git a/with-skeet-firebase/src/utils/link.ts b/with-skeet-firebase/src/utils/link.ts new file mode 100644 index 00000000..8312f80d --- /dev/null +++ b/with-skeet-firebase/src/utils/link.ts @@ -0,0 +1,16 @@ +import { Linking } from 'react-native' +import i18n from '@/lib/i18n' +import Toast from 'react-native-toast-message' + +export async function openUrl(url: string) { + const supported = await Linking.canOpenURL(url) + if (supported) { + await Linking.openURL(url) + } else { + Toast.show({ + type: 'error', + text1: i18n.t('linkError') ?? 'Link Error', + text2: i18n.t('urlError') ?? 'Could not open the link', + }) + } +} diff --git a/with-skeet-firebase/src/utils/placeholder.ts b/with-skeet-firebase/src/utils/placeholder.ts new file mode 100644 index 00000000..f5f971dd --- /dev/null +++ b/with-skeet-firebase/src/utils/placeholder.ts @@ -0,0 +1,2 @@ +export const blurhash = + '|rF?hV%2WCj[ayj[a|j[az_NaeWBj@ayfRayfQfQM{M|azj[azf6fQfQfQIpWXofj[ayj[j[fQayWCoeoeaya}j[ayfQa{oLj?j[WVj[ayayj[fQoff7azayj[ayj[j[ayofayayayj[fQj[ayayj[ayfjj[j[ayjuayj[' diff --git a/with-skeet-firebase/src/utils/storage.ts b/with-skeet-firebase/src/utils/storage.ts new file mode 100644 index 00000000..a5caee0d --- /dev/null +++ b/with-skeet-firebase/src/utils/storage.ts @@ -0,0 +1,16 @@ +export async function getImageBlob(uri: string) { + const blob = await new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest() + xhr.onload = function () { + resolve(xhr.response) + } + xhr.onerror = function (e) { + console.error(e) + reject(new TypeError('Network request failed')) + } + xhr.responseType = 'blob' + xhr.open('GET', uri, true) + xhr.send(null) + }) + return blob +} diff --git a/with-skeet-firebase/src/utils/time.ts b/with-skeet-firebase/src/utils/time.ts new file mode 100644 index 00000000..440dc9e7 --- /dev/null +++ b/with-skeet-firebase/src/utils/time.ts @@ -0,0 +1,3 @@ +export const sleep = async (mSec: number) => { + await new Promise((resolve) => setTimeout(resolve, mSec)) +} diff --git a/with-skeet-firebase/storage.rules b/with-skeet-firebase/storage.rules new file mode 100644 index 00000000..6d9f4028 --- /dev/null +++ b/with-skeet-firebase/storage.rules @@ -0,0 +1,8 @@ +service firebase.storage { + match /b/{bucket}/o { + match /User/{userId}/profileIcon/{allPaths=**} { + allow read: if request.auth != null; + allow write: if request.auth.uid == userId; + } + } +} \ No newline at end of file diff --git a/with-skeet-firebase/tailwind.config.js b/with-skeet-firebase/tailwind.config.js new file mode 100644 index 00000000..d94a4de3 --- /dev/null +++ b/with-skeet-firebase/tailwind.config.js @@ -0,0 +1,24 @@ +/** @type {import('tailwindcss').Config} */ +const defaultTheme = require('tailwindcss/defaultTheme') + +module.exports = { + content: ['./src/**/*.{js,ts,jsx,tsx}'], + theme: { + container: { + center: true, + }, + extend: { + fontFamily: { + 'loaded-light': ['Outfit_300Light', ...defaultTheme.fontFamily.sans], + 'loaded-normal': ['Outfit_400Regular', ...defaultTheme.fontFamily.sans], + 'loaded-medium': ['Outfit_500Medium', ...defaultTheme.fontFamily.sans], + 'loaded-bold': ['Outfit_700Bold', ...defaultTheme.fontFamily.sans], + }, + height: { + 'screen-bar-xs': '82vh', + 'screen-bar': '90vh', + }, + }, + }, + plugins: [], +} diff --git a/with-skeet-firebase/tests/check.test.ts b/with-skeet-firebase/tests/check.test.ts new file mode 100644 index 00000000..de4220ba --- /dev/null +++ b/with-skeet-firebase/tests/check.test.ts @@ -0,0 +1,3 @@ +test('check', () => { + console.log('OK') +}) diff --git a/with-skeet-firebase/tests/http/addStreamUserChatRoomMessage.test.ts b/with-skeet-firebase/tests/http/addStreamUserChatRoomMessage.test.ts new file mode 100644 index 00000000..1adbbf59 --- /dev/null +++ b/with-skeet-firebase/tests/http/addStreamUserChatRoomMessage.test.ts @@ -0,0 +1,52 @@ +import { AddStreamUserChatRoomMessageParams } from '@/types/http/openai/addStreamUserChatRoomMessageParams' +import { CreateUserChatRoomParams } from '@/types/http/openai/createUserChatRoomParams' +import { postFetch } from '../jest.setup' + +let userChatRoomId = '' +describe('addStreamUserChatRoomMessage', () => { + it('createUserChatRoom', async () => { + const requestBody: CreateUserChatRoomParams = { + model: 'gpt-3.5-turbo', + systemContent: + 'This is a great chatbot. This Assistant is very kind and helpful.', + maxTokens: 50, + temperature: 1, + stream: true, + } + const endpoint = '/createUserChatRoom' + const response = await postFetch( + endpoint, + requestBody + ) + const data = await response.json() + expect(response.status).toEqual(200) + expect(data).toEqual( + expect.objectContaining({ + status: 'success', + userChatRoomRef: expect.any(Object), + userChatRoomMessageRef: expect.any(Object), + }) + ) + userChatRoomId = data.userChatRoomRef.id + }) + + it('addStreamUserChatRoomMessage', async () => { + const requestBody: AddStreamUserChatRoomMessageParams = { + userChatRoomId, + content: 'Hello Test!', + } + const endpoint = '/addStreamUserChatRoomMessage' + const response = await postFetch( + endpoint, + requestBody + ) + const data = await response.json() + expect(response.status).toEqual(200) + expect(data).toEqual( + expect.objectContaining({ + status: 'streaming', + userChatRoomMessageId: expect.any(String), + }) + ) + }) +}) diff --git a/with-skeet-firebase/tests/http/addUserChatRoomMessage.test.ts b/with-skeet-firebase/tests/http/addUserChatRoomMessage.test.ts new file mode 100644 index 00000000..dfb4e1fd --- /dev/null +++ b/with-skeet-firebase/tests/http/addUserChatRoomMessage.test.ts @@ -0,0 +1,52 @@ +import { AddUserChatRoomMessageParams } from '@/types/http/openai/addUserChatRoomMessageParams' +import { CreateUserChatRoomParams } from '@/types/http/openai/createUserChatRoomParams' +import { postFetch } from '../jest.setup' + +let userChatRoomId = '' + +describe('POST with Bearer Token /addUserChatRoomMessage', () => { + it('createUserChatRoom', async () => { + const requestBody: CreateUserChatRoomParams = { + model: 'gpt-3.5-turbo', + systemContent: + 'This is a great chatbot. This Assistant is very kind and helpful.', + maxTokens: 50, + temperature: 1, + stream: false, + } + const endpoint = '/createUserChatRoom' + const response = await postFetch( + endpoint, + requestBody + ) + const data = await response.json() + expect(response.status).toEqual(200) + expect(data).toEqual( + expect.objectContaining({ + status: 'success', + userChatRoomRef: expect.any(Object), + userChatRoomMessageRef: expect.any(Object), + }) + ) + userChatRoomId = data.userChatRoomRef.id + }) + + it('addUserChatRoomMessage', async () => { + const requestBody: AddUserChatRoomMessageParams = { + userChatRoomId, + content: 'Hello Test!', + } + const endpoint = '/addUserChatRoomMessage' + const response = await postFetch( + endpoint, + requestBody + ) + const data = await response.json() + expect(response.status).toEqual(200) + expect(data).toEqual( + expect.objectContaining({ + status: 'success', + }) + ) + }) +}) diff --git a/with-skeet-firebase/tests/http/createUserChatRoom.test.ts b/with-skeet-firebase/tests/http/createUserChatRoom.test.ts new file mode 100644 index 00000000..8e49651d --- /dev/null +++ b/with-skeet-firebase/tests/http/createUserChatRoom.test.ts @@ -0,0 +1,48 @@ +import { CreateUserChatRoomParams } from '@/types/http/openai/createUserChatRoomParams' +import { postFetch } from '../jest.setup' + +describe('POST without Bearer Token /createUserChatRoom', () => { + it('Responds with Auth Error', async () => { + const requestBody: CreateUserChatRoomParams = { + maxTokens: 50, + } + const endpoint = '/createUserChatRoom' + const response = await postFetch( + endpoint, + requestBody, + false + ) + const data = await response.json() + expect(response.status).toEqual(500) + + expect(data.status).toEqual('error') + expect(data.message).toContain('Error: getUserAuth:') + }) +}) + +describe('POST with Bearer Token /createUserChatRoom', () => { + it('Responds with Success', async () => { + const requestBody: CreateUserChatRoomParams = { + model: 'gpt-3.5-turbo', + systemContent: + 'This is a great chatbot. This Assistant is very kind and helpful.', + maxTokens: 50, + temperature: 1, + stream: false, + } + const endpoint = '/createUserChatRoom' + const response = await postFetch( + endpoint, + requestBody + ) + const data = await response.json() + expect(response.status).toEqual(200) + expect(data).toEqual( + expect.objectContaining({ + status: 'success', + userChatRoomRef: expect.any(Object), + userChatRoomMessageRef: expect.any(Object), + }) + ) + }) +}) diff --git a/with-skeet-firebase/tests/http/getUserChatRoomMessages.test.ts b/with-skeet-firebase/tests/http/getUserChatRoomMessages.test.ts new file mode 100644 index 00000000..6d4b3e9f --- /dev/null +++ b/with-skeet-firebase/tests/http/getUserChatRoomMessages.test.ts @@ -0,0 +1,51 @@ +import { AddUserChatRoomMessageParams } from '@/types/http/openai/addUserChatRoomMessageParams' +import { postFetch } from '../jest.setup' +import { CreateUserChatRoomParams } from '@/types/http/openai/createUserChatRoomParams' + +let userChatRoomId = '' +describe('getUserChatRoomMessages', () => { + it('createUserChatRoom', async () => { + const requestBody: CreateUserChatRoomParams = { + model: 'gpt-3.5-turbo', + systemContent: + 'This is a great chatbot. This Assistant is very kind and helpful.', + maxTokens: 50, + temperature: 1, + stream: false, + } + const endpoint = '/createUserChatRoom' + const response = await postFetch( + endpoint, + requestBody + ) + const data = await response.json() + expect(response.status).toEqual(200) + expect(data).toEqual( + expect.objectContaining({ + status: 'success', + userChatRoomRef: expect.any(Object), + userChatRoomMessageRef: expect.any(Object), + }) + ) + userChatRoomId = data.userChatRoomRef.id + }) + + it('addUserChatRoomMessage', async () => { + const requestBody: AddUserChatRoomMessageParams = { + userChatRoomId, + content: 'Hello Test!', + } + const endpoint = '/addUserChatRoomMessage' + const response = await postFetch( + endpoint, + requestBody + ) + const data = await response.json() + expect(response.status).toEqual(200) + expect(data).toEqual( + expect.objectContaining({ + status: 'success', + }) + ) + }) +}) diff --git a/with-skeet-firebase/tests/jest.setup.ts b/with-skeet-firebase/tests/jest.setup.ts new file mode 100644 index 00000000..8cd81ca7 --- /dev/null +++ b/with-skeet-firebase/tests/jest.setup.ts @@ -0,0 +1,37 @@ +import { RequestInit } from 'node-fetch' +import dotenv from 'dotenv' +dotenv.config() + +const PROJECT_ID = process.env.PROJECT_ID || '' +const REGION = process.env.REGION || '' + +export const BASE_URL = `http://127.0.0.1:5001/${PROJECT_ID}/${REGION}` +const ACCESS_TOKEN = process.env.ACCESS_TOKEN || '' +if (ACCESS_TOKEN === '') { + console.error( + 'ACCESS_TOKEN is not defined\n Please run `skeet login` to get your access token.' + ) + process.exit(1) +} + +export const postFetch = async (path: string, params: T, isAuth = true) => { + try { + const body = JSON.stringify(params) + const headers: RequestInit['headers'] = isAuth + ? { + 'Content-Type': 'application/json', + Authorization: `Bearer ${ACCESS_TOKEN}`, + } + : { + 'Content-Type': 'application/json', + } + const response = await fetch(`${BASE_URL}${path}`, { + method: 'POST', + headers, + body, + }) + return response + } catch (error) { + throw new Error(`postFetch: ${error}`) + } +} diff --git a/with-skeet-firebase/tsconfig.json b/with-skeet-firebase/tsconfig.json new file mode 100644 index 00000000..e4295078 --- /dev/null +++ b/with-skeet-firebase/tsconfig.json @@ -0,0 +1,15 @@ +{ + "extends": "expo/tsconfig.base", + "compilerOptions": { + "strict": true, + "baseUrl": ".", + "paths": { + "@/*": ["./src/*"], + "@assets/*": ["./assets/*"], + "@lib/*": ["lib/*"], + "@root/*": ["./*"], + } + }, + "include": ["**/*.ts", "**/*.tsx", "src/**/*", "lib/**/*", "custom.d.ts", "App.tsx"], + "exclude": ["node_modules", ".expo", "dist"] +} diff --git a/with-skeet-firebase/webpack.config.js b/with-skeet-firebase/webpack.config.js new file mode 100644 index 00000000..c397cd8c --- /dev/null +++ b/with-skeet-firebase/webpack.config.js @@ -0,0 +1,30 @@ +const createExpoWebpackConfigAsync = require('@expo/webpack-config') + +module.exports = async function (env, argv) { + const config = await createExpoWebpackConfigAsync( + { + ...env, + }, + argv + ) + + config.module.rules.forEach((rule) => { + if (rule.oneOf) { + rule.oneOf.unshift({ + test: /\.svg$/, + exclude: /node_modules/, + use: [ + { + loader: require.resolve('@svgr/webpack'), + options: { + viewBox: false, + }, + }, + ], + }) + } + }) + config.resolve.extensions.push('.svg') + + return config +} diff --git a/with-skeet-firebase/workflows/firebase-rules.yml b/with-skeet-firebase/workflows/firebase-rules.yml new file mode 100644 index 00000000..02792f4f --- /dev/null +++ b/with-skeet-firebase/workflows/firebase-rules.yml @@ -0,0 +1,32 @@ +name: FirebaseRules +on: + push: + branches: + - main + paths: + - 'firestore.rules' + - 'firestore.indexes.json' + - 'storage.rules' + - '.github/workflows/firebase-rules.yml' + +jobs: + deploy: + name: Deploy + runs-on: ubuntu-latest + steps: + - name: Checkout Repository + uses: actions/checkout@v2 + - name: Install Node.js + uses: actions/setup-node@v2 + with: + node-version: '18.16.0' + - id: auth + uses: google-github-actions/auth@v0 + with: + credentials_json: ${{ secrets.SKEET_GCP_SA_KEY }} + - name: Install firebase tools + run: npm i -g npm firebase-tools + - name: GitHub repository setting + run: git config --global url."https://github.com".insteadOf ssh://git@github.com + - name: Deploy rules to Firebase + run: firebase deploy --only firestore:rules,storage diff --git a/with-skeet-firebase/workflows/functions-openai.yml b/with-skeet-firebase/workflows/functions-openai.yml new file mode 100644 index 00000000..534bba0d --- /dev/null +++ b/with-skeet-firebase/workflows/functions-openai.yml @@ -0,0 +1,34 @@ +name: Openai +on: + push: + branches: + - main + paths: + - 'functions/openai/**' + - '.github/workflows/functions-openai.yml' + +jobs: + deploy: + name: Deploy + runs-on: ubuntu-latest + steps: + - name: Checkout Repository + uses: actions/checkout@v2 + - name: Install Node.js + uses: actions/setup-node@v2 + with: + node-version: '18.16.0' + - id: auth + uses: google-github-actions/auth@v0 + with: + credentials_json: ${{ secrets.SKEET_GCP_SA_KEY }} + - name: Install yarn and firebase tools + run: npm i -g npm yarn firebase-tools + - name: GitHub repository setting + run: git config --global url."https://github.com".insteadOf ssh://git@github.com + - name: Install dependencies + run: cd ./functions/openai && yarn install --frozen-lockfile + - name: Build App + run: cd ./functions/openai && yarn build + - name: Deploy to Firebase + run: firebase deploy --only functions:openai diff --git a/with-skeet-firebase/workflows/webapp.yml b/with-skeet-firebase/workflows/webapp.yml new file mode 100644 index 00000000..d71f0009 --- /dev/null +++ b/with-skeet-firebase/workflows/webapp.yml @@ -0,0 +1,42 @@ +name: WebApp +on: + push: + branches: + - main + paths: + - 'src/**' + - 'assets/**' + - 'lib/**' + - '.github/workflows/webapp.yml' + - 'package.json' + - 'firebase.json' + - '.firebaserc' + - 'app.json' + - 'metro.config.js' + - 'babel.config.js' + +jobs: + deploy: + name: Deploy + runs-on: ubuntu-latest + steps: + - name: Checkout Repository + uses: actions/checkout@v2 + - name: Install Node.js + uses: actions/setup-node@v2 + with: + node-version: '18.16.0' + - id: auth + uses: google-github-actions/auth@v0 + with: + credentials_json: ${{ secrets.SKEET_GCP_SA_KEY }} + - name: Install yarn and firebase tools + run: npm i -g npm yarn firebase-tools + - name: GitHub repository setting + run: git config --global url."https://github.com".insteadOf ssh://git@github.com + - name: Install dependencies + run: yarn install --frozen-lockfile + - name: Build App + run: yarn build:production:webapp + - name: Deploy to Firebase + run: firebase deploy --only hosting