Repository: yinxin630/fiora Branch: master Commit: d741c006c5a0 Files: 319 Total size: 669.4 KB Directory structure: gitextract_efe0p5be/ ├── .dockerignore ├── .eslintignore ├── .eslintrc ├── .github/ │ └── workflows/ │ ├── codeql-analysis.yml │ ├── lint.yml │ ├── test.yml │ └── ts.yml ├── .gitignore ├── .prettierrc ├── .vscode/ │ └── settings.json ├── Dockerfile ├── LICENSE ├── README.md ├── docker-compose.yaml ├── index.ts ├── jest.config.js ├── jest.setup.js ├── jest.transformer.js ├── lerna.json ├── package.json ├── packages/ │ ├── app/ │ │ ├── .babelrc │ │ ├── .eslintrc │ │ ├── .gitignore │ │ ├── .watchmanconfig │ │ ├── App.tsx │ │ ├── app.json │ │ ├── package.json │ │ ├── src/ │ │ │ ├── App.tsx │ │ │ ├── components/ │ │ │ │ ├── Avatar.tsx │ │ │ │ ├── BackButton.tsx │ │ │ │ ├── Expression.tsx │ │ │ │ ├── Image.tsx │ │ │ │ ├── Loading.tsx │ │ │ │ ├── Nofitication.tsx │ │ │ │ ├── PageContainer.tsx │ │ │ │ └── Toast.tsx │ │ │ ├── hooks/ │ │ │ │ └── useStore.tsx │ │ │ ├── pages/ │ │ │ │ ├── Chat/ │ │ │ │ │ ├── Chat.tsx │ │ │ │ │ ├── ChatBackButton.tsx │ │ │ │ │ ├── ChatRightButton.tsx │ │ │ │ │ ├── ImageMessage.tsx │ │ │ │ │ ├── Input.tsx │ │ │ │ │ ├── InviteMessage.tsx │ │ │ │ │ ├── Message.tsx │ │ │ │ │ ├── MessageList.tsx │ │ │ │ │ ├── SystemMessage.tsx │ │ │ │ │ └── TextMessage.tsx │ │ │ │ ├── ChatList/ │ │ │ │ │ ├── ChatList.tsx │ │ │ │ │ ├── ChatListRightButton.tsx │ │ │ │ │ ├── Linkman.tsx │ │ │ │ │ └── SelfInfo.tsx │ │ │ │ ├── GroupInfo/ │ │ │ │ │ └── GroupInfo.tsx │ │ │ │ ├── GroupProfile/ │ │ │ │ │ └── GroupProfile.tsx │ │ │ │ ├── LoginSignup/ │ │ │ │ │ ├── Base.tsx │ │ │ │ │ ├── Login.tsx │ │ │ │ │ └── Signup.tsx │ │ │ │ ├── Other/ │ │ │ │ │ ├── Other.tsx │ │ │ │ │ ├── PrivacyPolicy.tsx │ │ │ │ │ └── Sponsor.tsx │ │ │ │ ├── SearchResult/ │ │ │ │ │ └── SearchResult.tsx │ │ │ │ └── UserInfo/ │ │ │ │ └── UserInfo.tsx │ │ │ ├── service.ts │ │ │ ├── socket.ts │ │ │ ├── state/ │ │ │ │ ├── action.ts │ │ │ │ ├── reducer.ts │ │ │ │ └── store.ts │ │ │ ├── types/ │ │ │ │ ├── global.d.ts │ │ │ │ ├── redux.ts │ │ │ │ └── socket.ts │ │ │ └── utils/ │ │ │ ├── constant.ts │ │ │ ├── convertMessage.ts │ │ │ ├── expressions.ts │ │ │ ├── fetch.ts │ │ │ ├── getFriendId.ts │ │ │ ├── getRandomColor.ts │ │ │ ├── linkman.ts │ │ │ ├── platform.ts │ │ │ ├── storage.ts │ │ │ ├── time.ts │ │ │ └── uploadFile.ts │ │ ├── tests/ │ │ │ └── state/ │ │ │ └── reducer.test.ts │ │ └── tsconfig.json │ ├── assets/ │ │ └── package.json │ ├── bin/ │ │ ├── index.ts │ │ ├── package.json │ │ ├── scripts/ │ │ │ ├── deleteMessages.ts │ │ │ ├── deleteTodayRegisteredUsers.ts │ │ │ ├── deleteUser.ts │ │ │ ├── doctor.ts │ │ │ ├── fixUsersAvatar.ts │ │ │ ├── getUserId.ts │ │ │ ├── register.ts │ │ │ └── updateDefaultGroupName.ts │ │ └── tsconfig.json │ ├── config/ │ │ ├── client.ts │ │ ├── package.json │ │ └── server.ts │ ├── database/ │ │ ├── mongoose/ │ │ │ ├── index.ts │ │ │ ├── initMongoDB.ts │ │ │ └── models/ │ │ │ ├── friend.ts │ │ │ ├── group.ts │ │ │ ├── history.ts │ │ │ ├── message.ts │ │ │ ├── notification.ts │ │ │ ├── socket.ts │ │ │ └── user.ts │ │ ├── package.json │ │ ├── redis/ │ │ │ └── initRedis.ts │ │ └── tsconfig.json │ ├── docs/ │ │ ├── .gitignore │ │ ├── babel.config.js │ │ ├── docs/ │ │ │ ├── API.md │ │ │ ├── App.md │ │ │ ├── CHANGELOG.md │ │ │ ├── Config.md │ │ │ ├── FAQ.md │ │ │ ├── Getting-Start.md │ │ │ ├── INSTALL.md │ │ │ └── Script.md │ │ ├── docusaurus.config.js │ │ ├── i18n/ │ │ │ ├── en/ │ │ │ │ ├── code.json │ │ │ │ ├── docusaurus-plugin-content-docs/ │ │ │ │ │ └── current/ │ │ │ │ │ ├── API.md │ │ │ │ │ ├── App.md │ │ │ │ │ ├── CHANGELOG.md │ │ │ │ │ ├── Config.md │ │ │ │ │ ├── FAQ.md │ │ │ │ │ ├── Getting-Start.md │ │ │ │ │ ├── INSTALL.md │ │ │ │ │ └── Script.md │ │ │ │ └── docusaurus-theme-classic/ │ │ │ │ ├── footer.json │ │ │ │ └── navbar.json │ │ │ └── zh-Hans/ │ │ │ ├── code.json │ │ │ ├── docusaurus-plugin-content-docs/ │ │ │ │ └── current/ │ │ │ │ ├── API.md │ │ │ │ ├── App.md │ │ │ │ ├── CHANGELOG.md │ │ │ │ ├── Config.md │ │ │ │ ├── FAQ.md │ │ │ │ ├── Getting-Start.md │ │ │ │ ├── INSTALL.md │ │ │ │ └── Script.md │ │ │ └── docusaurus-theme-classic/ │ │ │ ├── footer.json │ │ │ └── navbar.json │ │ ├── package.json │ │ ├── sidebars.js │ │ ├── src/ │ │ │ ├── css/ │ │ │ │ └── custom.css │ │ │ └── pages/ │ │ │ ├── index.js │ │ │ └── styles.module.css │ │ └── static/ │ │ └── .nojekyll │ ├── i18n/ │ │ ├── en-US/ │ │ │ ├── bin.ts │ │ │ └── index.ts │ │ ├── node.index.ts │ │ ├── package.json │ │ └── zh-CN/ │ │ ├── bin.ts │ │ └── index.ts │ ├── server/ │ │ ├── .nodemonrc │ │ ├── package.json │ │ ├── public/ │ │ │ ├── PrivacyPolicy.html │ │ │ ├── index.html │ │ │ └── manifest.json │ │ ├── src/ │ │ │ ├── app.ts │ │ │ ├── main.ts │ │ │ ├── middlewares/ │ │ │ │ ├── frequency.ts │ │ │ │ ├── isAdmin.ts │ │ │ │ ├── isLogin.ts │ │ │ │ ├── registerRoutes.ts │ │ │ │ └── seal.ts │ │ │ ├── routes/ │ │ │ │ ├── group.ts │ │ │ │ ├── history.ts │ │ │ │ ├── message.ts │ │ │ │ ├── notification.ts │ │ │ │ ├── system.ts │ │ │ │ └── user.ts │ │ │ └── types/ │ │ │ ├── index.d.ts │ │ │ └── server.d.ts │ │ ├── test/ │ │ │ ├── helpers/ │ │ │ │ └── middleware.ts │ │ │ └── middlewares/ │ │ │ ├── frequency.spec.ts │ │ │ ├── isAdmin.spec.ts │ │ │ ├── isLogin.spec.ts │ │ │ └── seal.spec.ts │ │ └── tsconfig.json │ ├── utils/ │ │ ├── compressImage.ts │ │ ├── const.ts │ │ ├── convertMessage.ts │ │ ├── expressions.ts │ │ ├── getFriendId.ts │ │ ├── getRandomAvatar.ts │ │ ├── getRandomColor.ts │ │ ├── logger.ts │ │ ├── package.json │ │ ├── sleep.ts │ │ ├── socket.ts │ │ ├── test/ │ │ │ ├── getFriendId.spec.ts │ │ │ └── url.spec.ts │ │ ├── time.ts │ │ ├── ua.ts │ │ ├── url.ts │ │ └── xss.ts │ └── web/ │ ├── .babelrc │ ├── build/ │ │ ├── webpack.common.js │ │ ├── webpack.dev.js │ │ └── webpack.prod.js │ ├── package.json │ ├── src/ │ │ ├── App.less │ │ ├── App.tsx │ │ ├── components/ │ │ │ ├── Avatar.tsx │ │ │ ├── Button.tsx │ │ │ ├── Dialog.less │ │ │ ├── Dialog.tsx │ │ │ ├── Dropdown.less │ │ │ ├── Dropdown.tsx │ │ │ ├── IconButton.less │ │ │ ├── IconButton.tsx │ │ │ ├── Input.less │ │ │ ├── Input.tsx │ │ │ ├── Loading.tsx │ │ │ ├── Menu.tsx │ │ │ ├── Message.less │ │ │ ├── Message.tsx │ │ │ ├── Progress.tsx │ │ │ ├── Select.tsx │ │ │ ├── Tabs.tsx │ │ │ ├── Tooltip.less │ │ │ └── Tooltip.tsx │ │ ├── context.ts │ │ ├── globalStyles.ts │ │ ├── hooks/ │ │ │ ├── useAction.ts │ │ │ ├── useAero.ts │ │ │ ├── useIsLogin.ts │ │ │ └── useStore.ts │ │ ├── localStorage.ts │ │ ├── main.tsx │ │ ├── modules/ │ │ │ ├── Chat/ │ │ │ │ ├── Chat.less │ │ │ │ ├── Chat.tsx │ │ │ │ ├── ChatInput.less │ │ │ │ ├── ChatInput.tsx │ │ │ │ ├── CodeEditor.less │ │ │ │ ├── CodeEditor.tsx │ │ │ │ ├── Expression.less │ │ │ │ ├── Expression.tsx │ │ │ │ ├── GroupManagePanel.less │ │ │ │ ├── GroupManagePanel.tsx │ │ │ │ ├── HeaderBar.less │ │ │ │ ├── HeaderBar.tsx │ │ │ │ ├── Message/ │ │ │ │ │ ├── CodeDialog.tsx │ │ │ │ │ ├── CodeMessage.less │ │ │ │ │ ├── CodeMessage.tsx │ │ │ │ │ ├── FileMessage.tsx │ │ │ │ │ ├── ImageMessage.tsx │ │ │ │ │ ├── InviteMessage.less │ │ │ │ │ ├── InviteMessageV2.tsx │ │ │ │ │ ├── Message.less │ │ │ │ │ ├── Message.tsx │ │ │ │ │ ├── SystemMessage.tsx │ │ │ │ │ ├── TextMessage.tsx │ │ │ │ │ └── UrlMessage.tsx │ │ │ │ ├── MessageList.less │ │ │ │ └── MessageList.tsx │ │ │ ├── FunctionBarAndLinkmanList/ │ │ │ │ ├── CreateGroup.less │ │ │ │ ├── CreateGroup.tsx │ │ │ │ ├── FunctionBar.less │ │ │ │ ├── FunctionBar.tsx │ │ │ │ ├── FunctionBarAndLinkmanList.less │ │ │ │ ├── FunctionBarAndLinkmanList.tsx │ │ │ │ ├── Linkman.less │ │ │ │ ├── Linkman.tsx │ │ │ │ ├── LinkmanList.less │ │ │ │ └── LinkmanList.tsx │ │ │ ├── GroupInfo.tsx │ │ │ ├── InfoDialog.less │ │ │ ├── InviteInfo.tsx │ │ │ ├── LoginAndRegister/ │ │ │ │ ├── Login.tsx │ │ │ │ ├── LoginAndRegister.less │ │ │ │ ├── LoginAndRegister.tsx │ │ │ │ ├── LoginRegister.less │ │ │ │ └── Register.tsx │ │ │ ├── Sidebar/ │ │ │ │ ├── About.less │ │ │ │ ├── About.tsx │ │ │ │ ├── Admin.less │ │ │ │ ├── Admin.tsx │ │ │ │ ├── Common.less │ │ │ │ ├── Download.less │ │ │ │ ├── Download.tsx │ │ │ │ ├── OnlineStatus.less │ │ │ │ ├── OnlineStatus.tsx │ │ │ │ ├── Reward.less │ │ │ │ ├── Reward.tsx │ │ │ │ ├── SelfInfo.less │ │ │ │ ├── SelfInfo.tsx │ │ │ │ ├── Setting.less │ │ │ │ ├── Setting.tsx │ │ │ │ ├── Sidebar.less │ │ │ │ └── Sidebar.tsx │ │ │ └── UserInfo.tsx │ │ ├── service.ts │ │ ├── socket.ts │ │ ├── state/ │ │ │ ├── action.ts │ │ │ ├── reducer.ts │ │ │ └── store.ts │ │ ├── styles/ │ │ │ ├── iconfont.less │ │ │ ├── normalize.less │ │ │ └── variable.less │ │ ├── template.html │ │ ├── themes.ts │ │ ├── types/ │ │ │ └── index.d.ts │ │ └── utils/ │ │ ├── fetch.ts │ │ ├── getRandomHuaji.ts │ │ ├── inobounce.ts │ │ ├── notification.ts │ │ ├── playSound.ts │ │ ├── readDiskFile.ts │ │ ├── setCssVariable.ts │ │ ├── uploadFile.ts │ │ └── voice.ts │ ├── test/ │ │ ├── components/ │ │ │ ├── Avatar.spec.tsx │ │ │ └── Button.spec.tsx │ │ ├── localStorage.spec.ts │ │ └── state/ │ │ └── reducer.spec.ts │ └── tsconfig.json └── tsconfig.json ================================================ FILE CONTENTS ================================================ ================================================ FILE: .dockerignore ================================================ **/node_module packages/docs/ packages/web/.linaria-cache/ packages/web/dist/ yarn-error.log ================================================ FILE: .eslintignore ================================================ node_modules/ dist/ public/ build/ docs/ *.d.ts ================================================ FILE: .eslintrc ================================================ { "extends": ["eslint-config-airbnb", "prettier"], "parser": "@typescript-eslint/parser", "parserOptions": { "project": "./tsconfig.json", "createDefaultProgram": true }, "env": { "browser": true, "node": true, "jest/globals": true }, "plugins": ["@typescript-eslint", "react", "react-hooks", "jsx-a11y", "import", "jest"], "globals": { "importScripts": true, "workbox": true, "__TEST__": true }, "settings": { "import/resolver": { "node": { "extensions": [".js", ".jsx", ".ts", ".tsx"] } } }, "rules": { "@typescript-eslint/no-unused-vars": 2, "global-require": 0, "implicit-arrow-linebreak": 0, "import/extensions": [ 2, { "ts": "never", "tsx": "never", "js": "never", "jsx": "never" } ], "indent": [ 2, 4, { "SwitchCase": 1 } ], "jsx-a11y/click-events-have-key-events": 0, "jsx-a11y/interactive-supports-focus": 0, "jsx-a11y/no-noninteractive-element-interactions": 0, "jsx-a11y/no-noninteractive-element-to-interactive-role": 0, "no-param-reassign": 0, "no-plusplus": 0, "no-script-url": 0, "no-underscore-dangle": 0, "object-curly-newline": 0, "react/jsx-filename-extension": [ 2, { "extensions": [".js", ".jsx", ".tsx"] } ], "react/jsx-indent": [2, 4], "react/jsx-indent-props": [2, 4], "react/jsx-props-no-spreading": 0, "react/jsx-one-expression-per-line": 0, "react/static-property-placement": 0, "react-hooks/rules-of-hooks": 2, "react-hooks/exhaustive-deps": 0, "react/require-default-props": [2, { "ignoreFunctionalComponents": true }], "import/prefer-default-export": 0, "prefer-promise-reject-errors": "off" } } ================================================ FILE: .github/workflows/codeql-analysis.yml ================================================ # For most projects, this workflow file will not need changing; you simply need # to commit it to your repository. # # You may wish to alter this file to override the set of languages analyzed, # or to provide custom queries or build logic. # # ******** NOTE ******** # We have attempted to detect the languages in your repository. Please check # the `language` matrix defined below to confirm you have the correct set of # supported CodeQL languages. # name: "CodeQL" on: push: branches: [ master ] pull_request: # The branches below must be a subset of the branches above branches: [ master ] schedule: - cron: '32 14 * * 5' jobs: analyze: name: Analyze runs-on: ubuntu-latest strategy: fail-fast: false matrix: language: [ 'typescript', 'javascript' ] # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python' ] # Learn more: # https://docs.github.com/en/free-pro-team@latest/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#changing-the-languages-that-are-analyzed steps: - name: Checkout repository uses: actions/checkout@v2 # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL uses: github/codeql-action/init@v1 with: languages: ${{ matrix.language }} # If you wish to specify custom queries, you can do so here or in a config file. # By default, queries listed here will override any specified in a config file. # Prefix the list here with "+" to use these queries and those in the config file. # queries: ./path/to/local/query, your-org/your-repo/queries@main # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). # If this step fails, then you should remove it and run the build manually (see below) - name: Autobuild uses: github/codeql-action/autobuild@v1 # ℹ️ Command-line programs to run using the OS shell. # 📚 https://git.io/JvXDl # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines # and modify them (or add more) to build your code if your project # uses a compiled language #- run: | # make bootstrap # make release - name: Perform CodeQL Analysis uses: github/codeql-action/analyze@v1 ================================================ FILE: .github/workflows/lint.yml ================================================ # This workflow will do a clean install of node dependencies, build the source code and run tests across different versions of node # For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions name: Lint Code Style on: push: branches: [ master ] pull_request: branches: [ master ] jobs: lint: runs-on: ubuntu-latest strategy: matrix: node-version: [10.x] steps: - uses: actions/checkout@master - uses: bahmutov/npm-install@v1.4.5 - run: yarn lint ================================================ FILE: .github/workflows/test.yml ================================================ name: Unit Test on: push: branches: [ master ] pull_request: branches: [ master ] jobs: test: runs-on: ubuntu-latest strategy: matrix: node-version: [10.x] steps: - uses: actions/checkout@master - uses: bahmutov/npm-install@v1.4.5 - run: yarn test ================================================ FILE: .github/workflows/ts.yml ================================================ # This workflow will do a clean install of node dependencies, build the source code and run tests across different versions of node # For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions name: Typescript Type Check on: push: branches: [ master ] pull_request: branches: [ master ] jobs: ts: runs-on: ubuntu-latest strategy: matrix: node-version: [10.x] steps: - uses: actions/checkout@master - uses: bahmutov/npm-install@v1.4.5 - run: yarn ts-check ================================================ FILE: .gitignore ================================================ .DS_Store node_modules/ dist/ coverage/ .idea/ .linaria-cache/ npm-debug.log yarn-error.log .eslintcache lerna-debug.log .env packages/server/public/* !packages/server/public/avatar/ packages/server/public/avatar/*_*.* !packages/server/public/favicon-96.png !packages/server/public/favicon-192.png !packages/server/public/favicon-512.png !packages/server/public/manifest.json !packages/server/public/index.html !packages/server/public/PrivacyPolicy.html ================================================ FILE: .prettierrc ================================================ { "tabWidth": 4, "trailingComma": "all", "singleQuote": true, "arrowParens": "always", "printWidth": 80 } ================================================ FILE: .vscode/settings.json ================================================ { "typescript.tsdk": "node_modules/typescript/lib" } ================================================ FILE: Dockerfile ================================================ FROM node:14 WORKDIR /usr/app/fiora COPY packages ./packages COPY package.json tsconfig.json yarn.lock lerna.json ./ RUN touch .env RUN yarn install RUN yarn build:web CMD yarn start ================================================ FILE: LICENSE ================================================ MIT License Copyright (c) 2015-2021 碎碎酱 Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: README.md ================================================ # [Fiora](https://fiora.suisuijiang.com/) · [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](https://github.com/yinxin630/fiora/blob/master/LICENSE) [![author](https://img.shields.io/badge/author-%E7%A2%8E%E7%A2%8E%E9%85%B1-blue.svg)](http://suisuijiang.com) [![Node.js Version](https://img.shields.io/badge/node.js-14.16.0-blue.svg)](http://nodejs.org/download) [![Test Status](https://github.com/yinxin630/fiora/workflows/Unit%20Test/badge.svg)](https://github.com/yinxin630/fiora/actions?query=workflow%3A%22Unit+Test%22) [![Typescript Status](https://github.com/yinxin630/fiora/workflows/Typescript%20Type%20Check/badge.svg)](https://github.com/yinxin630/fiora/actions?query=workflow%3A%22Typescript+Type+Check%22) Fiora is an interesting open source chat application. It is developed based on [node.js](https://nodejs.org/), [react](https://reactjs.org/) and [socket.io](https://socket.io/) technologies - **Richness:** Fiora contains backend, frontend, Android and iOS apps - **Cross Platform:** Fiora is developed with node.js. Supports Windows / Linux / macOS systems - **Open Source:** Fiora follows the MIT open source license Online Example: [https://fiora.suisuijiang.com/](https://fiora.suisuijiang.com/) Documentation: [https://yinxin630.github.io/fiora/](https://yinxin630.github.io/fiora/) **Other Client** Vscode Extension: [https://github.com/moonrailgun/fiora-for-vscode](https://github.com/moonrailgun/fiora-for-vscode) If you are seek for other open-source IM Application which like discord or slack, maybe try out `Tailchat`: https://tailchat.msgbyte.com/ ## Features 1. Register an account and log in, it can save your data for a long time 2. Join an existing group or create your own group to communicate with everyone 3. Chat privately with anyone and add them as friends 4. Multiple message types, including text / emoticons / pictures / codes / files / commands, you can also search for emoticons 5. Push notification when you receive a new message, you can customize the notification ringtone, and it can also read the message out 6. Choose the theme you like, and you can set it as any wallpaper and theme color you like 7. Set up an administrator to manage users ## Screenshot PC Phone App ## Install Fiora provides two ways to install - [Install by source code](https://yinxin630.github.io/fiora/docs/install#how-to-run) - [Install by docker](https://yinxin630.github.io/fiora/docs/install#running-on-the-docker) ## Change Log You can find the Fiora changelog [on the website](https://yinxin630.github.io/fiora/docs/changelog) ## Contribution Pull requests are welcome. For major changes, please open an issue first to discuss what you would like to change. Please make sure to update tests as appropriate 1. Fork it () 2. Create your feature branch (`git checkout -b some-feature`) 3. Commit your changes (`git commit -am 'Add some some features'`) 4. Push to the branch (`git push origin some-feature`) 5. Create a new Pull Request ## License Fiora is [MIT licensed](./LICENSE) ================================================ FILE: docker-compose.yaml ================================================ version: '3.2' services: mongodb: image: mongo restart: always redis: image: redis restart: always fiora: build: . restart: always ports: - "9200:9200" environment: - Database=mongodb://mongodb/fiora - RedisHost=redis ================================================ FILE: index.ts ================================================ #!/usr/bin/env ./node_modules/.bin/ts-node import { program } from 'commander'; import cp from 'child_process'; import i18n from './packages/i18n/node.index'; function exec(commandStr: string) { const [command, ...args] = commandStr.split(' '); cp.execFileSync(command, args, { stdio: 'inherit' }); } program .command('getUserId ') .description(i18n('getUserIdDescription')) .action((username: string) => { exec( `npx ts-node --transpile-only packages/bin/index.ts getUserId ${username}`, ); }); program .command('register ') .description(i18n('registerDescription')) .action((username: string, password: string) => { exec( `npx ts-node --transpile-only packages/bin/index.ts register ${username} ${password}`, ); }); program .command('deleteUser ') .description(i18n('deleteUserDescription')) .action((userId: string) => { exec( `npx ts-node --transpile-only packages/bin/index.ts deleteUser ${userId}`, ); }); program .command('fixUsersAvatar [searchValue] [replaceValue]') .description(i18n('fixUsersAvatarDescription')) .action((searchValue = '', replaceValue = '') => { exec( `npx ts-node --transpile-only packages/bin/index.ts fixUsersAvatar ${searchValue} ${replaceValue}`, ); }); program .command('deleteTodayRegisteredUsers') .description(i18n('deleteTodayRegisteredUsersDescription')) .action(() => { exec( `npx ts-node --transpile-only packages/bin/index.ts deleteTodayRegisteredUsers`, ); }); program .command('deleteMessages') .description(i18n('deleteMessagesDescription')) .action(() => { exec( `npx ts-node --transpile-only packages/bin/index.ts deleteMessages`, ); }); program .command('updateDefaultGroupName ') .description(i18n('updateDefaultGroupNameDescription')) .action((newName: string) => { exec( `npx ts-node --transpile-only packages/bin/index.ts updateDefaultGroupName ${newName}`, ); }); program .command('doctor') .description(i18n('doctorDescription')) .action(() => { exec(`npx ts-node --transpile-only packages/bin/index.ts doctor`); }); program.usage('[command]'); program.parse(process.argv); ================================================ FILE: jest.config.js ================================================ module.exports = { preset: 'ts-jest', moduleNameMapper: { '^.+\\.(css|less|jpg|png|gif|mp3)$': '/jest.transformer.js', }, collectCoverage: true, globals: { 'ts-jest': { isolatedModules: true, }, __TEST__: true, }, setupFilesAfterEnv: ['./jest.setup.js'], collectCoverageFrom: [ '**/*.{ts,tsx}', '!**/node_modules/**', '!**/config/**', '!**/test/helpers/**', ], }; ================================================ FILE: jest.setup.js ================================================ jest.mock('./packages/web/node_modules/linaria', () => ({ css: jest.fn(() => ''), })); jest.mock('./packages/database/node_modules/redis', () => jest.requireActual('redis-mock')); ================================================ FILE: jest.transformer.js ================================================ const path = require('path'); module.exports = { process(src, filename) { return `module.exports = ${JSON.stringify(path.basename(filename))};`; }, }; ================================================ FILE: lerna.json ================================================ { "packages": [ "packages/*" ], "version": "independent", "npmClient": "yarn" } ================================================ FILE: package.json ================================================ { "name": "fiora", "version": "1.0.0", "description": "An interesting chat application power by socket.io, koa, mongodb and react", "license": "MIT", "bin": "index.ts", "scripts": { "start": "npx lerna run start --stream", "dev:server": "npx lerna run dev:server --stream", "dev:web": "npx lerna run dev:web --stream", "build:web": "npx lerna run build:web --stream", "dev:app": "cd packages/app && yarn dev:app && cd ../../", "build:android": "cd packages/app && yarn build:android && cd ../../", "build:ios": "cd packages/app && yarn build:ios && cd ../../", "script": "npx lerna run script --stream", "dev:docs": "npx lerna run dev:docs --stream", "build:docs": "npx lerna run build:docs --stream", "deploy:docs": "npx lerna run deploy:docs --stream", "lint": "eslint ./ --ext js,jsx,ts,tsx --ignore-pattern .eslintignore --cache --fix", "test": "jest", "ts-check": "tsc --noEmit", "install": "npx lerna bootstrap && yarn link" }, "engines": { "node": ">= 14" }, "author": { "name": "碎碎酱", "email": "yinxin630@gmail.com" }, "repository": { "type": "git", "url": "https://github.com/yinxin630/fiora" }, "devDependencies": { "@testing-library/jest-dom": "^4.2.4", "@testing-library/react": "^12.0.0", "@types/jest": "^24.0.18", "@types/node": "^15.14.1", "@typescript-eslint/eslint-plugin": "^2.0.0", "@typescript-eslint/parser": "^2.0.0", "eslint": "^7.30.0", "eslint-config-airbnb": "^18.2.1", "eslint-config-prettier": "^8.3.0", "eslint-plugin-import": "^2.23.4", "eslint-plugin-jest": "^24.3.6", "eslint-plugin-jsx-a11y": "^6.4.1", "eslint-plugin-react": "^7.24.0", "eslint-plugin-react-hooks": "^4.2.0", "jest": "^26.1.0", "lerna": "^4.0.0", "redis-mock": "^0.56.3", "ts-jest": "^26.1.3", "ts-node": "^10.1.0", "typescript": "^3.8.2" }, "dependencies": { "commander": "^8.0.0" } } ================================================ FILE: packages/app/.babelrc ================================================ { "presets": ["babel-preset-expo"] } ================================================ FILE: packages/app/.eslintrc ================================================ { "extends": "../../.eslintrc", "rules": { "no-use-before-define": "off", "consistent-return": "off" } } ================================================ FILE: packages/app/.gitignore ================================================ # See https://help.github.com/ignore-files/ for more about ignoring files. # expo .expo/ .expo-shared/ # dependencies /node_modules # misc .env.local .env.development.local .env.test.local .env.production.local npm-debug.log* yarn-debug.log* yarn-error.log* ================================================ FILE: packages/app/.watchmanconfig ================================================ {} ================================================ FILE: packages/app/App.tsx ================================================ /* eslint-disable react/jsx-props-no-spreading */ import React from 'react'; import { Provider } from 'react-redux'; import App from './src/App'; import store from './src/state/store'; export default function Main(props: any) { return ( ); } ================================================ FILE: packages/app/app.json ================================================ { "expo": { "privacy": "public", "name": "fiora", "icon": "./icon.png", "version": "1.1.4", "description": "App for fiora. An online chatroom", "slug": "fiora", "scheme": "fiora", "ios": { "bundleIdentifier": "com.suisuijiang.fiora", "buildNumber": "1.1.4", "infoPlist": { "LSApplicationQueriesSchemes": ["wxp"], "CFBundleLocalizations" : ["zh_CN"], "CFBundleDevelopmentRegion": "zh_CN" } }, "android": { "package": "com.suisuijiang.fiora", "versionCode": 10, "useNextNotificationsApi": true }, "updates": { "enabled": false } } } ================================================ FILE: packages/app/package.json ================================================ { "name": "@fiora/app", "version": "1.0.0", "license": "MIT", "private": true, "main": "./node_modules/expo/AppEntry.js", "scripts": { "dev:app": "expo start", "eject": "expo eject", "build:android": "expo build:android -t apk", "build:ios": "expo build:ios -t archive" }, "dependencies": { "@expo/vector-icons": "^12.0.5", "@react-native-async-storage/async-storage": "~1.15.0", "@react-native-community/masked-view": "0.1.10", "@react-native-toolkit/triangle": "^0.0.1", "autobind-decorator": "^2.1.0", "deepmerge": "^4.2.2", "expo": "^42.0.0", "expo-constants": "~11.0.1", "expo-image-picker": "~10.2.2", "expo-notifications": "~0.12.3", "expo-web-browser": "~9.2.0", "immer": "^9.0.6", "native-base": "^2.4.5", "prop-types": "^15.6.1", "randomcolor": "^0.6.2", "react": "16.13.1", "react-native": "https://github.com/expo/react-native/archive/sdk-42.0.0.tar.gz", "react-native-dialog": "^6.2.0", "react-native-gesture-handler": "~1.10.2", "react-native-image-zoom-viewer": "^3.0.1", "react-native-reanimated": "~2.2.0", "react-native-router-flux": "^4.3.1", "react-native-safe-area-context": "3.2.0", "react-native-screens": "~3.4.0", "react-redux": "^7.2.2", "redux": "^4.0.0", "socket.io-client": "^4.1.3" }, "devDependencies": { "@types/randomcolor": "^0.5.5", "@types/react": "^17.0.14", "@types/react-native": "^0.64.12", "@types/react-redux": "^7.1.16", "@types/redux": "^3.6.0", "@types/socket.io-client": "^3.0.0", "expo-cli": "^4.7.3", "jest-expo": "^42.0.0", "react-test-renderer": "16.3.1" } } ================================================ FILE: packages/app/src/App.tsx ================================================ import React from 'react'; import { StyleSheet, View } from 'react-native'; import { Scene, Router, Stack, Tabs, Lightbox } from 'react-native-router-flux'; import { Icon, Root } from 'native-base'; import { connect } from 'react-redux'; import ChatList from './pages/ChatList/ChatList'; import Chat from './pages/Chat/Chat'; import Login from './pages/LoginSignup/Login'; import Signup from './pages/LoginSignup/Signup'; import Loading from './components/Loading'; import Other from './pages/Other/Other'; import Notification from './components/Nofitication'; import { State, User } from './types/redux'; import SelfInfo from './pages/ChatList/SelfInfo'; import ChatBackButton from './pages/Chat/ChatBackButton'; import GroupProfile from './pages/GroupProfile/GroupProfile'; import ChatRightButton from './pages/Chat/ChatRightButton'; import UserInfo from './pages/UserInfo/UserInfo'; import ChatListRightButton from './pages/ChatList/ChatListRightButton'; import SearchResult from './pages/SearchResult/SearchResult'; import GroupInfo from './pages/GroupInfo/GroupInfo'; import BackButton from './components/BackButton'; type Props = { title: string; primaryColor: string; isLogin: boolean; }; function App({ title, primaryColor, isLogin }: Props) { const primaryColor10 = `rgba(${primaryColor}, 1)`; const primaryColor8 = `rgba(${primaryColor}, 0.8)`; const sceneCommonProps = { hideNavBar: false, navigationBarStyle: { backgroundColor: primaryColor10, borderBottomWidth: 0, }, navBarButtonColor: '#f9f9f9', renderLeftButton: () => , }; return ( ( )} renderLeftButton={() => } renderRightButton={() => ( )} navigationBarStyle={{ backgroundColor: primaryColor10, borderBottomWidth: 0, }} /> ( )} /> } renderRightButton={() => } /> ); } export default connect((state: State) => ({ primaryColor: state.ui.primaryColor, isLogin: !!(state.user as User)?._id, }))(App); const styles = StyleSheet.create({ container: { flex: 1, }, }); ================================================ FILE: packages/app/src/components/Avatar.tsx ================================================ import React from 'react'; import { getOSSFileUrl } from '../utils/uploadFile'; import Image from './Image'; type Props = { src: string; size: number; }; export default function Avatar({ src, size }: Props) { const targetUrl = getOSSFileUrl( src, `image/resize,w_${size * 2},h_${size * 2}/quality,q_90`, ) as string; return ( ); } ================================================ FILE: packages/app/src/components/BackButton.tsx ================================================ import { View, Icon, Text } from 'native-base'; import React from 'react'; import { TouchableOpacity } from 'react-native'; import { Actions } from 'react-native-router-flux'; type Props = { text?: string; }; function BackButton({ text = '' }: Props) { return ( Actions.pop()}> {text} ); } export default BackButton; ================================================ FILE: packages/app/src/components/Expression.tsx ================================================ import React from 'react'; import { View } from 'react-native'; import Image from './Image'; import uri from '../assets/images/baidu.png'; type Props = { size: number; index: number; style?: any; }; export default function Expression({ size, index, style }: Props) { return ( ); } ================================================ FILE: packages/app/src/components/Image.tsx ================================================ import React from 'react'; import { Image as BaseImage, ImageSourcePropType } from 'react-native'; import { getOSSFileUrl } from '../utils/uploadFile'; import { referer } from '../utils/constant'; type Props = { src: string; width?: string | number; height?: string | number; style?: any; }; export default function Image({ src, width = '100%', height = '100%', style, }: Props) { // @ts-ignore let source: ImageSourcePropType = src; if (typeof src === 'string') { let uri = getOSSFileUrl(src, `image/quality,q_80`); if (width !== '100%' && height !== '100%') { uri = getOSSFileUrl( src, `image/resize,w_${Math.ceil(width as number)},h_${Math.ceil( height as number, )}/quality,q_80`, ); } source = { uri: uri as string, cache: 'force-cache', headers: { Referer: referer, }, }; } return ; } ================================================ FILE: packages/app/src/components/Loading.tsx ================================================ import React from 'react'; import { View, Text, Dimensions, StyleSheet } from 'react-native'; import { Spinner } from 'native-base'; import { useStore } from '../hooks/useStore'; const { width: ScreenWidth, height: ScreenHeight } = Dimensions.get('window'); export default function Loading() { const { loading } = useStore().ui; if (!loading) { return null; } return ( {loading} ); } const styles = StyleSheet.create({ loadingView: { width: ScreenWidth, height: ScreenHeight, position: 'absolute', backgroundColor: 'rgba(0,0,0,0.15)', alignItems: 'center', justifyContent: 'center', }, loadingBox: { width: 120, height: 120, backgroundColor: 'rgba(0,0,0,0.7)', borderRadius: 10, alignItems: 'center', }, loadingText: { color: 'white', }, }); ================================================ FILE: packages/app/src/components/Nofitication.tsx ================================================ import Constants from 'expo-constants'; import * as Notifications from 'expo-notifications'; import { useState, useEffect } from 'react'; import { Platform, AppState } from 'react-native'; import { Actions } from 'react-native-router-flux'; import { setNotificationToken } from '../service'; import action from '../state/action'; import { State, User } from '../types/redux'; import { isiOS } from '../utils/platform'; import { useIsLogin, useStore } from '../hooks/useStore'; import store from '../state/store'; function enableNotification() { Notifications.setNotificationHandler({ handleNotification: async () => ({ shouldShowAlert: true, shouldPlaySound: true, shouldSetBadge: false, }), }); } function disableNotification() { Notifications.setNotificationHandler({ handleNotification: async () => ({ shouldShowAlert: false, shouldPlaySound: false, shouldSetBadge: false, }), }); } function Nofitication() { const isLogin = useIsLogin(); const state = useStore(); const notificationTokens = (state.user as User)?.notificationTokens || []; const { connect } = state; const [notificationToken, updateNotificationToken] = useState(''); async function registerForPushNotificationsAsync() { // Push notification to Android device need google service // Not supported in China if (Constants.isDevice && isiOS) { const { status: existingStatus, } = await Notifications.getPermissionsAsync(); let finalStatus = existingStatus; if (existingStatus !== 'granted') { const { status, } = await Notifications.requestPermissionsAsync(); finalStatus = status; } if (finalStatus !== 'granted') { return; } const token = (await Notifications.getExpoPushTokenAsync()).data; updateNotificationToken(token); if (Platform.OS === 'android') { Notifications.setNotificationChannelAsync('default', { name: 'default', importance: Notifications.AndroidImportance.MAX, vibrationPattern: [0, 250, 250, 250], lightColor: '#FF231F7C', }); } } } function handleClickNotification(response: any) { const { focus } = response.notification.request.content.data; setTimeout(() => { const currentState = store.getState() as State; const linkmans = currentState.linkmans || []; if (linkmans.find((linkman) => linkman._id === focus)) { action.setFocus(focus); if (Actions.currentScene !== 'chat') { Actions.chat(); } } }, 1000); } useEffect(() => { disableNotification(); registerForPushNotificationsAsync(); Notifications.addNotificationResponseReceivedListener( handleClickNotification, ); }, []); useEffect(() => { if ( connect && isLogin && notificationToken && !notificationTokens.includes(notificationToken) ) { setNotificationToken(notificationToken); } }, [connect, isLogin, notificationToken]); function handleAppStateChange(nextAppState: string) { if (nextAppState === 'active') { disableNotification(); } else if (nextAppState === 'background') { enableNotification(); } } useEffect(() => { AppState.addEventListener('change', handleAppStateChange); return () => { AppState.removeEventListener('change', handleAppStateChange); }; }, []); return null; } export default Nofitication; ================================================ FILE: packages/app/src/components/PageContainer.tsx ================================================ import { View } from 'native-base'; import React from 'react'; import { ImageBackground, SafeAreaView, StyleSheet } from 'react-native'; type Props = { children: any; disableSafeAreaView?: boolean; }; function PageContainer({ children, disableSafeAreaView = false }: Props) { return ( {disableSafeAreaView ? ( children ) : ( {children} )} ); } export default PageContainer; const styles = StyleSheet.create({ container: { flex: 1, }, backgroundImage: { flex: 1, resizeMode: 'cover', }, children: { flex: 1, backgroundColor: 'rgba(241, 241, 241, 0.6)', }, }); ================================================ FILE: packages/app/src/components/Toast.tsx ================================================ import { Toast } from 'native-base'; export default { success(message: string) { Toast.show({ text: message, type: 'success', position: 'top', }); }, warning(message: string) { Toast.show({ text: message, type: 'warning', position: 'top', }); }, danger(message: string) { Toast.show({ text: message, type: 'danger', position: 'top', }); }, }; ================================================ FILE: packages/app/src/hooks/useStore.tsx ================================================ import { useSelector } from 'react-redux'; import { State, User } from '../types/redux'; export function useStore() { return useSelector((state: State) => state); } export function useUser() { return useStore().user as User; } export function useSelfId() { const user = useUser(); return (user && user._id) || ''; } export function useIsLogin() { return !!useSelfId(); } export function useIsAdmin() { const user = useUser(); return (user && user.isAdmin) || false; } export function useTheme() { const { ui } = useStore(); const { primaryColor, primaryTextColor } = ui; return { primaryColor8: `rgba(${primaryColor}, 0.8)`, primaryColor10: `rgba(${primaryColor}, 1)`, primaryTextColor10: `rgba(${primaryTextColor}, 1)`, }; } export function useLinkmans() { const data = useStore(); return data.linkmans || []; } export function useFocusLinkman() { const data = useStore(); const { linkmans, focus = '' } = data; if (linkmans) { return linkmans.find((linkman) => linkman._id === focus); } return null; } export function useFocus() { const data = useStore(); return data.focus || ''; } ================================================ FILE: packages/app/src/pages/Chat/Chat.tsx ================================================ import React, { useEffect, useRef } from 'react'; import { StyleSheet, KeyboardAvoidingView, ScrollView, Dimensions, } from 'react-native'; import Constants from 'expo-constants'; import { Actions } from 'react-native-router-flux'; import { isiOS } from '../../utils/platform'; import MessageList from './MessageList'; import Input from './Input'; import PageContainer from '../../components/PageContainer'; import { Friend, Group, Linkman } from '../../types/redux'; import { useFocusLinkman, useIsLogin, useSelfId, useStore, } from '../../hooks/useStore'; import { getDefaultGroupOnlineMembers, getGroupOnlineMembers, getUserOnlineStatus, } from '../../service'; import action from '../../state/action'; import { formatLinkmanName } from '../../utils/linkman'; import fetch from '../../utils/fetch'; let lastMessageIdCache = ''; const keyboardOffset = (() => { const { width, height } = Dimensions.get('window'); const screenRatio = height / width; if (screenRatio === 667 / 375) { // iPhone 6 / 7 / 8 return 64; } if (screenRatio === 736 / 414) { // iPhone 6 / 7 / 8 PLUS return 64; } if (screenRatio === 812 / 375) { // iPhone X / 12mini return 86; } if (screenRatio === 896 / 414) { // iPhone Xr / 11 / 11 Pro Max return 86; } if (screenRatio === 844 / 390) { // iPhone 12 / 12 Prop return 64; } if (screenRatio === 926 / 428) { // iPhone 12 Pro Max return 64; } return Constants.statusBarHeight + 44; })(); export default function Chat() { const isLogin = useIsLogin(); const self = useSelfId(); const { focus } = useStore(); const linkman = useFocusLinkman(); const $messageList = useRef(); async function fetchGroupOnlineMembers() { let onlineMembers: Group['members'] = []; if (isLogin) { onlineMembers = await getGroupOnlineMembers(focus); } else { onlineMembers = await getDefaultGroupOnlineMembers(); } if (onlineMembers) { action.updateGroupProperty(focus, 'members', onlineMembers); } } async function fetchUserOnlineStatus() { const isOnline = await getUserOnlineStatus(focus.replace(self, '')); action.updateFriendProperty(focus, 'isOnline', isOnline); } useEffect(() => { if (!linkman || !isLogin) { return; } const request = linkman.type === 'group' ? fetchGroupOnlineMembers : fetchUserOnlineStatus; request(); const timer = setInterval(() => request(), 1000 * 60); return () => clearInterval(timer); }, [focus, isLogin]); useEffect(() => { if (Actions.currentScene !== 'chat') { return; } Actions.refresh({ title: formatLinkmanName(linkman as Linkman), }); }, [(linkman as Group).members, (linkman as Friend).isOnline]); async function intervalUpdateHistory() { if (isLogin && linkman) { if (linkman.messages.length > 0) { const lastMessageId = linkman.messages[linkman.messages.length - 1]._id; if (lastMessageId !== lastMessageIdCache) { lastMessageIdCache = lastMessageId; await fetch('updateHistory', { linkmanId: focus, messageId: lastMessageId, }); } } } } useEffect(() => { const timer = setInterval(intervalUpdateHistory, 1000 * 5); return () => clearInterval(timer); }, [focus]); function scrollToEnd(time = 0) { if (time > 200) { return; } if ($messageList.current) { $messageList.current!.scrollToEnd({ animated: false }); } setTimeout(() => { scrollToEnd(time + 50); }, 50); } function handleInputHeightChange() { if ($messageList.current) { scrollToEnd(); } } return ( {/* // @ts-ignore */} ); } const styles = StyleSheet.create({ container: { flex: 1, }, }); ================================================ FILE: packages/app/src/pages/Chat/ChatBackButton.tsx ================================================ import React from 'react'; import BackButton from '../../components/BackButton'; import { useStore } from '../../hooks/useStore'; function ChatBackButton() { const store = useStore(); const unread = store.linkmans.reduce((result, linkman) => { result += linkman.unread; return result; }, 0); return ; } export default ChatBackButton; ================================================ FILE: packages/app/src/pages/Chat/ChatRightButton.tsx ================================================ import { View, Icon } from 'native-base'; import React from 'react'; import { StyleSheet, TouchableOpacity } from 'react-native'; import { Actions } from 'react-native-router-flux'; import { useFocusLinkman } from '../../hooks/useStore'; function ChatRightButton() { const linkman = useFocusLinkman(); function handleClick() { if (linkman?.type === 'group') { Actions.push('groupProfile'); } else { Actions.push('userInfo', { user: linkman }); } } return ( ); } export default ChatRightButton; const styles = StyleSheet.create({ container: { width: 44, height: 44, flexDirection: 'row', alignItems: 'center', justifyContent: 'center', }, icon: { color: 'white', fontSize: 26, }, }); ================================================ FILE: packages/app/src/pages/Chat/ImageMessage.tsx ================================================ /* eslint-disable react/jsx-props-no-spreading */ import { View } from 'native-base'; import React from 'react'; import { Dimensions, StyleSheet, TouchableOpacity } from 'react-native'; import Image from '../../components/Image'; import { Message } from '../../types/redux'; const { width: ScreenWidth } = Dimensions.get('window'); type Props = { message: Message; openImageViewer: (imageUrl: string) => void; couldDelete: boolean; onLongPress: () => void; }; function ImageMessage({ message, openImageViewer, couldDelete, onLongPress, }: Props) { const maxWidth = ScreenWidth - 130 - 16; const maxHeight = 200; let scale = 1; let width = 0; let height = 0; const parseResult = /width=([0-9]+)&height=([0-9]+)/.exec(message.content); if (parseResult) { width = parseInt(parseResult[1], 10); height = parseInt(parseResult[2], 10); if (width * scale > maxWidth) { scale = maxWidth / width; } if (height * scale > maxHeight) { scale = maxHeight / height; } } function handleImageClick() { const imageUrl = message.content; openImageViewer(imageUrl); } return ( ); } export default ImageMessage; const styles = StyleSheet.create({ container: { height: 200, width: ScreenWidth - 130 - 16, borderRadius: 3, overflow: 'hidden', }, }); ================================================ FILE: packages/app/src/pages/Chat/Input.tsx ================================================ import React, { useRef, useState } from 'react'; import { StyleSheet, View, TextInput, Text, Dimensions, TouchableOpacity, SafeAreaView, } from 'react-native'; import { Button } from 'native-base'; import { Actions } from 'react-native-router-flux'; import { Ionicons } from '@expo/vector-icons'; import * as ImagePicker from 'expo-image-picker'; import action from '../../state/action'; import fetch from '../../utils/fetch'; import { isiOS } from '../../utils/platform'; import expressions from '../../utils/expressions'; import Expression from '../../components/Expression'; import { useIsLogin, useStore, useUser } from '../../hooks/useStore'; import { Message } from '../../types/redux'; import uploadFile from '../../utils/uploadFile'; const { width: ScreenWidth } = Dimensions.get('window'); const ExpressionSize = (ScreenWidth - 16) / 10; type Props = { onHeightChange: () => void; }; export default function Input({ onHeightChange }: Props) { const isLogin = useIsLogin(); const user = useUser(); const { focus } = useStore(); const [message, setMessage] = useState(''); const [showFunctionList, toggleShowFunctionList] = useState(true); const [showExpression, toggleShowExpression] = useState(false); const [cursorPosition, setCursorPosition] = useState({ start: 0, end: 0 }); const $input = useRef(); function setInputText(text = '') { // iossetNativeProps无效, 解决办法参考:https://github.com/facebook/react-native/issues/18272 if (isiOS) { $input.current!.setNativeProps({ text: text || ' ' }); } setTimeout(() => { $input.current!.setNativeProps({ text: text || '' }); }); } function addSelfMessage(type: string, content: string) { const _id = focus + Date.now(); const newMessage: Message = { _id, type, content, createTime: Date.now(), from: { _id: user._id, username: user.username, avatar: user.avatar, tag: user.tag, }, to: '', loading: true, }; if (type === 'image') { newMessage.percent = 0; } action.addLinkmanMessage(focus, newMessage); return _id; } async function sendMessage(localId: string, type: string, content: string) { const [err, res] = await fetch('sendMessage', { to: focus, type, content, }); if (!err) { res.loading = false; action.updateSelfMessage(focus, localId, res); } } function handleSubmit() { if (message === '') { return; } const id = addSelfMessage('text', message); sendMessage(id, 'text', message); setMessage(''); toggleShowFunctionList(true); toggleShowExpression(false); setInputText(); } function handleSelectionChange(event: any) { const { start, end } = event.nativeEvent.selection; setCursorPosition({ start, end, }); } function handleFocus() { toggleShowFunctionList(true); toggleShowExpression(false); } function openExpression() { $input.current!.blur(); toggleShowFunctionList(false); toggleShowExpression(true); onHeightChange(); } async function handleClickImage() { const currentPermission = await ImagePicker.getMediaLibraryPermissionsAsync(); if (currentPermission.accessPrivileges === 'none') { if (currentPermission.canAskAgain) { const permission = await ImagePicker.requestMediaLibraryPermissionsAsync(); if (permission.accessPrivileges === 'none') { return; } } else { return; } } const result = await ImagePicker.launchImageLibraryAsync({ mediaTypes: ImagePicker.MediaTypeOptions.Images, base64: true, }); if (!result.cancelled) { const id = addSelfMessage( 'image', `${result.uri}?width=${result.width}&height=${result.height}`, ); const key = `ImageMessage/${user._id}_${Date.now()}`; const imageUrl = await uploadFile( result.base64 as string, key, true, ); sendMessage( id, 'image', `${imageUrl}?width=${result.width}&height=${result.height}`, ); } } async function handleClickCamera() { const currentPermission = await ImagePicker.getCameraPermissionsAsync(); if (currentPermission.status === 'undetermined') { if (currentPermission.canAskAgain) { const permission = await ImagePicker.requestCameraPermissionsAsync(); if (permission.status === 'undetermined') { return; } } else { return; } } const result = await ImagePicker.launchCameraAsync({ mediaTypes: ImagePicker.MediaTypeOptions.Images, base64: true, }); if (!result.cancelled) { const id = addSelfMessage( 'image', `${result.uri}?width=${result.width}&height=${result.height}`, ); const key = `ImageMessage/${user._id}_${Date.now()}`; const imageUrl = await uploadFile( result.base64 as string, key, true, ); sendMessage( id, 'image', `${imageUrl}?width=${result.width}&height=${result.height}`, ); } } function handleChangeText(value: string) { setMessage(value); } function insertExpression(e: string) { const expression = `#(${e})`; const newValue = `${message.substring( 0, cursorPosition.start, )}${expression}${message.substring( cursorPosition.end, message.length, )}`; setMessage(newValue); setCursorPosition({ start: cursorPosition.start + expression.length, end: cursorPosition.start + expression.length, }); setInputText(newValue); } return ( {isLogin ? ( ) : ( )} {isLogin && showFunctionList ? ( ) : null} {showExpression ? ( {expressions.default.map((e, i) => ( insertExpression(e)} > ))} ) : null} ); } const styles = StyleSheet.create({ safeView: { backgroundColor: 'rgba(255, 255, 255, 0.5)', }, container: { paddingTop: 4, }, inputContainer: { flexDirection: 'row', paddingLeft: 10, paddingRight: 10, }, input: { flex: 1, height: 36, paddingLeft: 8, paddingRight: 8, backgroundColor: 'white', borderWidth: 1, borderColor: '#e5e5e5', borderRadius: 5, }, sendButton: { width: 50, height: 36, marginLeft: 8, paddingLeft: 10, }, button: { height: 36, marginTop: 4, marginLeft: 10, marginRight: 10, marginBottom: 8, }, buttonText: { color: 'white', }, iconContainer: { height: 40, }, icon: { transform: [ { // @ts-ignore translate: [0, -3], }, ], }, iconButtonContainer: { flexDirection: 'row', paddingLeft: 15, paddingRight: 15, height: 44, }, iconButton: { width: '15%', }, cancelButton: { borderTopWidth: 1, borderTopColor: '#e6e6e6', }, cancelButtonText: { color: '#666', }, // 表情框 expressionContainer: { height: (isiOS ? 34 : 30) * 5 + 6, flexDirection: 'row', flexWrap: 'wrap', paddingTop: 3, paddingBottom: 3, paddingLeft: 8, paddingRight: 8, }, expression: { width: ExpressionSize, height: isiOS ? 34 : 30, alignItems: 'center', justifyContent: 'center', }, }); ================================================ FILE: packages/app/src/pages/Chat/InviteMessage.tsx ================================================ import { View, Text } from 'native-base'; import React from 'react'; import { StyleSheet, TouchableNativeFeedback } from 'react-native'; import { Actions } from 'react-native-router-flux'; import Toast from '../../components/Toast'; import { getLinkmanHistoryMessages, joinGroup } from '../../service'; import action from '../../state/action'; import { Message } from '../../types/redux'; type Props = { message: Message; isSelf: boolean; }; function InviteMessage({ message, isSelf }: Props) { const invite = JSON.parse(message.content); async function handleJoinGroup() { const group = await joinGroup(invite.group); if (group) { group.type = 'group'; action.addLinkman(group, true); Actions.refresh({ title: group.name }); Toast.success('加入群组成功'); const messages = await getLinkmanHistoryMessages(invite.group, 0); if (messages) { action.addLinkmanHistoryMessages(invite.group, messages); } } } return ( " {invite.inviterName} " 邀请你加入群组「 {invite.groupName}」 加入 ); } export default InviteMessage; const styles = StyleSheet.create({ container: { width: '90%', alignItems: 'center', }, text: { fontSize: 14, textAlign: 'center', lineHeight: 16, }, info: { width: '100%', borderBottomWidth: 1, borderBottomColor: 'white', paddingBottom: 4, }, join: { width: '100%', paddingTop: 4, paddingBottom: 2, }, }); ================================================ FILE: packages/app/src/pages/Chat/Message.tsx ================================================ import React, { useEffect } from 'react'; import { View, Text, StyleSheet, Dimensions, TouchableOpacity, } from 'react-native'; import Triangle from '@react-native-toolkit/triangle'; import { ActionSheet } from 'native-base'; import { Actions } from 'react-native-router-flux'; import Time from '../../utils/time'; import Avatar from '../../components/Avatar'; import { Message as MessageType } from '../../types/redux'; import SystemMessage from './SystemMessage'; import ImageMessage from './ImageMessage'; import TextMessage from './TextMessage'; import { getRandomColor } from '../../utils/getRandomColor'; import InviteMessage from './InviteMessage'; import { useFocus, useIsAdmin, useSelfId, useTheme, } from '../../hooks/useStore'; import { deleteMessage } from '../../service'; import action from '../../state/action'; const { width: ScreenWidth } = Dimensions.get('window'); type Props = { message: MessageType; isSelf: boolean; shouldScroll: boolean; scrollToEnd: () => void; openImageViewer: (imageUrl: string) => void; }; function Message({ message, isSelf, shouldScroll, scrollToEnd, openImageViewer, }: Props) { const { primaryColor8 } = useTheme(); const isAdmin = useIsAdmin(); const self = useSelfId(); const focus = useFocus(); const couldDelete = message.type !== 'system' && (isAdmin || message.from._id === self); useEffect(() => { if (shouldScroll) { scrollToEnd(); } }, []); async function handleDeleteMessage() { const options = ['撤回', '取消']; ActionSheet.show( { options: ['确定', '取消'], cancelButtonIndex: options.findIndex( (option) => option === '取消', ), title: '是否撤回消息?', }, async (buttonIndex) => { switch (buttonIndex) { case 0: { const isSuccess = await deleteMessage(message._id); if (isSuccess) { action.deleteLinkmanMessage(focus, message._id); } break; } default: { break; } } }, ); } function formatTime() { const createTime = new Date(message.createTime); const nowTime = new Date(); if (Time.isToday(nowTime, createTime)) { return Time.getHourMinute(createTime); } if (Time.isYesterday(nowTime, createTime)) { return `昨天 ${Time.getHourMinute(createTime)}`; } if (Time.isSameYear(nowTime, createTime)) { return `${Time.getMonthDate(createTime)} ${Time.getHourMinute( createTime, )}`; } return `${Time.getYearMonthDate(createTime)} ${Time.getHourMinute( createTime, )}`; } function handleClickAvatar() { Actions.push('userInfo', { user: message.from }); } function renderContent() { switch (message.type) { case 'text': { return ; } case 'image': { return ( ); } case 'system': { return ; } case 'inviteV2': { return ; } case 'file': case 'code': { return ( 暂未支持的消息类型[ {message.type} ], 请在Web端查看 ); } default: return ( 不支持的消息类型 ); } } return ( {isSelf ? ( ) : ( )} {!!message.from.tag && ( {message.from.tag} )} {message.from.username} {formatTime()} {couldDelete ? ( {renderContent()} ) : ( {renderContent()} )} ); } export default React.memo(Message); const styles = StyleSheet.create({ container: { flexDirection: 'row', marginBottom: 6, paddingLeft: 8, paddingRight: 8, }, containerSelf: { flexDirection: 'row-reverse', }, info: { position: 'relative', marginLeft: 8, marginRight: 8, maxWidth: ScreenWidth - 120, alignItems: 'flex-start', }, infoSelf: { alignItems: 'flex-end', }, nickTime: { flexDirection: 'row', }, nickTimeSelf: { flexDirection: 'row-reverse', }, nick: { fontSize: 13, color: '#333', }, nickSelf: { marginRight: 4, }, nickOther: { marginLeft: 4, }, time: { fontSize: 12, color: '#666', marginLeft: 4, }, timeSelf: { marginRight: 4, }, content: { marginTop: 3, borderRadius: 6, padding: 5, paddingLeft: 8, paddingRight: 8, backgroundColor: 'white', minHeight: 26, minWidth: 20, marginBottom: 6, }, triangle: { position: 'absolute', top: 25, }, triangleSelf: { right: -5, }, triangleOther: { left: -5, }, tag: { height: 14, alignItems: 'center', justifyContent: 'center', paddingLeft: 3, paddingRight: 3, borderRadius: 3, }, tagText: { fontSize: 11, color: 'white', }, }); ================================================ FILE: packages/app/src/pages/Chat/MessageList.tsx ================================================ import React, { useEffect, useState } from 'react'; import { ScrollView, StyleSheet, Keyboard, Modal, Image } from 'react-native'; import ImageViewer from 'react-native-image-zoom-viewer'; import action from '../../state/action'; import fetch from '../../utils/fetch'; import Message from './Message'; import { useFocusLinkman, useIsLogin, useSelfId, useStore, } from '../../hooks/useStore'; import { Message as MessageType } from '../../types/redux'; import Toast from '../../components/Toast'; import { isAndroid, isiOS } from '../../utils/platform'; import { referer } from '../../utils/constant'; type Props = { $scrollView: React.MutableRefObject; }; let prevContentHeight = 0; let prevMessageCount = 0; let shouldScroll = true; let isFirstTimeFetchHistory = true; function MessageList({ $scrollView }: Props) { const isLogin = useIsLogin(); const self = useSelfId(); const focusLinkman = useFocusLinkman(); const { focus } = useStore(); const messages = focusLinkman?.messages || []; const [refreshing, setRefreshing] = useState(false); const [showImageViewerDialog, toggleShowImageViewerDialog] = useState( false, ); const [imageViewerIndex, setImageViewerIndex] = useState(0); useEffect(() => { const keyboardDidShowListener = Keyboard.addListener( 'keyboardWillShow', handleKeyboardShow, ); return () => { prevContentHeight = 0; prevMessageCount = 0; shouldScroll = true; isFirstTimeFetchHistory = true; keyboardDidShowListener.remove(); }; }, []); function getImages() { const imageMessages = messages.filter( (message) => message.type === 'image', ); const images = imageMessages.map((message) => { const url = message.content; const parseResult = /width=(\d+)&height=(\d+)/.exec(url); return { url: `${url.startsWith('//') ? 'https:' : ''}${url}`, ...(parseResult ? { width: +parseResult[1], height: +parseResult[2], } : {}), }; }); return images; } function scrollToEnd(time = 0) { if (time > 200) { return; } if ($scrollView.current) { $scrollView.current!.scrollToEnd({ animated: false }); } setTimeout(() => { scrollToEnd(time + 50); }, 50); } function handleKeyboardShow() { scrollToEnd(); } async function handleRefresh() { if (refreshing) { return; } if (isFirstTimeFetchHistory && isAndroid) { isFirstTimeFetchHistory = false; return; } setRefreshing(true); let err = null; let result = null; if (isLogin) { [err, result] = await fetch('getLinkmanHistoryMessages', { linkmanId: focus, existCount: messages.length, }); } else { [err, result] = await fetch('getDefalutGroupHistoryMessages', { existCount: messages.length, }); } if (!err) { if (result.length > 0) { action.addLinkmanHistoryMessages(focus, result); } else { Toast.warning('没有更多消息了'); } } setTimeout(() => { setRefreshing(false); }, 1000); } /** * 加载历史消息后, 自动滚动到合适位置 */ function handleContentSizeChange( contentWidth: number, contentHeight: number, ) { if (prevContentHeight === 0) { $scrollView.current!.scrollTo({ x: 0, y: 0, animated: false, }); } else if ( contentHeight !== prevContentHeight && messages.length - prevMessageCount > 1 ) { $scrollView.current!.scrollTo({ x: 0, y: contentHeight - prevContentHeight - 60, animated: false, }); } prevContentHeight = contentHeight; prevMessageCount = messages.length; } function handleScroll(event: any) { const { layoutMeasurement, contentSize, contentOffset, } = event.nativeEvent; shouldScroll = contentOffset.y > contentSize.height - layoutMeasurement.height * 1.2; if (contentOffset.y < (isiOS ? 0 : 50)) { handleRefresh(); } } function openImageViewer(url: string) { const images = getImages(); const index = images.findIndex( (image) => image.url.indexOf(url) !== -1, ); toggleShowImageViewerDialog(true); setImageViewerIndex(index); } function renderMessage(message: MessageType) { return ( ); } function closeImageViewerDialog() { toggleShowImageViewerDialog(false); } return ( {messages.map((message) => renderMessage(message))} ( )} /> ); } export default MessageList; const styles = StyleSheet.create({ container: { paddingTop: 8, paddingBottom: 8, }, }); ================================================ FILE: packages/app/src/pages/Chat/SystemMessage.tsx ================================================ import { View, Text } from 'native-base'; import React from 'react'; import { StyleSheet } from 'react-native'; import { Message } from '../../types/redux'; import { getPerRandomColor } from '../../utils/getRandomColor'; type Props = { message: Message; }; function SystemMessage({ message }: Props) { const { content, from } = message; return ( {from.originUsername}   {content} ); } export default SystemMessage; const styles = StyleSheet.create({ container: { flexDirection: 'row', alignItems: 'center', }, text: { fontSize: 14, }, }); ================================================ FILE: packages/app/src/pages/Chat/TextMessage.tsx ================================================ import { View, Text } from 'native-base'; import React from 'react'; import { TouchableOpacity, Linking, StyleSheet } from 'react-native'; import Expression from '../../components/Expression'; import { Message } from '../../types/redux'; import expressions from '../../utils/expressions'; type Props = { message: Message; isSelf: boolean; }; function TextMessage({ message, isSelf }: Props) { const children = []; let copy = message.content; function push(str: string) { children.push( {str} , ); } // 处理文本消息中的表情和链接 let offset = 0; while (copy.length > 0) { const regex = /#\(([\u4e00-\u9fa5a-z]+)\)|https?:\/\/(www\.)?[-a-zA-Z0-9@:%._+~#=]{2,256}\.[a-z]{2,6}\b([-a-zA-Z0-9@:%_+.~#?&//=]*)/g; const matchResult = regex.exec(copy); if (matchResult) { const r = matchResult[0]; const e = matchResult[1]; const i = copy.indexOf(r); if (r[0] === '#') { // 表情消息 const index = expressions.default.indexOf(e); if (index !== -1) { // 处理从开头到匹配位置的文本 if (i > 0) { push(copy.substring(0, i)); } children.push( , ); offset += i + r.length; } } else { // 链接消息 if (i > 0) { push(copy.substring(0, i)); } children.push( Linking.openURL(r)} > {// Do not nest in view error in dev environment process.env.NODE_ENV === 'development' ? ( {r} ) : ( {r} )} , ); offset += i + r.length; } copy = copy.substr(i + r.length); } else { break; } } // 处理剩余文本 if (offset < message.content.length) { push(message.content.substring(offset, message.content.length)); } return {children}; } export default TextMessage; const styles = StyleSheet.create({ container: { // width: '100%', flexDirection: 'row', flexWrap: 'wrap', alignItems: 'flex-end', }, text: { flexShrink: 1, }, textSelf: { color: 'white', }, expression: { marginLeft: 1, marginRight: 1, transform: [ { translateY: 3, }, ], }, }); ================================================ FILE: packages/app/src/pages/ChatList/ChatList.tsx ================================================ import React, { useState } from 'react'; import { ScrollView, StyleSheet } from 'react-native'; import { Header, Item, Icon, Input } from 'native-base'; import { Actions } from 'react-native-router-flux'; import Linkman from './Linkman'; import { useLinkmans } from '../../hooks/useStore'; import { Linkman as LinkmanType } from '../../types/redux'; import PageContainer from '../../components/PageContainer'; import { search } from '../../service'; import { isiOS } from '../../utils/platform'; export default function ChatList() { const [searchKeywords, updateSearchKeywords] = useState(''); const linkmans = useLinkmans(); async function handleSearch() { const result = await search(searchKeywords); updateSearchKeywords(''); Actions.push('searchResult', result); } function renderLinkman(linkman: LinkmanType) { const { _id: linkmanId, unread, messages, createTime } = linkman; const lastMessage = messages.length > 0 ? messages[messages.length - 1] : null; let time = new Date(createTime); let preview = '暂无消息'; if (lastMessage) { time = new Date(lastMessage.createTime); preview = lastMessage.type === 'text' ? `${lastMessage.content}` : `[${lastMessage.type}]`; if (linkman.type === 'group') { preview = `${lastMessage.from.username}: ${preview}`; } } return ( ); } return (
{linkmans && linkmans.map((linkman) => renderLinkman(linkman))}
); } const styles = StyleSheet.create({ messageList: {}, searchContainer: { marginTop: isiOS ? 0 : 5, backgroundColor: 'transparent', height: 42, borderBottomWidth: 0, }, searchItem: { backgroundColor: 'rgba(255,255,255,0.5)', }, searchIcon: { color: '#555', }, searchText: { fontSize: 14, }, }); ================================================ FILE: packages/app/src/pages/ChatList/ChatListRightButton.tsx ================================================ import { View, Icon } from 'native-base'; import React, { useState } from 'react'; import { StyleSheet, TouchableOpacity } from 'react-native'; import { Actions } from 'react-native-router-flux'; import Dialog from 'react-native-dialog'; import { createGroup } from '../../service'; import action from '../../state/action'; function ChatListRightButton() { const [showDialog, toggleDialog] = useState(false); const [groupName, updateGroupName] = useState(''); function handleCloseDialog() { updateGroupName(''); toggleDialog(false); } async function handleCreateGroup() { const group = await createGroup(groupName); if (group) { action.addLinkman({ ...group, type: 'group', unread: 1, messages: [], }); action.setFocus(group._id); handleCloseDialog(); Actions.push('chat', { title: group.name }); } } return ( <> toggleDialog(true)}> 创建群组 请输入群组名 ); } export default ChatListRightButton; const styles = StyleSheet.create({ container: { width: 44, height: 44, flexDirection: 'row', alignItems: 'center', justifyContent: 'center', }, icon: { color: 'white', fontSize: 32, }, }); ================================================ FILE: packages/app/src/pages/ChatList/Linkman.tsx ================================================ import React from 'react'; import { Text, StyleSheet, View, TouchableOpacity } from 'react-native'; import { Actions } from 'react-native-router-flux'; import Time from '../../utils/time'; import action from '../../state/action'; import Avatar from '../../components/Avatar'; import { Linkman as LinkmanType } from '../../types/redux'; import { formatLinkmanName } from '../../utils/linkman'; import fetch from '../../utils/fetch'; type Props = { id: string; name: string; avatar: string; preview: string; time: Date; unread: number; lastMessageId: string; linkman: LinkmanType; }; export default function Linkman({ id, name, avatar, preview, time, unread, lastMessageId, linkman, }: Props) { function formatTime() { const nowTime = new Date(); if (Time.isToday(nowTime, time)) { return Time.getHourMinute(time); } if (Time.isYesterday(nowTime, time)) { return '昨天'; } if (Time.isSameYear(nowTime, time)) { return Time.getMonthDate(time); } return Time.getYearMonthDate(time); } function handlePress() { action.setFocus(id); Actions.chat({ title: formatLinkmanName(linkman) }); if (id && lastMessageId) { fetch('updateHistory', { linkmanId: id, messageId: lastMessageId }); } } return ( {name} {formatTime()} {preview} {unread > 0 ? ( {unread > 99 ? '99' : unread} ) : null} ); } const styles = StyleSheet.create({ container: { flexDirection: 'row', height: 70, alignItems: 'center', paddingLeft: 16, paddingRight: 16, }, content: { flex: 1, marginLeft: 8, }, nickTime: { flexDirection: 'row', justifyContent: 'space-between', }, nick: { fontSize: 16, color: '#333', }, time: { fontSize: 14, color: '#888', }, previewUnread: { marginTop: 8, flexDirection: 'row', justifyContent: 'space-between', }, preview: { flex: 1, fontSize: 14, color: '#666', }, unread: { backgroundColor: '#2a7bf6', width: 18, height: 18, borderRadius: 9, alignItems: 'center', justifyContent: 'center', marginLeft: 5, }, unreadText: { fontSize: 10, color: 'white', }, }); ================================================ FILE: packages/app/src/pages/ChatList/SelfInfo.tsx ================================================ import { Text, View } from 'native-base'; import React from 'react'; import { StyleSheet } from 'react-native'; import Avatar from '../../components/Avatar'; import { useIsLogin, useStore, useTheme, useUser } from '../../hooks/useStore'; function SelfInfo() { const isLogin = useIsLogin(); const user = useUser(); const { primaryTextColor10 } = useTheme(); const { connect } = useStore(); if (!isLogin) { return null; } const { avatar, username } = user; return ( {username} ); } export default SelfInfo; const styles = StyleSheet.create({ container: { flexDirection: 'row', alignItems: 'center', height: 38, paddingLeft: 8, paddingRight: 8, }, avatar: { position: 'relative', }, nickname: { marginLeft: 8, }, onlineStatus: { width: 10, height: 10, borderRadius: 5, position: 'absolute', right: 0, bottom: 0, }, online: { backgroundColor: 'rgba(94, 212, 92, 1)', }, offline: { backgroundColor: 'rgba(206, 12, 35, 1)', }, }); ================================================ FILE: packages/app/src/pages/GroupInfo/GroupInfo.tsx ================================================ import React from 'react'; import { Button, Text, View } from 'native-base'; import { StyleSheet } from 'react-native'; import { Actions } from 'react-native-router-flux'; import PageContainer from '../../components/PageContainer'; import Avatar from '../../components/Avatar'; import { useFocusLinkman, useLinkmans } from '../../hooks/useStore'; import { Linkman } from '../../types/redux'; import action from '../../state/action'; import { getLinkmanHistoryMessages, joinGroup } from '../../service'; type Props = { group: { _id: string; avatar: string; name: string; members: number; }; }; function GroupInfo({ group }: Props) { const { _id, avatar, name, members } = group; const linkmans = useLinkmans(); const linkman = linkmans.find( (x) => x._id === _id && x.type === 'group', ) as Linkman; const isJoined = !!linkman; const currentLinkman = useFocusLinkman() as Linkman; function handleSendMessage() { action.setFocus(group._id); if (currentLinkman._id === group._id) { Actions.popTo('chat'); } else { Actions.popTo('_chatlist'); Actions.push('chat', { title: group.name }); } } async function handleJoinGroup() { const newLinkman = await joinGroup(_id); if (newLinkman) { action.addLinkman({ ...newLinkman, type: 'group', unread: 0, messages: [], }); const messages = await getLinkmanHistoryMessages(_id, 0); action.addLinkmanHistoryMessages(_id, messages); action.setFocus(_id); Actions.popTo('_chatlist'); Actions.push('chat', { title: newLinkman.name }); } } return ( {name} 人数: {members} {isJoined ? ( ) : ( )} ); } export default GroupInfo; const styles = StyleSheet.create({ container: { paddingTop: 20, paddingLeft: 16, paddingRight: 16, }, userContainer: { alignItems: 'center', }, infoContainer: { marginTop: 20, }, infoRow: { flexDirection: 'row', }, infoLabel: { color: '#666', }, infoValue: { color: '#333', marginLeft: 12, }, nick: { color: '#333', marginTop: 6, }, buttonContainer: { marginTop: 20, }, button: { marginBottom: 12, }, }); ================================================ FILE: packages/app/src/pages/GroupProfile/GroupProfile.tsx ================================================ import { View, Text, Button } from 'native-base'; import React from 'react'; import { Alert, Pressable, ScrollView, StyleSheet } from 'react-native'; import { Actions } from 'react-native-router-flux'; import Avatar from '../../components/Avatar'; import PageContainer from '../../components/PageContainer'; import { useFocusLinkman, useSelfId } from '../../hooks/useStore'; import { deleteGroup, leaveGroup } from '../../service'; import action from '../../state/action'; import { Group } from '../../types/redux'; function GroupProfile() { const linkman = useFocusLinkman() as Group; const self = useSelfId(); const isGroupCreator = linkman.creator === self; function getOS(os: string) { return os === 'Windows Server 2008 R2 / 7' ? 'Windows 7' : os; } function ShowEnvironment(environment: string) { Alert.alert('设备信息', environment); } async function handleLeaveGroup() { if (isGroupCreator) { const isSuccess = await deleteGroup(linkman._id); if (isSuccess) { action.removeLinkman(linkman._id); Actions.popTo('_chatlist', { title: '' }); } } else { const isSuccess = await leaveGroup(linkman._id); if (isSuccess) { action.removeLinkman(linkman._id); Actions.popTo('_chatlist', { title: '' }); } } } return ( 功能 在线成员 {linkman.members.map((member) => ( {member.user.username} ShowEnvironment(member.environment) } > {member.browser} {getOS(member.os)} ))} ); } export default GroupProfile; const styles = StyleSheet.create({ container: { paddingLeft: 12, paddingRight: 12, paddingTop: 8, paddingBottom: 8, }, section: { marginBottom: 24, }, sectionTitle: { fontSize: 18, fontWeight: 'bold', marginBottom: 12, }, member: { flexDirection: 'row', alignItems: 'center', height: 32, }, memberName: { fontSize: 14, color: '#333', marginLeft: 8, }, memberInfoContainer: { flex: 1, }, memberInfo: { fontSize: 12, color: '#666', textAlign: 'right', }, }); ================================================ FILE: packages/app/src/pages/LoginSignup/Base.tsx ================================================ import React, { useRef, useState } from 'react'; import { Alert, StyleSheet, Text, TextInput } from 'react-native'; import { Form, Label, Button, View } from 'native-base'; import { Actions } from 'react-native-router-flux'; import PageContainer from '../../components/PageContainer'; type Props = { buttonText: string; jumpText: string; jumpPage: string; onSubmit: (username: string, password: string) => void; }; export default function Base({ buttonText, jumpText, jumpPage, onSubmit, }: Props) { const [username, setUsername] = useState(''); const [password, setPassword] = useState(''); const $username = useRef(); const $password = useRef(); function handlePress() { $username.current!.blur(); $password.current!.blur(); onSubmit(username, password); } function handleJump() { if (Actions[jumpPage]) { Actions.replace(jumpPage); } else { Alert.alert(`跳转 ${jumpPage} 失败`); } } return (
); } const styles = StyleSheet.create({ container: { paddingLeft: 12, paddingRight: 12, paddingTop: 20, }, button: { marginTop: 18, }, buttonText: { fontSize: 18, color: '#fafafa', }, signup: { alignSelf: 'flex-end', }, signupText: { color: '#2a7bf6', fontSize: 14, }, label: { marginBottom: 8, }, input: { height: 42, fontSize: 16, borderRadius: 6, marginBottom: 12, paddingLeft: 6, borderWidth: 1, borderColor: '#777', }, }); ================================================ FILE: packages/app/src/pages/LoginSignup/Login.tsx ================================================ import React from 'react'; import { Container } from 'native-base'; import { Actions } from 'react-native-router-flux'; import fetch from '../../utils/fetch'; import platform from '../../utils/platform'; import action from '../../state/action'; import Base from './Base'; import { setStorageValue } from '../../utils/storage'; import { Friend, Group } from '../../types/redux'; export default function Login() { async function handleSubmit(username: string, password: string) { const [err, res] = await fetch('login', { username, password, ...platform, }); if (!err) { const user = res; action.setUser(user); const linkmanIds = [ ...user.groups.map((g: Group) => g._id), ...user.friends.map((f: Friend) => f._id), ]; const [err2, linkmans] = await fetch('getLinkmansLastMessagesV2', { linkmans: linkmanIds, }); if (!err2) { action.setLinkmansLastMessages(linkmans); } Actions.pop(); await setStorageValue('token', res.token); } } return ( ); } ================================================ FILE: packages/app/src/pages/LoginSignup/Signup.tsx ================================================ import React from 'react'; import { Container, Toast } from 'native-base'; import { Actions } from 'react-native-router-flux'; import fetch from '../../utils/fetch'; import platform from '../../utils/platform'; import action from '../../state/action'; import Base from './Base'; import { setStorageValue } from '../../utils/storage'; import { Friend, Group } from '../../types/redux'; export default function Signup() { async function handleSubmit(username: string, password: string) { const [err, res] = await fetch('register', { username, password, ...platform, }); if (!err) { Toast.show({ text: '创建成功', type: 'success', }); const user = res; action.setUser(user); const linkmanIds = [ ...user.groups.map((g: Group) => g._id), ...user.friends.map((f: Friend) => f._id), ]; const [err2, linkmans] = await fetch('getLinkmansLastMessagesV2', { linkmans: linkmanIds, }); if (!err2) { action.setLinkmansLastMessages(linkmans); } Actions.chatlist(); await setStorageValue('token', res.token); } } return ( ); } ================================================ FILE: packages/app/src/pages/Other/Other.tsx ================================================ import { Body, Button, Content, Icon, List, ListItem, Right, Text, Toast, View, } from 'native-base'; import React, { useEffect, useState } from 'react'; import { Linking, StyleSheet } from 'react-native'; import { Actions } from 'react-native-router-flux'; import PageContainer from '../../components/PageContainer'; import { useIsLogin } from '../../hooks/useStore'; import socket from '../../socket'; import action from '../../state/action'; import { getStorageValue, removeStorageValue } from '../../utils/storage'; import appInfo from '../../../app.json'; import Avatar from '../../components/Avatar'; import PrivacyPolicy, { PrivacyPolicyStorageKey } from './PrivacyPolicy'; function getIsNight() { const hour = new Date().getHours(); return hour >= 18 || hour < 6; } function Other() { const isLogin = useIsLogin(); const [isNight, setIsNight] = useState(getIsNight()); const [showPrivacyPolicy, togglePrivacyPolicy] = useState(false); async function getPrivacyPolicyStatus() { const privacyPoliceStorageValue = await getStorageValue( PrivacyPolicyStorageKey, ); togglePrivacyPolicy(privacyPoliceStorageValue !== 'true'); } useEffect(() => { const timer = setInterval(() => { setIsNight(getIsNight()); }, 1000); getPrivacyPolicyStatus(); return () => { clearInterval(timer); }; }, []); async function logout() { action.logout(); await removeStorageValue('token'); Toast.show({ text: '您已经退出登录' }); socket.disconnect(); socket.connect(); } async function login() { const privacyPoliceStorageValue = await getStorageValue( PrivacyPolicyStorageKey, ); if (privacyPoliceStorageValue !== 'true') { togglePrivacyPolicy(true); return; } Actions.push('login'); } return ( fiora v{appInfo.expo.version} Linking.openURL( 'https://github.com/yinxin630/fiora-app', ) } > 源码 Linking.openURL('https://www.suisuijiang.com') } > 作者 Linking.openURL('https://fiora.suisuijiang.com') } > fiora 网页版 {isLogin ? ( ) : ( )} Copyright© 2015- {new Date().getFullYear()} 碎碎酱 togglePrivacyPolicy(false)} /> ); } const styles = StyleSheet.create({ logoutButton: { marginLeft: 12, marginRight: 12, }, app: { alignItems: 'center', paddingTop: 12, }, name: { marginTop: 6, color: '#222', }, list: { marginTop: 20, backgroundColor: 'rgba(255, 255, 255, 0.4)', }, listItemTitle: { color: '#333', }, listItemArrow: { color: '#999', }, github: { fontSize: 26, color: '#000', }, copyrightContainer: { marginTop: 12, marginBottom: 6, }, copyright: { fontSize: 10, textAlign: 'center', color: '#666', }, }); export default Other; ================================================ FILE: packages/app/src/pages/Other/PrivacyPolicy.tsx ================================================ import { Text } from 'native-base'; import React from 'react'; import { Linking, StyleSheet, TouchableOpacity } from 'react-native'; import Dialog from 'react-native-dialog'; import { removeStorageValue, setStorageValue } from '../../utils/storage'; export const PrivacyPolicyStorageKey = 'privacy-policy'; type Props = { visible: boolean; onClose: () => void; }; function PrivacyPolicy({ visible, onClose }: Props) { function handleClickPrivacyPolicy() { Linking.openURL('https://fiora.suisuijiang.com/PrivacyPolicy.html'); } async function handleAgree() { await setStorageValue(PrivacyPolicyStorageKey, 'true'); onClose(); } async function handleDisagree() { await removeStorageValue(PrivacyPolicyStorageKey); onClose(); } return ( 服务协议和隐私条款 欢迎使用 fiora APP。我们非常重视您的个人信息和隐私保护,在您使用之前,请务必审慎阅读 《隐私政策》 ,并充分理解协议条款内容。我们将严格按照您同意的各项条款使用您的个人信息,以便为您提供更好的服务。 ); } export default PrivacyPolicy; const styles = StyleSheet.create({ container: { textAlign: 'left', }, text: { fontSize: 12, color: '#2a7bf6', }, }); ================================================ FILE: packages/app/src/pages/Other/Sponsor.tsx ================================================ import { View, Text } from 'native-base'; import React from 'react'; import { StyleSheet } from 'react-native'; import Dialog from 'react-native-dialog'; type Props = { visible: boolean; onClose: () => void; onOK: () => void; }; function Sponsor({ visible, onClose, onOK }: Props) { return ( 赞助 如果你觉得这个聊天室还不错的话, 希望能赞助一下~~ 请在转账备注中填写您的 fiora 账号 ); } export default Sponsor; const styles = StyleSheet.create({ text: { fontSize: 14, color: '#333', marginTop: 16, }, tip: { fontSize: 12, color: '#666', textAlign: 'center', marginTop: 12, }, }); ================================================ FILE: packages/app/src/pages/SearchResult/SearchResult.tsx ================================================ import React from 'react'; import { Tab, Tabs, Text, View } from 'native-base'; import { ScrollView, StyleSheet, TouchableOpacity } from 'react-native'; import { Actions } from 'react-native-router-flux'; import PageContainer from '../../components/PageContainer'; import Avatar from '../../components/Avatar'; type Props = { groups: { _id: string; name: string; avatar: string; members: number; }[]; users: { _id: string; username: string; avatar: string; }[]; }; function SearchResult({ groups, users }: Props) { function handleClickGroup(group: any) { Actions.push('groupInfo', { group }); } function handleClickUser(user: any) { Actions.push('userInfo', { user }); } return ( {groups.map((group) => ( handleClickGroup(group)} > {group.name} {group.members}人 ))} {users.map((user) => ( handleClickUser(user)} > {user.username} ))} ); } export default SearchResult; const styles = StyleSheet.create({ container: { backgroundColor: 'transparent', }, item: { height: 56, flexDirection: 'row', alignItems: 'center', paddingLeft: 16, paddingRight: 16, }, groupInfo: { marginLeft: 8, }, groupName: { color: '#444', }, groupMembers: { fontSize: 14, color: '#888', marginTop: 1, }, username: { color: '#444', marginLeft: 8, }, }); ================================================ FILE: packages/app/src/pages/UserInfo/UserInfo.tsx ================================================ import React from 'react'; import { Button, Text, View } from 'native-base'; import { StyleSheet } from 'react-native'; import { Actions } from 'react-native-router-flux'; import PageContainer from '../../components/PageContainer'; import Avatar from '../../components/Avatar'; import { useFocusLinkman, useIsAdmin, useLinkmans, useSelfId, } from '../../hooks/useStore'; import { Linkman } from '../../types/redux'; import action from '../../state/action'; import { addFriend, deleteFriend, getLinkmanHistoryMessages, sealUser, sealUserOnlineIp, } from '../../service'; import getFriendId from '../../utils/getFriendId'; import Toast from '../../components/Toast'; type Props = { user: { _id: string; avatar: string; tag: string; username: string; }; }; function UserInfo({ user }: Props) { const { _id, avatar, username } = user; const linkmans = useLinkmans(); const friend = linkmans.find((linkman) => linkman._id.includes(_id), ) as Linkman; const isFriend = friend && friend.type === 'friend'; const isAdmin = useIsAdmin(); const currentLinkman = useFocusLinkman() as Linkman; const self = useSelfId(); function handleSendMessage() { action.setFocus(friend._id); if (currentLinkman._id === friend._id) { Actions.pop(); } else { Actions.popTo('_chatlist'); Actions.push('chat', { title: friend.name }); } } async function handleDeleteFriend() { const isSuccess = await deleteFriend(_id); if (isSuccess) { action.removeLinkman(friend._id); if (currentLinkman._id === friend._id) { Actions.popTo('_chatlist'); } else { Actions.pop(); } } } async function handleAddFriend() { const newLinkman = await addFriend(_id); const friendId = getFriendId(_id, self); if (newLinkman) { if (friend) { action.updateFriendProperty(friend._id, 'type', 'friend'); const messages = await getLinkmanHistoryMessages( friend._id, friend.messages.length, ); action.addLinkmanHistoryMessages(friend._id, messages); } else { action.addLinkman({ ...newLinkman, _id: friendId, name: username, type: 'friend', unread: 0, messages: [], from: self, to: { _id, avatar, username, }, }); const messages = await getLinkmanHistoryMessages(friendId, 0); action.addLinkmanHistoryMessages(friendId, messages); } action.setFocus(friendId); if (currentLinkman._id === friend?._id) { Actions.pop(); } else { Actions.popTo('_chatlist'); Actions.push('chat', { title: newLinkman.username }); } } } async function handleSealUser() { const isSuccess = await sealUser(username); if (isSuccess) { Toast.success('封禁用户成功'); } } async function handleSealIp() { const isSuccess = await sealUserOnlineIp(_id); if (isSuccess) { Toast.success('封禁用户当前ip成功'); } } return ( {username} {isFriend ? ( <> ) : ( )} {isAdmin && ( <> )} ); } export default UserInfo; const styles = StyleSheet.create({ container: { paddingTop: 20, paddingLeft: 16, paddingRight: 16, }, userContainer: { alignItems: 'center', }, nick: { color: '#333', marginTop: 6, }, buttonContainer: { marginTop: 20, }, button: { marginBottom: 12, }, }); ================================================ FILE: packages/app/src/service.ts ================================================ import { User } from './types/redux'; import fetch from './utils/fetch'; function saveUsername(username: string) { window.localStorage.setItem('username', username); } /** * 注册新用户 * @param username 用户名 * @param password 密码 * @param os 系统 * @param browser 浏览器 * @param environment 环境信息 */ export async function register( username: string, password: string, os = '', browser = '', environment = '', ) { const [err, user] = await fetch('register', { username, password, os, browser, environment, }); if (err) { return null; } saveUsername(user.username); return user; } /** * 使用账密登录 * @param username 用户名 * @param password 密码 * @param os 系统 * @param browser 浏览器 * @param environment 环境信息 */ export async function login( username: string, password: string, os = '', browser = '', environment = '', ) { const [err, user] = await fetch('login', { username, password, os, browser, environment, }); if (err) { return null; } saveUsername(user.username); return user; } /** * 使用token登录 * @param token 登录token * @param os 系统 * @param browser 浏览器 * @param environment 环境信息 */ export async function loginByToken( token: string, os = '', browser = '', environment = '', ) { const [err, user] = await fetch( 'loginByToken', { token, os, browser, environment, }, { toast: false }, ); if (err) { return null; } saveUsername(user.username); return user; } /** * 游客模式登陆 * @param os 系统 * @param browser 浏览器 * @param environment 环境信息 */ export async function guest(os = '', browser = '', environment = '') { const [err, res] = await fetch('guest', { os, browser, environment }); if (err) { return null; } return res; } /** * 修用户头像 * @param avatar 新头像链接 */ export async function changeAvatar(avatar: string) { const [error] = await fetch('changeAvatar', { avatar }); return !error; } /** * 修改用户密码 * @param oldPassword 旧密码 * @param newPassword 新密码 */ export async function changePassword(oldPassword: string, newPassword: string) { const [error] = await fetch('changePassword', { oldPassword, newPassword, }); return !error; } /** * 修改用户名 * @param username 新用户名 */ export async function changeUsername(username: string) { const [error] = await fetch('changeUsername', { username, }); return !error; } /** * 修改群组名 * @param groupId 目标群组 * @param name 新名字 */ export async function changeGroupName(groupId: string, name: string) { const [error] = await fetch('changeGroupName', { groupId, name }); return !error; } /** * 修改群头像 * @param groupId 目标群组 * @param name 新头像 */ export async function changeGroupAvatar(groupId: string, avatar: string) { const [error] = await fetch('changeGroupAvatar', { groupId, avatar }); return !error; } /** * 创建群组 * @param name 群组名 */ export async function createGroup(name: string) { const [, group] = await fetch('createGroup', { name }); return group; } /** * 删除群组 * @param groupId 群组id */ export async function deleteGroup(groupId: string) { const [error] = await fetch('deleteGroup', { groupId }); return !error; } /** * 加入群组 * @param groupId 群组id */ export async function joinGroup(groupId: string) { const [, group] = await fetch('joinGroup', { groupId }); return group; } /** * 离开群组 * @param groupId 群组id */ export async function leaveGroup(groupId: string) { const [error] = await fetch('leaveGroup', { groupId }); return !error; } /** * 添加好友 * @param userId 目标用户id */ export async function addFriend(userId: string) { const [, user] = await fetch('addFriend', { userId }); return user; } /** * 删除好友 * @param userId 目标用户id */ export async function deleteFriend(userId: string) { const [err] = await fetch('deleteFriend', { userId }); return !err; } /** * 获取联系人历史消息 * @param linkmanId 联系人id * @param existCount 客户端已有消息条数 */ export async function getLinkmanHistoryMessages( linkmanId: string, existCount: number, ) { const [, messages] = await fetch('getLinkmanHistoryMessages', { linkmanId, existCount, }); return messages; } /** * 获取默认群组的历史消息 * @param existCount 客户端已有消息条数 */ export async function getDefaultGroupHistoryMessages(existCount: number) { const [, messages] = await fetch('getDefaultGroupHistoryMessages', { existCount, }); return messages; } /** * 搜索用户和群组 * @param keywords 关键字 */ export async function search(keywords: string) { const [, result] = await fetch('search', { keywords }); return result; } /** * 搜索表情包 * @param keywords 关键字 */ export async function searchExpression(keywords: string) { const [, result] = await fetch('searchExpression', { keywords }); return result; } /** * 发送消息 * @param to 目标 * @param type 消息类型 * @param content 消息内容 */ export async function sendMessage(to: string, type: string, content: string) { return fetch('sendMessage', { to, type, content }); } /** * 删除消息 * @param messageId 要删除的消息id */ export async function deleteMessage(messageId: string) { const [err] = await fetch('deleteMessage', { messageId }); return !err; } /** * 获取目标群组的在线用户列表 * @param groupId 目标群id */ export async function getGroupOnlineMembers(groupId: string) { const [, members] = await fetch('getGroupOnlineMembers', { groupId }); return members; } /** * 获取默认群组的在线用户列表 */ export async function getDefaultGroupOnlineMembers() { const [, members] = await fetch('getDefaultGroupOnlineMembers'); return members; } /** * 封禁用户 * @param username 目标用户名 */ export async function sealUser(username: string) { const [err] = await fetch('sealUser', { username }); return !err; } /** * 封禁ip * @param ip ip地址 */ export async function sealIp(ip: string) { const [err] = await fetch('sealIp', { ip }); return !err; } /** * 封禁用户所有在线ip * @param userId 用户id */ export async function sealUserOnlineIp(userId: string) { const [err] = await fetch('sealUserOnlineIp', { userId }); return !err; } /** * 获取封禁用户列表 */ export async function getSealList() { const [, sealList] = await fetch('getSealList'); return sealList; } /** * 重置指定用户的密码 * @param username 目标用户名 */ export async function resetUserPassword(username: string) { const [, res] = await fetch('resetUserPassword', { username }); return res; } /** * 更新指定用户的标签 * @param username 目标用户名 * @param tag 标签 */ export async function setUserTag(username: string, tag: string) { const [err] = await fetch('setUserTag', { username, tag }); return !err; } /** * 获取在线用户 ip * @param userId 用户id */ export async function getUserIps(userId: string) { const [, res] = await fetch('getUserIps', { userId }); return res; } export async function getUserOnlineStatus(userId: string) { const [, res] = await fetch('getUserOnlineStatus', { userId }); return res && res.isOnline; } export async function setNotificationToken(token: string) { const [, res] = await fetch( 'setNotificationToken', { token }, { toast: false }, ); return res && res.isOK; } ================================================ FILE: packages/app/src/socket.ts ================================================ import IO from 'socket.io-client'; import Toast from './components/Toast'; import action from './state/action'; import store from './state/store'; import { AddLinkmanAction, AddLinkmanActionType, AddLinkmanHistoryMessagesAction, AddLinkmanHistoryMessagesActionType, AddlinkmanMessageAction, AddlinkmanMessageActionType, ConnectAction, ConnectActionType, DeleteLinkmanMessageAction, DeleteLinkmanMessageActionType, Friend, Group, Message, RemoveLinkmanAction, RemoveLinkmanActionType, SetGuestAction, SetGuestActionType, State, Temporary, UpdateGroupPropertyAction, UpdateGroupPropertyActionType, UpdateUserPropertyAction, UpdateUserPropertyActionType, User, } from './types/redux'; import getFriendId from './utils/getFriendId'; import platform from './utils/platform'; import { getStorageValue } from './utils/storage'; const { dispatch } = store; const options = { transports: ['websocket'], }; const host = 'http://10.132.67.127:9200'; const socket = IO(host, options); function fetch( event: string, data: any = {}, { toast = true } = {}, ): Promise<[string | null, T | null]> { return new Promise((resolve) => { socket.emit(event, data, (res: any) => { if (typeof res === 'string') { if (toast) { Toast.danger(res); } resolve([res, null]); } else { resolve([null, res]); } }); }); } async function guest() { const [err, res] = await fetch('guest', {}); if (!err) { dispatch({ type: SetGuestActionType, linkmans: [res], } as SetGuestAction); } } socket.on('connect', async () => { dispatch({ type: ConnectActionType, value: true, } as ConnectAction); const token = await getStorageValue('token'); if (token) { const [err, res] = await fetch( 'loginByToken', { token, ...platform, }, { toast: false }, ); if (err) { guest(); } else { const user = res; action.setUser(user); const linkmanIds = [ ...user.groups.map((g: Group) => g._id), ...user.friends.map((f: Friend) => f._id), ]; const [err2, linkmans] = await fetch('getLinkmansLastMessagesV2', { linkmans: linkmanIds, }); if (!err2) { action.setLinkmansLastMessages(linkmans); } } } else { guest(); } }); socket.on('disconnect', () => { dispatch({ type: ConnectActionType, value: false, } as ConnectAction); }); socket.on('message', (message: Message) => { const state = store.getState() as State; const linkman = state.linkmans.find((x) => x._id === message.to); if (linkman) { dispatch({ type: AddlinkmanMessageActionType, linkmanId: message.to, message, } as AddlinkmanMessageAction); } else { const newLinkman: Temporary = { _id: getFriendId((state.user as User)._id, message.from._id), type: 'temporary', createTime: Date.now(), avatar: message.from.avatar, name: message.from.username, messages: [], unread: 1, }; dispatch({ type: AddLinkmanActionType, linkman: newLinkman, focus: false, } as AddLinkmanAction); fetch('getLinkmanHistoryMessages', { linkmanId: newLinkman._id, existCount: 0, }).then(([err, res]) => { if (!err) { dispatch({ type: AddLinkmanHistoryMessagesActionType, linkmanId: newLinkman._id, messages: res, } as AddLinkmanHistoryMessagesAction); } }); } }); socket.on( 'changeGroupName', ({ groupId, name }: { groupId: string; name: string }) => { dispatch({ type: UpdateGroupPropertyActionType, groupId, key: 'name', value: name, } as UpdateGroupPropertyAction); }, ); socket.on('deleteGroup', ({ groupId }: { groupId: string }) => { dispatch({ type: RemoveLinkmanActionType, linkmanId: groupId, } as RemoveLinkmanAction); }); socket.on('changeTag', (tag: string) => { dispatch({ type: UpdateUserPropertyActionType, key: 'tag', value: tag, } as UpdateUserPropertyAction); }); socket.on( 'deleteMessage', ({ linkmanId, messageId }: { linkmanId: string; messageId: string }) => { dispatch({ type: DeleteLinkmanMessageActionType, linkmanId, messageId, } as DeleteLinkmanMessageAction); }, ); socket.connect(); export default socket; ================================================ FILE: packages/app/src/state/action.ts ================================================ import getFriendId from '../utils/getFriendId'; import store from './store'; import { ConnectActionType, ConnectAction, Friend, SetUserActionType, SetUserAction, SetLinkmanMessagesAction, SetGuestActionType, SetGuestAction, LogoutActionType, UpdateUserPropertyActionType, UpdateUserPropertyAction, AddlinkmanMessageActionType, AddlinkmanMessageAction, AddLinkmanHistoryMessagesActionType, AddLinkmanHistoryMessagesAction, UpdateSelfMessageActionType, UpdateSelfMessageAction, SetFocusAction, AddLinkmanAction, RemoveLinkmanActionType, RemoveLinkmanAction, SetFriendAction, UpdateUIPropertyActionType, UpdateUIPropertyAction, Group, Message, Linkman, UpdateGroupPropertyActionType, UpdateGroupPropertyAction, UpdateFriendPropertyActionType, UpdateFriendPropertyAction, DeleteLinkmanMessageAction, DeleteLinkmanMessageActionType, } from '../types/redux'; const { dispatch } = store; function connect() { dispatch({ type: ConnectActionType, value: true, } as ConnectAction); } function disconnect() { dispatch({ type: ConnectActionType, value: false, } as ConnectAction); } function setUser(user: any) { user.groups.forEach((group: Group) => { Object.assign(group, { type: 'group', unread: 0, messages: [], members: [], }); }); user.friends.forEach((friend: Friend) => { Object.assign(friend, { type: 'friend', _id: getFriendId(friend.from, friend.to._id), messages: [], unread: 0, avatar: friend.to.avatar, name: friend.to.username, to: friend.to._id, }); }); const linkmans = [...user.groups, ...user.friends]; dispatch({ type: SetUserActionType, user: { ...user, groups: null, friends: null, }, linkmans, } as SetUserAction); } function setLinkmansLastMessages( linkmans: SetLinkmanMessagesAction['linkmans'], ) { dispatch({ type: 'SetLinkmanMessages', linkmans, } as SetLinkmanMessagesAction); } function setGuest(defaultGroup: Group) { dispatch({ type: SetGuestActionType, linkmans: [ Object.assign(defaultGroup, { type: 'group', unread: 0, members: [], }), ], } as SetGuestAction); } function logout() { dispatch({ type: LogoutActionType, }); } function setAvatar(avatar: string) { dispatch({ type: UpdateUserPropertyActionType, key: 'avatar', value: avatar, } as UpdateUserPropertyAction); } function updateUserProperty(key: string, value: any) { dispatch({ type: UpdateUserPropertyActionType, key, value, } as UpdateUserPropertyAction); } function addLinkmanMessage(linkmanId: string, message: Message) { dispatch({ type: AddlinkmanMessageActionType, linkmanId, message, } as AddlinkmanMessageAction); } function deleteLinkmanMessage(linkmanId: string, messageId: string) { dispatch({ type: DeleteLinkmanMessageActionType, linkmanId, messageId, } as DeleteLinkmanMessageAction); } function addLinkmanHistoryMessages(linkmanId: string, messages: Message[]) { dispatch({ type: AddLinkmanHistoryMessagesActionType, linkmanId, messages, } as AddLinkmanHistoryMessagesAction); } function updateSelfMessage( linkmanId: string, messageId: string, message: Message, ) { dispatch({ type: UpdateSelfMessageActionType, linkmanId, messageId, message, } as UpdateSelfMessageAction); } function setFocus(linkmanId: string) { dispatch({ type: 'SetFocus', linkmanId, } as SetFocusAction); } function setGroupMembers(groupId: string, members: Group['members']) { dispatch({ type: UpdateGroupPropertyActionType, groupId, key: 'members', value: members, } as UpdateGroupPropertyAction); } function setGroupAvatar(groupId: string, avatar: string) { dispatch({ type: UpdateGroupPropertyActionType, groupId, key: 'avatar', value: avatar, } as UpdateGroupPropertyAction); } function updateGroupProperty(groupId: string, key: string, value: any) { dispatch({ type: UpdateGroupPropertyActionType, groupId, key, value, } as UpdateGroupPropertyAction); } function updateFriendProperty(userId: string, key: string, value: any) { dispatch({ type: UpdateFriendPropertyActionType, userId, key, value, } as UpdateFriendPropertyAction); } function addLinkman(linkman: Linkman, focus = false) { if (linkman.type === 'group') { linkman.members = []; linkman.messages = []; linkman.unread = 0; } dispatch({ type: 'AddLinkman', linkman, focus, } as AddLinkmanAction); } function removeLinkman(linkmanId: string) { dispatch({ type: RemoveLinkmanActionType, linkmanId, } as RemoveLinkmanAction); } function setFriend(linkmanId: string, from: Friend['from'], to: Friend['to']) { dispatch({ type: 'SetFriend', linkmanId, from, to, } as SetFriendAction); } function loading(text: string) { dispatch({ type: UpdateUIPropertyActionType, key: 'loading', value: text, } as UpdateUIPropertyAction); } export default { setUser, setGuest, connect, disconnect, logout, setAvatar, updateUserProperty, setLinkmansLastMessages, setFocus, setGroupMembers, setGroupAvatar, addLinkman, removeLinkman, setFriend, updateGroupProperty, updateFriendProperty, addLinkmanMessage, addLinkmanHistoryMessages, updateSelfMessage, deleteLinkmanMessage, loading, }; ================================================ FILE: packages/app/src/state/reducer.ts ================================================ import produce from 'immer'; import deepmerge from 'deepmerge'; import { State, ActionTypes, ConnectActionType, LogoutActionType, SetUserActionType, SetGuestActionType, UpdateUserPropertyActionType, SetLinkmanMessagesActionType, SetFocusActionType, SetFriendActionType, Friend, AddLinkmanActionType, RemoveLinkmanActionType, AddlinkmanMessageActionType, AddLinkmanHistoryMessagesActionType, UpdateSelfMessageActionType, UpdateUIPropertyActionType, Group, UpdateGroupPropertyActionType, UpdateFriendPropertyActionType, DeleteLinkmanMessageActionType, User, Linkman, } from '../types/redux'; import convertMessage from '../utils/convertMessage'; export function mergeLinkmans( linkmans1: Linkman[], linkmans2: Linkman[], ): Linkman[] { const linkmansMap2 = linkmans2.reduce( (map: { [key: string]: Linkman }, linkman) => { map[linkman._id] = linkman; return map; }, {}, ); const unionListingsIdSet = new Set( linkmans1 .map((linkman) => linkman._id) .filter((linkmanId) => !!linkmansMap2[linkmanId]), ); const linkmans = [ ...linkmans1.filter((linkman) => unionListingsIdSet.has(linkman._id)), ...linkmans2.filter((linkman) => !unionListingsIdSet.has(linkman._id)), ]; return linkmans.map((linkman) => { if (unionListingsIdSet.has(linkman._id)) { return deepmerge(linkman as any, linkmansMap2[linkman._id] as any, { customMerge: (key) => { if (key === 'messages') { // The new linkman data at this time does not have messages // So keep the old messages return () => linkman.messages; } }, }); } return linkman; }); } const initialState = { linkmans: [], focus: '', connect: true, ui: { ready: false, loading: '', // 全局loading文本内容, 为空则不展示 primaryColor: '5,159,149', primaryTextColor: '255, 255, 255', }, }; const reducer = produce((state: State = initialState, action: ActionTypes) => { switch (action.type) { case ConnectActionType: { state.connect = action.value; return state; } case LogoutActionType: { return initialState; } case SetUserActionType: { const currentUserId = (state.user as User)?._id; if (!currentUserId || currentUserId !== action.user._id) { // No user or guest user or different user state.user = action.user; state.linkmans = action.linkmans; } else { // Same user. Deep merge to reserve history messages; // But these history messages must be overwritten in SetLinkmanMessagesAction // Otherwise, there may be errors when fetch history messages later state.user = action.user; state.linkmans = mergeLinkmans(state.linkmans, action.linkmans); } return state; } case SetGuestActionType: { action.linkmans.forEach((linkman) => { linkman.messages.forEach(convertMessage); }); state.linkmans = action.linkmans; return state; } case UpdateUserPropertyActionType: { // @ts-ignore state!.user[action.key] = action.value; return state; } case SetLinkmanMessagesActionType: { state.linkmans = state.linkmans.map((linkman) => ({ ...linkman, ...(action.linkmans[linkman._id] ? { messages: action.linkmans[linkman._id].messages.map(convertMessage), unread: action.linkmans[linkman._id].unread, } : {}), })) as Linkman[]; state.linkmans.sort((linkman1, linkman2) => { const lastMessageTime1 = linkman1.messages.length > 0 ? linkman1.messages[linkman1.messages.length - 1] .createTime : linkman1.createTime; const lastMessageTime2 = linkman2.messages.length > 0 ? linkman2.messages[linkman2.messages.length - 1] .createTime : linkman2.createTime; return new Date(lastMessageTime1) < new Date(lastMessageTime2) ? 1 : -1; }); if ( !state.focus || !state.linkmans.find((linkman) => linkman._id === state.focus) ) { state.focus = state.linkmans.length > 0 ? state.linkmans[0]._id : ''; } return state; } case UpdateGroupPropertyActionType: { const group = state.linkmans.find( (linkman) => linkman.type === 'group' && linkman._id === action.groupId, ) as Group; if (group) { // @ts-ignore group[action.key] = action.value; } return state; } case UpdateFriendPropertyActionType: { const friend = state.linkmans.find( (linkman) => linkman.type !== 'group' && linkman._id === action.userId, ) as Friend; if (friend) { // @ts-ignore friend[action.key] = action.value; } return state; } case SetFocusActionType: { const targetLinkman = state.linkmans.find( (linkman) => linkman._id === action.linkmanId, ); if (targetLinkman) { state.focus = action.linkmanId; targetLinkman.unread = 0; } return state; } case SetFriendActionType: { const friend = state.linkmans.find( (linkman) => linkman._id === action.linkmanId, ) as Friend; if (friend) { friend.type = 'friend'; friend.from = action.from; friend.to = action.to; friend.unread = 0; state.focus = action.linkmanId; } return state; } case AddLinkmanActionType: { state.linkmans.unshift(action.linkman); if (action.focus) { state.focus = action.linkman._id; } return state; } case RemoveLinkmanActionType: { const index = state.linkmans.findIndex( (linkman) => linkman._id === action.linkmanId, ); if (index !== -1) { state.linkmans.splice(index, 1); if (state.focus === action.linkmanId) { state.focus = state.linkmans.length > 0 ? state.linkmans[0]._id : ''; } } return state; } case AddlinkmanMessageActionType: { const targetLinkman = state.linkmans.find( (linkman) => linkman._id === action.linkmanId, ); if (targetLinkman) { if (state.focus !== targetLinkman._id) { targetLinkman.unread += 1; } targetLinkman.messages.push(convertMessage(action.message)); if (targetLinkman.messages.length > 500) { targetLinkman.messages.slice(250); } } return state; } case AddLinkmanHistoryMessagesActionType: { const targetLinkman = state.linkmans.find( (linkman) => linkman._id === action.linkmanId, ); if (targetLinkman) { targetLinkman.messages.unshift( ...action.messages.map(convertMessage), ); } return state; } case UpdateSelfMessageActionType: { const targetLinkman = state.linkmans.find( (linkman) => linkman._id === action.linkmanId, ); if (targetLinkman) { const targetMessage = targetLinkman.messages.find( (message) => message._id === action.messageId, ); if (targetMessage) { Object.assign( targetMessage, convertMessage(action.message), ); } } return state; } case DeleteLinkmanMessageActionType: { const targetLinkman = state.linkmans.find( (linkman) => linkman._id === action.linkmanId, ); if (targetLinkman) { const targetMessage = targetLinkman.messages.find( (message) => message._id === action.messageId, ); if (targetMessage) { targetMessage.deleted = true; convertMessage(targetMessage); } } return state; } case UpdateUIPropertyActionType: { state.ui[action.key] = action.value; return state; } default: { return state; } } }, initialState); export default reducer; ================================================ FILE: packages/app/src/state/store.ts ================================================ import { createStore } from 'redux'; import reducer from './reducer'; const store = createStore( // @ts-ignore reducer, // @ts-ignore window.__REDUX_DEVTOOLS_EXTENSION__ && window.__REDUX_DEVTOOLS_EXTENSION__(), ); export default store; ================================================ FILE: packages/app/src/types/global.d.ts ================================================ declare module '@react-native-toolkit/triangle'; declare module 'react-native-dialog'; declare module '*.png'; ================================================ FILE: packages/app/src/types/redux.ts ================================================ export const ConnectActionType = 'SetConnect'; export type ConnectAction = { type: typeof ConnectActionType; value: boolean; }; export const SetUserActionType = 'SetUser'; export type SetUserAction = { type: typeof SetUserActionType; user: User; linkmans: Linkman[]; }; export const SetLinkmanMessagesActionType = 'SetLinkmanMessages'; export type SetLinkmanMessagesAction = { type: typeof SetLinkmanMessagesActionType; linkmans: Record< string, { messages: Message[]; unread: number; } >; }; export const SetGuestActionType = 'SetGuest'; export type SetGuestAction = { type: typeof SetGuestActionType; linkmans: Linkman[]; }; export const LogoutActionType = 'Logout'; export type LogoutAction = { type: typeof LogoutActionType; }; export const UpdateUserPropertyActionType = 'UpdateUserProperty'; export type UpdateUserPropertyAction = { type: typeof UpdateUserPropertyActionType; key: keyof User; value: any; }; export const AddlinkmanMessageActionType = 'AddlinkmanMessage'; export type AddlinkmanMessageAction = { type: typeof AddlinkmanMessageActionType; linkmanId: string; message: Message; }; export const DeleteLinkmanMessageActionType = 'DeleteLinkmanMessage'; export type DeleteLinkmanMessageAction = { type: typeof DeleteLinkmanMessageActionType; linkmanId: string; messageId: string; }; export const AddLinkmanHistoryMessagesActionType = 'AddLinkmanHistoryMessages'; export type AddLinkmanHistoryMessagesAction = { type: typeof AddLinkmanHistoryMessagesActionType; linkmanId: string; messages: Message[]; }; export const UpdateSelfMessageActionType = 'UpdateSelfMessageActionType'; export type UpdateSelfMessageAction = { type: typeof UpdateSelfMessageActionType; linkmanId: string; messageId: string; message: Message; }; export const SetFocusActionType = 'SetFocus'; export type SetFocusAction = { type: typeof SetFocusActionType; linkmanId: string; }; export const UpdateGroupPropertyActionType = 'UpdateGroupProperty'; export type UpdateGroupPropertyAction = { type: typeof UpdateGroupPropertyActionType; groupId: string; key: keyof Group; value: any; }; export const UpdateFriendPropertyActionType = 'UpdateFriendProperty'; export type UpdateFriendPropertyAction = { type: typeof UpdateFriendPropertyActionType; userId: string; key: keyof Group; value: any; }; export const AddLinkmanActionType = 'AddLinkman'; export type AddLinkmanAction = { type: typeof AddLinkmanActionType; linkman: Linkman; focus: boolean; }; export const RemoveLinkmanActionType = 'RemoveLinkmanActionType'; export type RemoveLinkmanAction = { type: typeof RemoveLinkmanActionType; linkmanId: string; }; export const SetFriendActionType = 'SetFriend'; export type SetFriendAction = { type: typeof SetFriendActionType; linkmanId: string; from: Friend['from']; to: Friend['to']; }; export const UpdateUIPropertyActionType = 'UpdateUIPropertyActionType'; export type UpdateUIPropertyAction = { type: typeof UpdateUIPropertyActionType; key: keyof State['ui']; value: any; }; export type ActionTypes = | ConnectAction | SetUserAction | SetLinkmanMessagesAction | UpdateGroupPropertyAction | SetGuestAction | SetFocusAction | SetFriendAction | AddLinkmanAction | RemoveLinkmanAction | AddlinkmanMessageAction | AddLinkmanHistoryMessagesAction | DeleteLinkmanMessageAction | UpdateSelfMessageAction | UpdateUserPropertyAction | UpdateUIPropertyAction | UpdateFriendPropertyAction | LogoutAction; export type Message = { _id: string; type: string; content: string; createTime: number; percent?: number; loading?: boolean; from: { _id: string; username: string; avatar: string; tag: string; originUsername?: string; }; to: string; deleted?: boolean; }; export type Group = { _id: string; type: 'group'; name: string; avatar: string; messages: Message[]; unread: number; members: { _id: string; user: { _id: string; username: string; avatar: string; }; os: string; browser: string; environment: string; }[]; creator: string; createTime: number; }; export type Friend = { _id: string; type: 'friend'; name: string; avatar: string; from: string; to: { _id: string; avatar: string; username: string; }; messages: Message[]; unread: number; createTime: number; isOnline?: boolean; }; export type Temporary = { _id: string; type: 'temporary'; name: string; avatar: string; messages: Message[]; unread: number; createTime: number; isOnline?: boolean; }; export type Linkman = Group | Friend | Temporary; export type User = { _id: string; username: string; avatar: string; tag: string; isAdmin: boolean; notificationTokens: string[]; createTime: number; }; export type State = { user?: User; linkmans: Linkman[]; focus: string; connect: boolean; ui: { loading: string; primaryColor: string; primaryTextColor: string; }; }; ================================================ FILE: packages/app/src/types/socket.ts ================================================ export type Socket = { on: (event: string, callback: (...params: any) => void) => void; }; ================================================ FILE: packages/app/src/utils/constant.ts ================================================ export const referer = 'https://fiora.suisuijiang.com/'; ================================================ FILE: packages/app/src/utils/convertMessage.ts ================================================ // function convertRobot10Message(message) { // if (message.from._id === '5adad39555703565e7903f79') { // try { // const parseMessage = JSON.parse(message.content); // message.from.tag = parseMessage.source; // message.from.avatar = parseMessage.avatar; // message.from.username = parseMessage.username; // message.type = parseMessage.type; // message.content = parseMessage.content; // } catch (err) { // console.warn('解析robot10消息失败', err); // } // } // } import { Message } from '../types/redux'; const WuZeiNiangImage = require('../assets/images/wuzeiniang.gif'); function convertSystemMessage(message: Message) { if (message.type === 'system') { message.from._id = 'system'; message.from.originUsername = message.from.username; message.from.username = '乌贼娘殿下'; message.from.avatar = WuZeiNiangImage; message.from.tag = 'system'; let content = null; try { content = JSON.parse(message.content); } catch { content = { command: 'parse-error', }; } switch (content?.command) { case 'roll': { message.content = `掷出了${content.value}点 (上限${content.top}点)`; break; } case 'rps': { message.content = `使出了 ${content.value}`; break; } default: { message.content = '不支持的指令'; } } } else if (message.deleted) { message.type = 'system'; message.from._id = 'system'; message.from.originUsername = message.from.username; message.from.username = '乌贼娘殿下'; message.from.avatar = WuZeiNiangImage; message.from.tag = 'system'; message.content = `撤回了消息`; } return message; } /** * 处理文本消息的html转义字符 * @param {Object} message 消息 */ function convertMessageHtml(message: Message) { if (message.type === 'text') { message.content = message.content .replace(/</g, '<') .replace(/>/g, '>'); } return message; } export default function convertMessage(message: Message) { convertSystemMessage(message); convertMessageHtml(message); return message; } ================================================ FILE: packages/app/src/utils/expressions.ts ================================================ export default { default: [ '呵呵', '哈哈', '吐舌', '啊', '酷', '怒', '开心', '汗', '泪', '黑线', '鄙视', '不高兴', '真棒', '钱', '疑问', '阴险', '吐', '咦', '委屈', '花心', '呼', '笑眼', '冷', '太开心', '滑稽', '勉强', '狂汗', '乖', '睡觉', '惊哭', '升起', '惊讶', '喷', '爱心', '心碎', '玫瑰', '礼物', '星星月亮', '太阳', '音乐', '灯泡', '蛋糕', '彩虹', '钱币', '咖啡', 'haha', '胜利', '大拇指', '弱', 'ok', ], }; ================================================ FILE: packages/app/src/utils/fetch.ts ================================================ import Toast from '../components/Toast'; import socket from '../socket'; export default function fetch( event: string, data: any = {}, { toast = true } = {}, ): Promise<[string | null, T | null]> { return new Promise((resolve) => { socket.emit(event, data, (res: any) => { if (typeof res === 'string') { if (toast) { Toast.danger(res); } resolve([res, null]); } else { resolve([null, res]); } }); }); } ================================================ FILE: packages/app/src/utils/getFriendId.ts ================================================ export default function getFriendId(userId1: string, userId2: string) { if (userId1 < userId2) { return userId1 + userId2; } return userId2 + userId1; } ================================================ FILE: packages/app/src/utils/getRandomColor.ts ================================================ import randomColor from 'randomcolor'; type ColorMode = 'dark' | 'bright' | 'light' | 'random'; /** * 获取随机颜色, 刷新页面不变 * @param seed when passed will cause randomColor to return the same color each time */ export function getRandomColor(seed: string, luminosity: ColorMode = 'dark') { return randomColor({ luminosity, seed, }); } type Cache = { [key: string]: string; }; const cache: Cache = {}; /** * 获取随机颜色, 刷新页面后重新随机 * @param seed 随机种子 * @param luminosity 亮度 */ export function getPerRandomColor( seed: string, luminosity: ColorMode = 'dark', ) { if (cache[seed]) { return cache[seed]; } cache[seed] = randomColor({ luminosity }); return cache[seed]; } ================================================ FILE: packages/app/src/utils/linkman.ts ================================================ import { Friend, Group, Linkman } from '../types/redux'; export function formatLinkmanName(linkman: Linkman) { if (linkman!.type === 'group' && (linkman as Group).members.length > 0) { return `${(linkman as Group).name} (${ (linkman as Group).members.length })`; } if ( linkman!.type !== 'group' && (linkman as Friend).isOnline !== undefined ) { return `${(linkman as Friend).name} (${ (linkman as Friend).isOnline ? '在线' : '离线' })`; } return linkman.name; } ================================================ FILE: packages/app/src/utils/platform.ts ================================================ import { Platform } from 'react-native'; import Constants from 'expo-constants'; // eslint-disable-next-line import/extensions import packageInfo from '../../app.json'; const os = Platform.OS === 'ios' ? 'iOS' : 'Android'; export const isiOS = Platform.OS === 'ios'; export const isAndroid = Platform.OS === 'android'; export default { os, browser: 'App', environment: `App ${ process.env.NODE_ENV === 'development' ? '开发版' : packageInfo.expo.version } on ${os} ${ isiOS ? Constants.platform?.ios?.systemVersion : Constants.systemVersion } ${isiOS ? Constants.platform?.ios?.model : ''}`, }; ================================================ FILE: packages/app/src/utils/storage.ts ================================================ import AsyncStorage from '@react-native-async-storage/async-storage'; export async function getStorageValue(key: string) { return AsyncStorage.getItem(key); } export async function setStorageValue(key: string, value: string) { return AsyncStorage.setItem(key, value); } export async function removeStorageValue(key: string) { return AsyncStorage.removeItem(key); } ================================================ FILE: packages/app/src/utils/time.ts ================================================ export default { isToday(time1: Date, time2: Date) { return ( time1.getFullYear() === time2.getFullYear() && time1.getMonth() === time2.getMonth() && time1.getDate() === time2.getDate() ); }, isYesterday(time1: Date, time2: Date) { const prevDate = new Date(time1); prevDate.setDate(time1.getDate() - 1); return ( prevDate.getFullYear() === time2.getFullYear() && prevDate.getMonth() === time2.getMonth() && prevDate.getDate() === time2.getDate() ); }, isSameYear(time1: Date, time2: Date) { return time1.getFullYear() === time2.getFullYear(); }, getHourMinute(time: Date) { const hours = time.getHours(); const minutes = time.getMinutes(); return `${hours < 10 ? `0${hours}` : hours}:${ minutes < 10 ? `0${minutes}` : minutes }`; }, getMonthDate(time: Date) { return `${time.getMonth() + 1}/${time.getDate()}`; }, getYearMonthDate(time: Date) { return `${time.getFullYear()}/${time.getMonth() + 1}/${time.getDate()}`; }, }; ================================================ FILE: packages/app/src/utils/uploadFile.ts ================================================ import fetch from './fetch'; /** * 上传文件 * @param blob 文件blob数据 * @param fileName 文件名 */ export default async function uploadFile( blob: Blob | string, fileName: string, isBase64 = false, ): Promise { const [uploadErr, result] = await fetch('uploadFile', { file: blob, fileName, isBase64, }); if (uploadErr) { throw Error(`上传图片失败::${uploadErr}`); } return result.url; } export function getOSSFileUrl(url: string | number = '', process = '') { if (typeof url === 'number') { return url; } const [rawUrl = '', extraPrams = ''] = url.split('?'); if (/^\/\/cdn\.suisuijiang\.com/.test(rawUrl)) { return `https:${rawUrl}?x-oss-process=${process}${ extraPrams ? `&${extraPrams}` : '' }`; } if (url.startsWith('//')) { return `https:${url}`; } if (url.startsWith('/')) { return `https://fiora.suisuijiang.com${url}`; } return `${url}`; } ================================================ FILE: packages/app/tests/state/reducer.test.ts ================================================ import { mergeLinkmans } from '../../src/state/reducer'; import { Linkman } from '../../src/types/redux'; describe('mergeLinkmans', () => { it('should return linkmans which is newly and reserve history messages', () => { const linkmans1 = [ { _id: 'l1', name: 'l1', messages: [], }, { _id: 'l2', name: 'l2', messages: [ { _id: 'm1', }, { _id: 'm2', }, ], }, ]; const linkmans2 = [ { _id: 'l2', name: 'l2', messages: [ { _id: 'm1', }, { _id: 'm2', }, ], }, { _id: 'l3', name: 'l3', messages: [], }, ]; const linkmans = mergeLinkmans( linkmans1 as any, linkmans2 as any, ) as Linkman[]; expect(linkmans).toHaveLength(2); expect(linkmans[0]._id).toBe('l2'); expect(linkmans[1]._id).toBe('l3'); expect(linkmans[0].messages).toHaveLength(2); }); }); ================================================ FILE: packages/app/tsconfig.json ================================================ { "extends": "../../tsconfig", } ================================================ FILE: packages/assets/package.json ================================================ { "name": "@fiora/assets", "version": "1.0.0", "license": "MIT", "private": true } ================================================ FILE: packages/bin/index.ts ================================================ import chalk from 'chalk'; import path from 'path'; import fs from 'fs'; const script = process.argv[2]; if (!script) { console.log(chalk.green('没有任何事发生~')); process.exit(0); } const file = path.resolve(__dirname, `scripts/${script}.ts`); if (!fs.existsSync(file)) { console.log(chalk.red(`[${script}] 脚本不存在`)); } // @ts-ignore import(file).then((module) => { module.default(); }); ================================================ FILE: packages/bin/package.json ================================================ { "name": "@fiora/bin", "version": "1.0.0", "license": "MIT", "private": true, "scripts": { "script": "ts-node --transpile-only index.ts" }, "dependencies": { "@fiora/config": "^1.0.0", "@fiora/database": "^1.0.0", "bcryptjs": "^2.4.3", "chalk": "^4.1.1", "detect-port": "^1.3.0", "inquirer": "^8.1.2" }, "devDependencies": { "@types/bcryptjs": "^2.4.2", "@types/detect-port": "^1.3.1", "@types/inquirer": "^7.3.3" } } ================================================ FILE: packages/bin/scripts/deleteMessages.ts ================================================ import path from 'path'; import fs from 'fs'; import inquirer from 'inquirer'; import { promisify } from 'util'; import chalk from 'chalk'; import initMongoDB from '@fiora/database/mongoose/initMongoDB'; import Message from '@fiora/database/mongoose/models/message'; import History from '@fiora/database/mongoose/models/history'; export async function deleteMessages() { const shouldDeleteAllMessages = await inquirer.prompt({ type: 'confirm', name: 'result', message: 'Confirm to delete all messages?', }); if (!shouldDeleteAllMessages.result) { return; } await initMongoDB(); const deleteResult = await Message.deleteMany({}); console.log('Delete result:', deleteResult); const deleteHistoryResult = await History.deleteMany({}); console.log('Delete history result:', deleteHistoryResult); const shouldDeleteAllFiles = await inquirer.prompt({ type: 'confirm', name: 'result', message: 'Confirm to delete all message files(Except OSS files)?', }); if (!shouldDeleteAllFiles.result) { return; } const files = await promisify(fs.readdir)( path.resolve(__dirname, '../../server/public/'), ); const iamgesAndFiles = files.filter( (filename) => filename.startsWith('ImageMessage_') || filename.startsWith('FileMessage_'), ); const unlinkAsync = promisify(fs.unlink); await Promise.all( iamgesAndFiles.map((file) => unlinkAsync(path.resolve(__dirname, '../../server/public/', file)), ), ); console.log('Delete files:', chalk.green(iamgesAndFiles.length.toString())); console.log(chalk.red(iamgesAndFiles.join('\n'))); console.log(chalk.green('Successfully deleted all messages')); } async function run() { await deleteMessages(); process.exit(0); } export default run; ================================================ FILE: packages/bin/scripts/deleteTodayRegisteredUsers.ts ================================================ /** * Delete users created today and their related data */ import chalk from 'chalk'; import inquirer from 'inquirer'; import initMongoDB from '@fiora/database/mongoose/initMongoDB'; import User from '@fiora/database/mongoose/models/user'; import { deleteUser } from './deleteUser'; export async function deleteTodayRegisteredUsers() { await initMongoDB(); const now = new Date(); const time = new Date( `${now.getFullYear()}-${now.getMonth() + 1}-${now.getDate()} 00:00:00`, ); const users = await User.find({ createTime: { $gte: time, }, }); console.log( `There are ${chalk.green( users.length.toString(), )} newly registered users today`, ); if (users.length === 0) { return; } const shouldDeleteUsers = await inquirer.prompt({ type: 'confirm', name: 'result', message: 'Confirm to delete these users?', }); if (!shouldDeleteUsers.result) { return; } await Promise.all( users.map((user) => deleteUser(user._id.toString(), false)), ); console.log( chalk.green('Successfully deleted today’s newly registered users'), ); } async function run() { await deleteTodayRegisteredUsers(); process.exit(0); } export default run; ================================================ FILE: packages/bin/scripts/deleteUser.ts ================================================ /* eslint-disable no-console */ import chalk from 'chalk'; import inquirer from 'inquirer'; import User from '@fiora/database/mongoose/models/user'; import Message from '@fiora/database/mongoose/models/message'; import Group, { GroupDocument } from '@fiora/database/mongoose/models/group'; import Friend from '@fiora/database/mongoose/models/friend'; import History from '@fiora/database/mongoose/models/history'; import initMongoDB, { mongoose } from '@fiora/database/mongoose/initMongoDB'; export async function deleteUser(userIdOrName: string, confirm = true) { if (!userIdOrName) { console.log(chalk.red('Wrong command, [userIdOrName] is missing.')); return; } await initMongoDB(); try { const user = await User.findOne( mongoose.isValidObjectId(userIdOrName) ? { _id: userIdOrName } : { username: userIdOrName }, ); if (user) { console.log( 'Found user:', chalk.blue(user._id.toString()), chalk.green(user.username), ); if (confirm) { const shouldDeleteUser = await inquirer.prompt({ type: 'confirm', name: 'result', message: 'Confirm to delete user?', }); if (!shouldDeleteUser.result) { return; } } const messages = await Message.find({ from: user._id }); const deleteHistoryResult = await History.deleteMany({ message: { $in: messages.map((message) => message.id), }, }); console.log('Delete history result:', deleteHistoryResult); console.log(chalk.yellow('Delete messages created by this user')); const deleteMessageResult = await Message.deleteMany({ from: user._id, }); console.log('Delete result:', deleteMessageResult); console.log( chalk.yellow('Leave the group that the user has joined'), ); const groups = await Group.find({ members: user._id, }); // eslint-disable-next-line no-inner-declarations async function leaveGroup(group: GroupDocument) { if (!user) { return; } console.log('Leave', group.name); const index = group.members.indexOf(user?._id); group.members.splice(index, 1); if (group.creator?.toString() === user?._id.toString()) { // @ts-ignore group.creator = null; } await group.save(); } await Promise.all(groups.map(leaveGroup)); console.log( chalk.yellow( 'Delete the friend relationship related to this user', ), ); const deleteFriendResult1 = await Friend.deleteMany({ from: user._id, }); const deleteFriendResult2 = await Friend.deleteMany({ to: user._id, }); console.log( 'Delete result:', deleteFriendResult1, deleteFriendResult2, ); console.log(chalk.yellow('Delete this user')); const deleteUserResult = await User.deleteMany({ _id: user._id, }); console.log('Delete result:', deleteUserResult); console.log(chalk.green('Successfully deleted user')); } else { console.log(chalk.red(`User [${userIdOrName}] does not exist`)); } } catch (err) { console.log(chalk.red('Failed to delete user!', err.message)); } } async function run() { const userIdOrName = process.argv[3]; await deleteUser(userIdOrName); process.exit(0); } export default run; ================================================ FILE: packages/bin/scripts/doctor.ts ================================================ import chalk from 'chalk'; import cp from 'child_process'; import fs from 'fs'; import path from 'path'; import detect from 'detect-port'; import server from '@fiora/config/server'; import initRedis from '@fiora/database/redis/initRedis'; import initMongoDB from '@fiora/database/mongoose/initMongoDB'; export async function doctor() { console.log(chalk.yellow('===== Run Fiora Doctor =====')); const nodeVersion = cp.execSync('node --version').toString(); console.log( chalk.green(`node ${nodeVersion.slice(0, nodeVersion.length - 1)}`), ); await initMongoDB(); console.log(chalk.green('MongoDB is OK')); await (async () => new Promise((resolve) => { const redis = initRedis(); redis.on('connect', resolve); }))(); console.log(chalk.green('Redis is OK')); const avaliablePort = await detect(server.port); if (avaliablePort === server.port) { console.log(chalk.green(`Port [${server.port}] is OK`)); } else { console.log(chalk.red(`Port [${server.port}] was occupied`)); } const indexFilePath = path.resolve( __dirname, '../../server/public/index.html', ); const indexFile = fs.readFileSync(indexFilePath); if (!indexFile) { console.log(chalk.red('Homepage not exists')); } else if (indexFile.toString().includes('默认首页')) { console.log( chalk.red( 'Homepage is default. Please build web client by [yarn build:web]', ), ); } else { console.log(chalk.green(`Homepage is OK`)); } } async function run() { await doctor(); process.exit(0); } export default run; ================================================ FILE: packages/bin/scripts/fixUsersAvatar.ts ================================================ import chalk from 'chalk'; import inquirer from 'inquirer'; import User from '@fiora/database/mongoose/models/user'; import initMongoDB from '@fiora/database/mongoose/initMongoDB'; export async function fixUsersAvatar( searchValue: string, replaceValue: string, ) { searchValue = searchValue || 'fioraavatar'; replaceValue = replaceValue || 'fiora/avatar'; await initMongoDB(); const users = await User.find({ avatar: { $regex: 'fioraavatar' } }); if (users.length) { console.log(chalk.red('Oh No!'), "Some user's avatar is wrong"); users.forEach((user) => { console.log(user._id, user.username, user.avatar); }); const shouldFix = await inquirer.prompt({ type: 'confirm', name: 'result', message: 'Confirm to fix?', }); if (shouldFix.result) { await Promise.all( users.map((user) => { user.avatar = user.avatar.replace( searchValue, replaceValue, ); return user.save(); }), ); console.log(chalk.green('Congratulations! Fixed!')); } } else { console.log(chalk.green('OK!'), "All user's avatar is corrent"); } } async function run() { const searchValue = process.argv[3]; const replaceValue = process.argv[4]; await fixUsersAvatar(searchValue, replaceValue); process.exit(0); } export default run; ================================================ FILE: packages/bin/scripts/getUserId.ts ================================================ import chalk from 'chalk'; import User from '@fiora/database/mongoose/models/user'; import initMongoDB from '@fiora/database/mongoose/initMongoDB'; export async function getUserId(username: string) { if (!username) { console.log(chalk.red('Wrong command, [username] is missing.')); return; } await initMongoDB(); const user = await User.findOne({ username }); if (!user) { console.log(chalk.red(`User [${username}] does not exist`)); } else { console.log( `The userId of [${username}] is:`, chalk.green(user._id.toString()), ); } } async function run() { const username = process.argv[3]; await getUserId(username); process.exit(0); } export default run; ================================================ FILE: packages/bin/scripts/register.ts ================================================ /** * Register */ import bcrypt from 'bcryptjs'; import chalk from 'chalk'; import initMongoDB from '@fiora/database/mongoose/initMongoDB'; import User, { UserDocument } from '../../database/mongoose/models/user'; import Group from '../../database/mongoose/models/group'; import { SALT_ROUNDS } from '../../utils/const'; import getRandomAvatar from '../../utils/getRandomAvatar'; export async function register(username: string, password: string) { if (!username) { console.log(chalk.red('Wrong command, [username] is missing.')); return; } if (!password) { console.log(chalk.red('Wrong command, [password] is missing.')); return; } await initMongoDB(); const user = await User.findOne({ username }); if (user) { console.log(chalk.red('The username already exists')); return; } const defaultGroup = await Group.findOne({ isDefault: true }); if (!defaultGroup) { console.log(chalk.red('Default group does not exist')); return; } const salt = await bcrypt.genSalt(SALT_ROUNDS); const hash = await bcrypt.hash(password, salt); let newUser = null; try { newUser = await User.create({ username, salt, password: hash, avatar: getRandomAvatar(), } as UserDocument); } catch (createError) { if (createError.name === 'ValidationError') { console.log( chalk.red( 'Username contains unsupported characters or the length exceeds the limit', ), ); return; } console.log(chalk.red('Error:'), createError); return; } if (!defaultGroup.creator) { defaultGroup.creator = newUser._id; } if (newUser) { defaultGroup.members.push(newUser._id); } await defaultGroup.save(); console.log(chalk.green(`Successfully created user [${username}]`)); } async function run() { const username = process.argv[3]; const password = process.argv[4]; await register(username, password); process.exit(0); } export default run; ================================================ FILE: packages/bin/scripts/updateDefaultGroupName.ts ================================================ import chalk from 'chalk'; import initMongoDB from '@fiora/database/mongoose/initMongoDB'; import Group from '@fiora/database/mongoose/models/group'; export async function updateDefaultGroupName(newName: string) { if (!newName) { console.log(chalk.red('Wrong command, [newName] is missing.')); return; } await initMongoDB(); const group = await Group.findOne({ isDefault: true }); if (!group) { console.log(chalk.red('Default group does not exist')); } else { group.name = newName; try { await group.save(); console.log(chalk.green('Update default group name success!')); } catch (err) { console.log( chalk.red('Update default group name fail!'), err.message, ); } } } async function run() { const newName = process.argv[3]; await updateDefaultGroupName(newName); process.exit(0); } export default run; ================================================ FILE: packages/bin/tsconfig.json ================================================ { "extends": "../../tsconfig", } ================================================ FILE: packages/config/client.ts ================================================ import { MB } from '../utils/const'; export default { server: process.env.Server || (process.env.NODE_ENV === 'development' ? '//localhost:9200' : '/'), maxImageSize: process.env.MaxImageSize ? parseInt(process.env.MaxImageSize, 10) : MB * 5, maxBackgroundImageSize: process.env.MaxBackgroundImageSize ? parseInt(process.env.MaxBackgroundImageSize, 10) : MB * 5, maxAvatarSize: process.env.MaxAvatarSize ? parseInt(process.env.MaxAvatarSize, 10) : MB * 1.5, maxFileSize: process.env.MaxFileSize ? parseInt(process.env.MaxFileSize, 10) : MB * 10, // client default system setting defaultTheme: process.env.DefaultTheme || 'cool', sound: process.env.Sound || 'default', tagColorMode: process.env.TagColorMode || 'fixedColor', /** * 前端监控: https://yueying.effirst.com/index * 值为监控应用id, 为空则不启用监控 */ frontendMonitorAppId: process.env.FrontendMonitorAppId || '', // 禁止用户撤回消息, 不包括管理员, 管理员始终能撤回任何消息 // 默认是禁止的 disableDeleteMessage: process.env.DisableDeleteMessage ? process.env.DisableDeleteMessage === 'true' : false, }; ================================================ FILE: packages/config/package.json ================================================ { "name": "@fiora/config", "version": "1.0.0", "license": "MIT", "private": true, "dependencies": { "ip": "^1.1.5" }, "devDependencies": { "@types/ip": "^1.1.0" } } ================================================ FILE: packages/config/server.ts ================================================ import ip from 'ip'; const { env } = process; export default { /** 服务端host, 默认为本机ip地址(可能会是局域网地址) */ host: env.Host || ip.address(), // service port port: env.Port ? parseInt(env.Port, 10) : 9200, // mongodb address database: env.Database || 'mongodb://localhost:27017/fiora', redis: { host: env.RedisHost || 'localhost', port: env.RedisPort ? parseInt(env.RedisPort, 10) : 6379, }, // jwt encryption secret jwtSecret: env.JwtSecret || 'jwtSecret', // Maximize the number of groups maxGroupsCount: env.MaxGroupCount ? parseInt(env.MaxGroupCount, 10) : 3, allowOrigin: env.AllowOrigin ? env.AllowOrigin.split(',') : null, // token expires time tokenExpiresTime: env.TokenExpiresTime ? parseInt(env.TokenExpiresTime, 10) : 1000 * 60 * 60 * 24 * 30, // administrator user id administrator: env.Administrator ? env.Administrator.split(',') : [], /** 禁用注册功能 */ disableRegister: env.DisableRegister ? env.DisableRegister === 'true' : false, /** disable user create new group */ disableCreateGroup: env.DisableCreateGroup ? env.DisableCreateGroup === 'true' : false, /** Aliyun OSS */ aliyunOSS: { enable: env.ALIYUN_OSS ? env.ALIYUN_OSS === 'true' : false, accessKeyId: env.ACCESS_KEY_ID || '', accessKeySecret: env.ACCESS_KEY_SECRET || '', roleArn: env.ROLE_ARN || '', region: env.REGION || '', bucket: env.BUCKET || '', endpoint: env.ENDPOINT || '', }, }; ================================================ FILE: packages/database/mongoose/index.ts ================================================ export * from 'mongoose'; ================================================ FILE: packages/database/mongoose/initMongoDB.ts ================================================ /** * 连接 MongoDB */ import mongoose from 'mongoose'; import config from '@fiora/config/server'; import logger from '@fiora/utils/logger'; mongoose.Promise = Promise; mongoose.set('useCreateIndex', true); export default function initMongoDB() { return new Promise((resolve) => { mongoose.connect( config.database, { useNewUrlParser: true, useUnifiedTopology: true }, async (err) => { if (err) { logger.error('[mongoDB]', err.message); process.exit(0); } else { resolve(null); } }, ); }); } export { mongoose }; ================================================ FILE: packages/database/mongoose/models/friend.ts ================================================ import { Schema, model, Document } from 'mongoose'; const FriendSchema = new Schema({ createTime: { type: Date, default: Date.now }, from: { type: Schema.Types.ObjectId, ref: 'User', index: true, }, to: { type: Schema.Types.ObjectId, ref: 'User', }, }); export interface FriendDocument extends Document { /** 源用户id */ from: string; /** 目标用户id */ to: string; /** 创建时间 */ createTime: Date; } /** * Friend Model * 好友信息 * 好友关系是单向的 */ const Friend = model('Friend', FriendSchema); export default Friend; ================================================ FILE: packages/database/mongoose/models/group.ts ================================================ import { Schema, model, Document } from 'mongoose'; import { NAME_REGEXP } from '@fiora/utils/const'; const GroupSchema = new Schema({ createTime: { type: Date, default: Date.now }, name: { type: String, trim: true, unique: true, match: NAME_REGEXP, index: true, }, avatar: String, announcement: { type: String, default: '', }, creator: { type: Schema.Types.ObjectId, ref: 'User', }, isDefault: { type: Boolean, default: false, }, members: [ { type: Schema.Types.ObjectId, ref: 'User', }, ], }); export interface GroupDocument extends Document { /** 群组名 */ name: string; /** 头像 */ avatar: string; /** 公告 */ announcement: string; /** 创建者 */ creator: string; /** 是否为默认群组 */ isDefault: boolean; /** 成员 */ members: string[]; /** 创建时间 */ createTime: Date; } /** * Group Model * 群组信息 */ const Group = model('Group', GroupSchema); export default Group; ================================================ FILE: packages/database/mongoose/models/history.ts ================================================ import { Schema, model, Document } from 'mongoose'; const HistoryScheme = new Schema({ user: { type: String, required: true, }, linkman: { type: String, required: true, }, message: { type: String, required: true, }, }); export interface HistoryDocument extends Document { /** user id */ user: string; /** linkman id */ linkman: string; /** last readed message id */ message: string; } const History = model('History', HistoryScheme); export default History; export async function createOrUpdateHistory( userId: string, linkmanId: string, messageId: string, ) { const history = await History.findOne({ user: userId, linkman: linkmanId }); if (history) { history.message = messageId; await history.save(); } else { await History.create({ user: userId, linkman: linkmanId, message: messageId, }); } return {}; } ================================================ FILE: packages/database/mongoose/models/message.ts ================================================ import { Schema, model, Document } from 'mongoose'; import Group from './group'; import User from './user'; const MessageSchema = new Schema({ createTime: { type: Date, default: Date.now, index: true }, from: { type: Schema.Types.ObjectId, ref: 'User', }, to: { type: String, index: true, }, type: { type: String, enum: ['text', 'image', 'file', 'code', 'inviteV2', 'system'], default: 'text', }, content: { type: String, default: '', }, deleted: { type: Boolean, default: false, }, }); export interface MessageDocument extends Document { /** 发送人 */ from: string; /** 接受者, 发送给群时为群_id, 发送给个人时为俩人的_id按大小序拼接后值 */ to: string; /** 类型, text: 文本消息, image: 图片消息, code: 代码消息, invite: 邀请加群消息, system: 系统消息 */ type: string; /** 内容, 某些消息类型会存成JSON */ content: string; /** 创建时间 */ createTime: Date; /** Has it been deleted */ deleted: boolean; } /** * Message Model * 聊天消息 */ const Message = model('Message', MessageSchema); export default Message; interface SendMessageData { to: string; type: string; content: string; } export async function handleInviteV2Message(message: SendMessageData) { if (message.type === 'inviteV2') { const inviteInfo = JSON.parse(message.content); if (inviteInfo.inviter && inviteInfo.group) { const [user, group] = await Promise.all([ User.findOne({ _id: inviteInfo.inviter }), Group.findOne({ _id: inviteInfo.group }), ]); if (user && group) { message.content = JSON.stringify({ inviter: inviteInfo.inviter, inviterName: user?.username, group: inviteInfo.group, groupName: group.name, }); } } } } export async function handleInviteV2Messages(messages: SendMessageData[]) { return Promise.all( messages.map(async (message) => { if (message.type === 'inviteV2') { await handleInviteV2Message(message); } }), ); } ================================================ FILE: packages/database/mongoose/models/notification.ts ================================================ import { Schema, model, Document } from 'mongoose'; const NotificationSchema = new Schema({ createTime: { type: Date, default: Date.now }, user: { type: Schema.Types.ObjectId, ref: 'User', }, token: { type: String, unique: true, }, }); export interface NotificationDocument extends Document { user: any; token: string; } const Notification = model( 'Notification', NotificationSchema, ); export default Notification; ================================================ FILE: packages/database/mongoose/models/socket.ts ================================================ import { Schema, model, Document } from 'mongoose'; const SocketSchema = new Schema({ createTime: { type: Date, default: Date.now }, id: { type: String, unique: true, index: true, }, user: { type: Schema.Types.ObjectId, ref: 'User', }, ip: String, os: { type: String, default: '', }, browser: { type: String, default: '', }, environment: { type: String, default: '', }, }); export interface SocketDocument extends Document { /** socket连接id */ id: string; /** 关联用户id */ user: any; /** ip地址 */ ip: string; /** 系统 */ os: string; /** 浏览器 */ browser: string; /** 详细环境信息 */ environment: string; /** 创建时间 */ createTime: Date; } /** * Socket Model * 客户端socket连接信息 */ const Socket = model('Socket', SocketSchema); export default Socket; ================================================ FILE: packages/database/mongoose/models/user.ts ================================================ import { Schema, model, Document } from 'mongoose'; import { NAME_REGEXP } from '@fiora/utils/const'; const UserSchema = new Schema({ createTime: { type: Date, default: Date.now }, lastLoginTime: { type: Date, default: Date.now }, username: { type: String, trim: true, unique: true, match: NAME_REGEXP, index: true, }, salt: String, password: String, avatar: String, tag: { type: String, default: '', trim: true, match: NAME_REGEXP, }, expressions: [ { type: String, }, ], lastLoginIp: String, }); export interface UserDocument extends Document { /** 用户名 */ username: string; /** 密码加密盐 */ salt: string; /** 加密的密码 */ password: string; /** 头像 */ avatar: string; /** 用户标签 */ tag: string; /** 表情收藏 */ expressions: string[]; /** 创建时间 */ createTime: Date; /** 最后登录时间 */ lastLoginTime: Date; /** 最后登录IP */ lastLoginIp: string; } /** * User Model * 用户信息 */ const User = model('User', UserSchema); export default User; ================================================ FILE: packages/database/package.json ================================================ { "name": "@fiora/database", "version": "1.0.0", "license": "MIT", "private": true, "dependencies": { "@fiora/config": "^1.0.0", "@fiora/utils": "^1.0.0", "mongoose": "^5.13.3", "redis": "^3.1.2" }, "devDependencies": { "@types/mongoose": "^5.11.97", "@types/redis": "^2.8.31" } } ================================================ FILE: packages/database/redis/initRedis.ts ================================================ import redis from 'redis'; import { promisify } from 'util'; import config from '@fiora/config/server'; import logger from '@fiora/utils/logger'; export default function initRedis() { const client = redis.createClient({ ...config.redis, }); client.on('error', (err) => { logger.error('[redis]', err.message); process.exit(0); }); return client; } const client = initRedis(); export const get = promisify(client.get).bind(client); export const expire = promisify(client.expire).bind(client); export async function set(key: string, value: string, expireTime = Infinity) { await promisify(client.set).bind(client)(key, value); if (expireTime !== Infinity) { await expire(key, expireTime); } } export const keys = promisify(client.keys).bind(client); export async function has(key: string) { const v = await get(key); return v !== null; } export function getNewUserKey(userId: string) { return `NewUser-${userId}`; } export function getNewRegisteredUserIpKey(ip: string) { // The value of v1 is ip // The value of v2 is count number return `NewRegisteredUserIpV2-${ip}`; } export function getSealIpKey(ip: string) { return `SealIp-${ip}`; } export async function getAllSealIp() { const allSealIpKeys = await keys('SealIp-*'); return allSealIpKeys.map((key) => key.replace('SealIp-', '')); } export function getSealUserKey(user: string) { return `SealUser-${user}`; } export async function getAllSealUser() { const allSealUserKeys = await keys('SealUser-*'); return allSealUserKeys.map((key) => key.split('-')[1]); } const Minute = 60; const Hour = Minute * 60; const Day = Hour * 24; export const Redis = { get, set, has, expire, keys, Minute, Hour, Day, }; export const DisableSendMessageKey = 'DisableSendMessage'; export const DisableNewUserSendMessageKey = 'DisableNewUserSendMessageKey'; ================================================ FILE: packages/database/tsconfig.json ================================================ { "extends": "../../tsconfig", } ================================================ FILE: packages/docs/.gitignore ================================================ # Dependencies /node_modules # Production /build # Generated files .docusaurus .cache-loader # Misc .DS_Store .env.local .env.development.local .env.test.local .env.production.local npm-debug.log* yarn-debug.log* yarn-error.log* ================================================ FILE: packages/docs/babel.config.js ================================================ module.exports = { presets: [require.resolve('@docusaurus/core/lib/babel/preset')], }; ================================================ FILE: packages/docs/docs/API.md ================================================ --- id: api --- ================================================ FILE: packages/docs/docs/App.md ================================================ --- id: app --- ================================================ FILE: packages/docs/docs/CHANGELOG.md ================================================ --- id: changelog --- ================================================ FILE: packages/docs/docs/Config.md ================================================ --- id: config --- ================================================ FILE: packages/docs/docs/FAQ.md ================================================ --- id: faq --- ================================================ FILE: packages/docs/docs/Getting-Start.md ================================================ --- id: getting-start --- ================================================ FILE: packages/docs/docs/INSTALL.md ================================================ --- id: install --- ================================================ FILE: packages/docs/docs/Script.md ================================================ --- id: script --- ================================================ FILE: packages/docs/docusaurus.config.js ================================================ module.exports = { title: 'fiora docs', tagline: 'An interesting open source chat application', url: 'https://fiora.suisuijiang.com', baseUrl: '/fiora/', onBrokenLinks: 'throw', onBrokenMarkdownLinks: 'warn', favicon: 'img/favicon.png', organizationName: 'yinxin630', // Usually your GitHub org/user name. projectName: 'fiora', // Usually your repo name. themeConfig: { navbar: { title: 'fiora', logo: { alt: 'Logo', src: 'img/favicon.png', }, items: [ { to: 'docs/getting-start', activeBasePath: 'docs', label: 'Docs', position: 'right', }, { href: 'https://github.com/yinxin630/fiora', label: 'GitHub', position: 'right', }, { type: 'localeDropdown', position: 'right', }, ], }, footer: { style: 'dark', links: [ { title: 'Docs', items: [ { label: 'Overview', to: '/', }, { label: 'Getting Start', to: 'docs/getting-start', }, { label: 'Change Log', to: 'docs/changelog', }, ], }, { title: 'Community', items: [ { label: 'Feedback', href: 'https://fiora.suisuijiang.com/invite/group/5adacdcfa109ce59da3e83d3', }, { label: 'Issues', href: 'https://github.com/yinxin630/fiora/issues', }, ], }, { title: 'More', items: [ { label: 'Author', href: 'https://suisuijiang.com', }, { label: 'GitHub', href: 'https://github.com/yinxin630/fiora', }, ], }, ], copyright: `Copyright © 2015 - ${new Date().getFullYear()} developed by 碎碎酱`, }, colorMode: { disableSwitch: true, }, }, presets: [ [ '@docusaurus/preset-classic', { docs: { sidebarPath: require.resolve('./sidebars.js'), // Please change this to your repo. editUrl: 'https://github.com/yinxin630/fiora/edit/master/docs/', }, theme: { customCss: require.resolve('./src/css/custom.css'), }, }, ], ], i18n: { defaultLocale: 'en', locales: ['en', 'zh-Hans'], localeConfigs: { en: { label: 'English', }, 'zh-Hans': { label: '简体中文', }, }, }, }; ================================================ FILE: packages/docs/i18n/en/code.json ================================================ { "Title": { "message": "fiora" }, "TagLine": { "message": "An interesting open source chat application" }, "Keywords": { "message": "fiora, fiora docs, node.js, chatroom" }, "Description": { "message": "This site is for fiora doc. Fiora is an interesting open source chat application" }, "Richness": { "message": "Fiora contains backend, frontend, Android and iOS apps" }, "Cross Platform": { "message": "Fiora is developed with node.js. Supports Windows / Linux / macOS systems" }, "Open Source": { "message": "Fiora follows the MIT open source license" }, "Join Chat Title": { "message": "Join Chat" }, "Join Chat Content": { "message": "Register an account to join the chat. Join or create new group. Chat privately with funny strangers and add them as friends. Your account and messages will be stored forever" }, "Rich Feature Title": { "message": "Rich Feature" }, "Rich Feature Content": { "message": "You can send text, emoticons, pictures, codes and files to others. You can also withdraw the sent message. In addition, you can modify your name and avatar. The most exciting is you can choose or customize different themes" }, "Deploy By Yourself Title": { "message": "Deploy By Yourself" }, "Deploy By Yourself Content": { "message": "Fiora is an open source project. You can clone the source code and deploy to your own server. It supports windows / Linux and macOS systems. But recommended that you deploy on a linux server" }, "Interested": { "message": "Are you very interested?" }, "Getting Start": { "message": "Getting Start" }, "Try It Now": { "message": "Try It Now" }, "View Docs": { "message": "View Docs" }, "DocsUrl": { "message": "/fiora/docs/getting-start/" } } ================================================ FILE: packages/docs/i18n/en/docusaurus-plugin-content-docs/current/API.md ================================================ --- id: api title: API sidebar_label: API --- ## 如何调用接口 fiora 后端基于 socket.io, 首先需要与后端建立连接 ```js import IO from 'socket.io-client'; const socket = new IO(serverAddrress, options); ``` 接口调用格式为 ```js socket.emit(event, data, callback); ``` 参数说明 - event {string} 接口名/事件名 - data {object} 接口入参 - callback {string|object => void} 接口回调, 返回 string 表示接口失败, string 内容为失败原因, 反正 object 表示接口成功, 里面包含返回数据 ## 返回数据结构定义 ### User ```js { _id, // {string} id username, // {string} 用户名 avatar, // {string} 头像 groups, // {[Group]} 群组列表 friends, // {[User]} 好友列表 token, // {string} 免密登录token isAdmin, // {boolean} 是否为管理员 } ``` ### Group ```js { _id, // {string} id name, // {string} 群组名 avatar, // {string} 头像 creator, // {User ID} 群主id isDefault, // {boolean} 是否为默认群 members, // {[User]} 成员列表 messages, // {[Message]} 消息列表 } ``` ### Message ```js { _id, // {string} id from, // {User} 发送者 to, // {string} 群聊: 群id, 私聊: 两人id拼接, 按字符串比较, 小的在前 type, // {string} 消息类型 ['text', 'image', 'code', 'invite'] content, // {string} 消息内容 } ``` ## 接口列表 ### 用户注册 ```js socket.emit( 'register', { username, // {string} 用户名 password, // {string} 密码 os, // {string} 操作系统 browser, // {string} 浏览器 environment, // {string} 环境信息 }, (user) => {}, // {User} 用户数据 ); ``` ### 用户登录 ```js socket.emit( 'login', { username, // {string} 用户名 password, // {string} 密码 os, // {string} 操作系统 browser, // {string} 浏览器 environment, // {string} 环境信息 }, (user) => {}, // {User} 用户数据 ); ``` ### 免密登录 / 断线重连 ```js socket.emit( 'loginByToken', { token, // {string} 免密登录token os, // {string} 操作系统 browser, // {string} 浏览器 environment, // {string} 环境信息 }, (user) => {}, // {User} 用户数据 ); ``` ### 游客登录 游客仅能获取到默认群组 ```js socket.emit( 'guest', { os, // {string} 操作系统 browser, // {string} 浏览器 environment, // {string} 环境信息 }, (defaultGroup) => {}, // {Group} 默认群组数据 ); ``` ### 修改头像 ```js socket.emit( 'changeAvatar', { avatar, // {string} 新头像url }, () => {}, // {Object} 返回空对象 ); ``` ### 添加好友 ```js socket.emit( 'addFriend', { userId, // {User ID} 目标的id }, (friend) => {}, // {User} 好友信息 ); ``` ### 删除好友 ```js socket.emit( 'deleteFriend', { userId, // {User ID} 目标的id }, () => {}, // {Object} 返回空对象 ); ``` ### 修改密码 ```js socket.emit( 'changePassword', { oldPassword, // {string} 旧密码 newPassword, // {string} 新密码 }, () => {}, // {Object} 返回空对象 ); ``` ### 修改用户名 ```js socket.emit( 'changeUsername', { username, // {string} 新用户名 }, () => {}, // {Object} 返回空对象 ); ``` ### 重置指定用户密码 仅管理员可调用 ```js socket.emit( 'resetUserPassword', { username, // {string} 新用户名 }, (data) => { // {Object} 返回数据 data.newPassword, // {string} 新密码 }, ); ``` ### 发送消息 通过 to 字段判断是发送给群, 还是发送给个人 发送群的话, to 就是群 id 发送个人的话, to 就是两个人的 id 拼接, 按字符串比较结果, 小的在前大的在后 ```js socket.emit( 'sendMessage', { to, // {string} 目标群组, 或者俩用户id拼接结果 type, // {string} 消息类型 content, // {string} 消息内容 }, (message) => {}, // {Message} 新消息 ); ``` ### 获取联系人最后消息 ```js socket.emit( 'getLinkmansLastMessages', { linkmans, // {[string]} 联系人id列表, 与to同规则 }, (messages) => {}, // {object} 所有联系人的最后消息, key: 联系人id, value: [Message] 消息列表 ); ``` ### 获取联系人历史消息 ```js socket.emit( 'getLinkmanHistoryMessages', { linkmanId, // {string} 联系人id existCount, // {number} 已有消息数量 }, (messages) => {}, // {[Message]} 消息列表 ); ``` ### 获取默认群组的历史消息 不需要登录态 ```js socket.emit( 'getDefaultGroupHistoryMessages', { existCount, // {number} 已有消息数量 }, (messages) => {}, // {[Message]} 消息列表 ); ``` ### 创建群组 ```js socket.emit( 'createGroup', { name, // {string} 群组名 }, (group) => {}, // {Group} 新创建的群组 ); ``` ### 加入群组 ```js socket.emit( 'joinGroup', { groupId, // {Group ID} 目标群id }, (group) => {}, // {Group} 新创建的群组 ); ``` ### 退出群组 ```js socket.emit( 'leaveGroup', { groupId, // {Group ID} 目标群id }, () => {}, // {object} 返回空数据 ); ``` ### 获取群组在线用户列表 ```js socket.emit( 'getGroupOnlineMembers', { groupId, // {Group ID} 目标群id }, (users) => {}, // {[User]} 在线用户列表 ); ``` ### 获取默认群组在线用户列表 ```js socket.emit( 'getDefaultGroupOnlineMembers', {}, (users) => {}, // {[User]} 在线用户列表 ); ``` ### 修改群头像 ```js socket.emit( 'changeGroupAvatar', { groupId, // {Group ID} 目标群id avatar, // {string} 新头像url }, () => {}, // {object} 返回空数据 ); ``` ### 获取七牛前端文件上传 token ```js socket.emit( 'uploadToken', { }, (data) => { // 服务端支持七牛 data.token, // 上传token data.urlPrefix, // 文件上传后的路径前缀 // 服务端不支持七牛 data.useUploadFile, // 不支持上传七牛, 需要客户端调用 uploadFile 上传文件到服务端 }, ); ``` ### 搜索用户/群组 ```js socket.emit( 'search', { keywords, // {string} 搜索关键字 }, (data) => { data.users, // {[User]} 命中的用户 data.groups, // {[Group]} 命中的群组 }, ); ``` ### 搜索表情包 ```js socket.emit( 'searchExpression', { keywords, // {string} 搜索关键字 }, (imageUrls) => {}, // {[string]} 图片列表 ); ``` ### 获取百度语言合成 token ```js socket.emit( 'getBaiduToken', { }, (data) => { data.token, // {string} token }, ); ``` ### 封禁用户 ```js socket.emit( 'sealUser', { username, // {string} 要封禁的用户名 }, () => {}, // {object} 返回空数据 ); ``` ### 获取封禁用户列表 ```js socket.emit( 'getSealList', {}, (users) => {}, // {[string]} 被封禁的用户名列表 ); ``` ### 上传文件到服务端 ```js socket.emit( 'uploadFile', { fileName, // {string} 文件名 file, // {blob} 文件内容, blob格式 }, (data) => { data.url, // 文件url }, ); ``` ================================================ FILE: packages/docs/i18n/en/docusaurus-plugin-content-docs/current/App.md ================================================ --- id: app title: Fiora App sidebar_label: Fiora App --- Fiora app is developed with [expo](https://expo.io/) and [react-native](https://reactnative.dev/). Support Android and iOS systems ## Download App ### Android Click link or scan qrcode to download APK [https://cdn.suisuijiang.com/fiora.apk](https://cdn.suisuijiang.com/fiora.apk) ![](/img/android-download-qrcode.png) ### iOS The iOS app is being submitted to the app store for review. You can now install unreviewed apps through testflight. Please contact 碎碎酱 or send an email to . Please attach your apple ID ## Hot to run 1. Install expo `yarn global add expo-cli` 2. Install dependencies `yarn install` 3. Start compilation `expo start` 4. According to the console prompt, run the app in the simulator or real device For more information, please see [https://docs.expo.io/](https://docs.expo.io/) ## Build Standalone App Please refer to ================================================ FILE: packages/docs/i18n/en/docusaurus-plugin-content-docs/current/CHANGELOG.md ================================================ --- id: changelog title: Change Log sidebar_label: Change Log --- ## 2021-6-24 - Support user names and user tags with Japanese characters ## 2021-5-11 - Use Aliyun OSS to replace Qiniu CDN ## 2021-3-24 - Fix the problem that the search function allows regular expression matching ## 2021-3-14 - Support the server to calculate the number of unread messages ## 2021-3-2 - When identifying the url in the message, support host as localhost or ip ## 2021-3-1 - No longer limit the number of groups created by the administrator ## 2021-2-28 - Multiple users use the same notification token ## 2021-2-27 - Modify app notification content - Messages sent by yourself no longer push notification to yourself - The progress bar is displayed when the webpack build production environment ## 2021-2-25 - Support push notification to fiora app ## 2021-2-21 - **Important** Fix the wrong logic of judging whether it is an administrator on the server side. Treat everyone as an administrator ## 2021-2-17 - Support sharing groups externally ## 2021-1-26 - File message size calculation error ## 2021-1-22 - A single ip can register up to 3 accounts within 24 hours ## 2020-12-17 - Support search expressions by input content. It is disabled default and you can enable it in setting - Only limit send message frequency ## 2020-12-08 - **Breaking!!!** Refactor to use redis cache instead of memory variable cache. So you should run redis first before start fiora ## 2020-11-15 - Refactor to use webpack plugin to generate service worker script - Refacotr or add server scripts ## 2020-11-14 - Adapt to ios full screen devices ## 2020-11-12 - Support multiple administrators - Add getUserId and deleteUser scripts ## 2020-11-08 - Support to withdraw self's message ## 2020-11-07 - Support send file directly - Support display linkman realtime info. About user online status and group online members count - Refactor webpack build config - Fix the issue of right click on image viewer to copy image will close it ## 2020-11-04 - **Breaking!!!** Modify the config files. It no longer supports modifying config items through command line params - Remove pm2 ecosystem config and deploy shell script ## 2020-11-03 - Rename some npm scripts name ================================================ FILE: packages/docs/i18n/en/docusaurus-plugin-content-docs/current/Config.md ================================================ --- id: config title: Config sidebar_label: Config --- Server configuration `config/server.ts` Client configuration `config/client.ts` Compared to directly modifying the configuration file, it is recommended to use environment variables to modify the configuration Create a `.env` file in the fiora root directory and enter `key=value` key-value pair (one per line) to modify the configuration. For example, modify the port number `Port=8888` ## Server Config **Modifying the server configuration requires restarting the application** | Key | Type | Default | Description | | ------------------ | ------- | -------------------------------------- | -------------------------------------------------------------------------------------------------------- | | Host | string | your ip | backend server host | | Port | number | 9200 | backend server port | | Database | string | mongodb://localhost:27017/fiora | mongodbb address | | RedisHost | string | localhost | redis host | | RedisPort | number | 6379 | redis port | | JwtSecret | string | jwtSecret (Modify it to ensure safety) | jwt token encryption secret | | MaxGroupCount | number | 3 | Maximum number of groups created per user | | AllowOrigin | string | null | The list of allowed client origins. If null, all origins are allowed. Multiple values separated by comma | | tokenExpiresTime | number | 2592000000 (30days) | login token expires time | | Administrator | string | '' | Administrator userId list. Multiple values separated by comma | | DisableRegister | boolean | false | Disable register | | DisableCreateGroup | boolean | false | Disable create group | | ALIYUN_OSS | boolean | false | enable to use aliyun OSS | | ACCESS_KEY_ID | string | '' | aliyun OSS access key id. reference: https://help.aliyun.com/document_detail/48699.html | | ACCESS_KEY_SECRET | string | '' | aliyun OSS access key secret. reference like ACCESS_KEY_ID | | ROLE_ARN | string | '' | aliyun OSS RoleARN. reference: https://help.aliyun.com/document_detail/28649.html | | REGION | string | '' | aliyun OSS region. example: `oss-cn-zhangjiakou` | | BUCKET | string | '' | aliyun OSS bucket name | | ENDPOINT | string | '' | aliyun OSS domain. example: `cdn.suisuijiang.com` | ## Client Config **Modifying the client configuration requires rebuilding the client** | Key | Type | Default | Description | | ---------------------- | ------- | --------------- | ------------------------------------------------------------ | | Server | string | / | Server address of the client connection | | MaxImageSize | number | 3145728 (3MB) | The maximum image size that the client can upload | | MaxBackgroundImageSize | number | 5242880 (5MB) | The maximum background image size that the client can upload | | MaxAvatarSize | number | 1572864 (1.5MB) | The maximum avatar image size that the client can upload | | MaxFileSize | number | 10485760 (10MB) | The maximum file size that the client can upload | | DefaultTheme | string | cool | default theme | | Sound | string | default | default notification sound | | TagColorMode | string | fixedColor | default tag color mode | | FrontendMonitorAppId | string | fixedColor | appId of monitor | | DisableDeleteMessage | boolean | false | disable user delete messages | ================================================ FILE: packages/docs/i18n/en/docusaurus-plugin-content-docs/current/FAQ.md ================================================ --- id: faq title: FAQ sidebar_label: FAQ --- ## How to set up an administrator 1. Get user id. reference [getUserId](/docs/script#getuserid) 2. Set `Administrator` in config to be administrator userId. 3. Restart the server ## How to modify the default group name reference [updateDefaultGroupname](/docs/script#updatedefaultgroupname) ## How to custom domain name Recommend to use nginx reverse proxy Example config, **Please modify the configuration of the comment item** ``` server { listen 80; # Change to your domain name server_name fiora.suisuijiang.com; location / { proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header Host $http_host; proxy_set_header Upgrade $http_upgrade; proxy_set_header X-NginX-Proxy true; proxy_set_header Connection "upgrade"; proxy_http_version 1.1; proxy_pass http://localhost:9200; } } ``` HTTPS + HTTP 2.0 config ``` server { listen 80; # Change to your domain name server_name fiora.suisuijiang.com; return 301 https://fiora.suisuijiang.com$request_uri; } server { listen 443 ssl http2; # Change to your domain name server_name fiora.suisuijiang.com; ssl on; # Modify to your ssl certificate location ssl_certificate ./ssl/fiora.suisuijiang.com.crt; ssl_certificate_key ./ssl/fiora.suisuijiang.com.key; ssl_session_timeout 5m; ssl_protocols TLSv1 TLSv1.1 TLSv1.2; ssl_ciphers ECDHE-RSA-AES128-GCM-SHA256:HIGH:!aNULL:!MD5:!RC4:!DHE; ssl_prefer_server_ciphers on; location / { proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header Host $http_host; proxy_set_header Upgrade $http_upgrade; proxy_set_header X-NginX-Proxy true; proxy_set_header Connection "upgrade"; proxy_http_version 1.1; proxy_pass http://localhost:9200; } } ``` ## How to Disable register, manual account assignment Set `DisableRegister` in config to be true. Restart the server to take effect Use scripts to manually register new users. Reference [register](/docs/script#register) ## How to delete user Reference [deleteUser](/docs/script#deleteuser) ## The client throw an error "调用失败,处于萌新阶段" In order to prevent newly registered users from sending messages randomly, users whose registration time is less than 24 hours can only send 5 messages per minute. ## An error is throwed when executing the command. "Couldn't find a package.json file in xxx" First cd to the fiora root directory, and then execute the corresponding command ## Why the modified configuration does not take effect 1. First confirm whether the configuration modification is correct -If you modify the configuration file directly, please make sure that the modified part of the syntax and format is correct -If you modify the configuration through the .env file, please make sure the format is correct 2. After modifying the configuration -If you modify the server configuration, you need to restart the server -If you modify the client configuration, you need to rebuild the client ## How to rebuild the web client `yarn build:web` ================================================ FILE: packages/docs/i18n/en/docusaurus-plugin-content-docs/current/Getting-Start.md ================================================ --- id: getting-start title: Getting Start sidebar_label: Getting Start --- import useBaseUrl from '@docusaurus/useBaseUrl'; fiora is an interesting chat application. It is developed based on node.js, mongodb, react and socket.io technologies The project started at [2015-11-04](https://github.com/yinxin630/chatroom-with-sails/commit/0a032372727550b8b4087f24ac299de03b677b9f) Online address: [https://fiora.suisuijiang.com/](https://fiora.suisuijiang.com/) Android / iOS app: [https://github.com/yinxin630/fiora-app](https://github.com/yinxin630/fiora-app) ## Functions 1. Register an account and log in, it can save your data for a long time 2. Join an existing group or create your own group to communicate with everyone 3. Chat privately with anyone and add them as friends 4. Multiple message types, including text / emoticons / pictures / codes / files / commands, you can also search for emoticons 5. Push notification when you receive a new message, you can customize the notification ringtone, and it can also read the message out 6. Choose the theme you like, and you can set it as any wallpaper and theme color you like 7. Set up an administrator to manage users ## Screenshot PC screenshot Mobile screenshot ## Directory |-- [.githubb] // github actions |-- [.vscode] // vscode workspace config |-- [bin] // server scripts |-- [build] // webpack config |-- [client] // web client |-- [config] // application configs |-- [dist] // client buid output directory |-- [docs] // document |-- [public] // server static resources |-- [server] // server |-- [test] // unit test |-- [types] // typescript types |-- [utils] // util functions |-- .babelrc // babel config |-- .eslintignore // eslint ignore list |-- .eslintrc // eslint config |-- .gitignore // git ignore |-- .nodemonrc // nodemon config |-- .prettierrc // prettier config |-- Dockerfile // docker file |-- LICENSE // fiora license |-- docker-compose.yaml // docker compose config |-- jest.*.sj // jest config |-- package.json // npm |-- tsconfig.json // typescript config |-- yarn.lock // yarn ... ## Contribution If you want to add functionality or fix bugs, please follow the process below: 1. Fork this repository and clone the fork post to the local 2. Installation dependencies `yarn install` 3. Modify the code and confirm it is bug free 4. Submit code, if eslint has reported error, please repair it and submit it again. 5. Create a pull request ================================================ FILE: packages/docs/i18n/en/docusaurus-plugin-content-docs/current/INSTALL.md ================================================ --- id: install title: Install sidebar_label: Install --- ## Environmental Preparation To run Fiora, you need Node.js(recommend v14 LTS version), MongoDB and redis - Install Node.js - Official website - It is recommended to use nvm to install Node.js - Install nvm - Install Node.js via nvm - Install MongoDB - Official website - Install redis - Official website Recommended to running on Linux or MacOS systems ## How to run 1. Clone the project `git clone https://github.com/yinxin630/fiora.git -b master` 2. Ensure you have install [yarn](https://www.npmjs.com/package/yarn) before, if not please run `npm install -g yarn` 3. Install project dependencies `yarn install` 4. Build client `yarn build:web` 5. Config JwtSecret `echo "JwtSecret=" > .env2`. Change `` to a secret text 6. Start the server `yarn start` 7. Open `http://[ip]:[port]`(such as `http://127.0.0.1:9200`) in browser ### Run in the background Using `yarn start` to run the server will stop running after disconnecting the ssh connection, it is recommended to use pm2 to run ```bash # install pm2 npm install -g pm2 # use pm2 to run fiora pm2 start yarn --name fiora -- start # view pm2 apps status pm2 ls # view pm2 fiora logging pm2 logs fiora ``` ### Run With Develop Mode 1. Start the server `yarn dev:server` 2. Start the client `yarn dev:web` 3. Open `http://localhost:8080` in browser ### Running on the docker First install docker #### Run directly from the DockerHub image ```bash # Pull mongo docker pull mongo # Pull redis docker pull redis # Pull fiora docker pull suisuijiang/fiora # Create a virtual network docker network create fiora-network # Run mongodB docker run --name fioradb -p 27017:27017 --network fiora-network mongo # Run redis docker run --name fioraredis -p 6379:6379 --network fiora-network redis # Run fiora docker run --name fiora -p 9200:9200 --network fiora-network -e Database=mongodb://fioradb:27017/fiora -e RedisHost=fioraredis suisuijiang/fiora ``` #### Local build image and run 1. Clone the project to the local `git clone https://github.com/yinxin630/fiora.git -b master` 2. Build the image `docker-compose build --no-cache --force-rm` 3. Run it `docker-compose up` ================================================ FILE: packages/docs/i18n/en/docusaurus-plugin-content-docs/current/Script.md ================================================ --- id: script title: Script sidebar_label: Script --- Fiora has a built-in command line tool to manage the server. Execute `fiora` to view the tool **Note!** Most of these scripts will directly modify the database. It is recommended (but not necessary) to backup the database in advance and stop the server before executing ## deleteMessages `fiora deleteMessages` Delete all historical message records, if the message pictures and files are stored on the server, they can also be deleted together ## deleteTodayRegisteredUsers `fiora deleteTodayRegisteredUsers` Delete all newly registered users on the day (based on server time) ## deleteUser `fiora deleteUser [userId]` Delete the specified user, delete its historical messages, exit the group that it has joined, and delete all its friends ## doctor `fiora doctor` Check the server configuration and status, which can be used to locate the cause of the server startup failure ## fixUsersAvatar `fiora fixUsersAvatar` Fix user error avatar path, please modify the script judgment logic according to your actual situation ## getUserId `fiora getUserId [username]` Get the userId of the specified user name ## register `fiora register [username] [password]` Register new users, when registration is prohibited, the administrator can register new users through it ## updateDefaultGroupName `fiora updateDefaultGroupName [newName]` Update default group name ================================================ FILE: packages/docs/i18n/en/docusaurus-theme-classic/footer.json ================================================ { "link.title.Docs": { "message": "Docs" }, "link.item.label.Overview": { "message": "Overview" }, "link.title.Community": { "message": "Community" }, "link.item.label.Feedback": { "message": "Join Chat" }, "link.item.label.Issues": { "message": "Submit Issue" }, "link.title.More": { "message": "More" }, "link.item.label.Author": { "message": "About Author" }, "link.item.label.GitHub": { "message": "GitHub" } } ================================================ FILE: packages/docs/i18n/en/docusaurus-theme-classic/navbar.json ================================================ { "item.label.Docs": { "message": "Docs" } } ================================================ FILE: packages/docs/i18n/zh-Hans/code.json ================================================ { "Title": { "message": "fiora" }, "TagLine": { "message": "一个有趣的开源聊天应用" }, "Keywords": { "message": "fiora, fiora 文档, node.js, 聊天室" }, "Description": { "message": "这是 fiora 文档网站, fiora 是一个有趣的开源聊天室应用" }, "Richness": { "message": "fiora 包括后端、前端、安卓和 iOS App" }, "Cross Platform": { "message": "fiora 基于 node.js 开发, 支持 Windows / Linux / macOS 等操作系统" }, "Open Source": { "message": "fiora 遵循 MIT 开源许可" }, "Join Chat Title": { "message": "加入聊天" }, "Join Chat Content": { "message": "注册一个账号加入聊天, 加入或者新的群组, 和有趣的陌生人私聊并加为好友, 你的账号和消息会永久保留" }, "Rich Feature Title": { "message": "丰富的功能" }, "Rich Feature Content": { "message": "你可以发送文本、表情、图片、代码和文件给其他人, 你还可以撤回已发送的消息, 另外你还可以修改用户名和头像, 最令人兴奋的是你可以选择或者自定义不同的主题" }, "Deploy By Yourself Title": { "message": "自己部署" }, "Deploy By Yourself Content": { "message": "fiora 是一个开源项目, 你可以克隆源码并部署到自己的服务器, 支持 windows / Linux and macOS 操作系统, 但是推荐您部署到 Linux 服务器上" }, "Interested": { "message": "你是否非常感兴趣?" }, "Getting Start": { "message": "查看文档" }, "Try It Now": { "message": "去体验看看" }, "View Docs": { "message": "查看文档" }, "DocsUrl": { "message": "/fiora/zh-Hans/docs/getting-start/" } } ================================================ FILE: packages/docs/i18n/zh-Hans/docusaurus-plugin-content-docs/current/API.md ================================================ --- id: api title: 接口 sidebar_label: 接口 --- ## 如何调用接口 fiora 后端基于 socket.io, 首先需要与后端建立连接 ```js import IO from 'socket.io-client'; const socket = new IO(serverAddrress, options); ``` 接口调用格式为 ```js socket.emit(event, data, callback); ``` 参数说明 - event {string} 接口名/事件名 - data {object} 接口入参 - callback {string|object => void} 接口回调, 返回 string 表示接口失败, string 内容为失败原因, 反正 object 表示接口成功, 里面包含返回数据 ## 返回数据结构定义 ### User ```js { _id, // {string} id username, // {string} 用户名 avatar, // {string} 头像 groups, // {[Group]} 群组列表 friends, // {[User]} 好友列表 token, // {string} 免密登录token isAdmin, // {boolean} 是否为管理员 } ``` ### Group ```js { _id, // {string} id name, // {string} 群组名 avatar, // {string} 头像 creator, // {User ID} 群主id isDefault, // {boolean} 是否为默认群 members, // {[User]} 成员列表 messages, // {[Message]} 消息列表 } ``` ### Message ```js { _id, // {string} id from, // {User} 发送者 to, // {string} 群聊: 群id, 私聊: 两人id拼接, 按字符串比较, 小的在前 type, // {string} 消息类型 ['text', 'image', 'code', 'invite'] content, // {string} 消息内容 } ``` ## 接口列表 ### 用户注册 ```js socket.emit( 'register', { username, // {string} 用户名 password, // {string} 密码 os, // {string} 操作系统 browser, // {string} 浏览器 environment, // {string} 环境信息 }, (user) => {}, // {User} 用户数据 ); ``` ### 用户登录 ```js socket.emit( 'login', { username, // {string} 用户名 password, // {string} 密码 os, // {string} 操作系统 browser, // {string} 浏览器 environment, // {string} 环境信息 }, (user) => {}, // {User} 用户数据 ); ``` ### 免密登录 / 断线重连 ```js socket.emit( 'loginByToken', { token, // {string} 免密登录token os, // {string} 操作系统 browser, // {string} 浏览器 environment, // {string} 环境信息 }, (user) => {}, // {User} 用户数据 ); ``` ### 游客登录 游客仅能获取到默认群组 ```js socket.emit( 'guest', { os, // {string} 操作系统 browser, // {string} 浏览器 environment, // {string} 环境信息 }, (defaultGroup) => {}, // {Group} 默认群组数据 ); ``` ### 修改头像 ```js socket.emit( 'changeAvatar', { avatar, // {string} 新头像url }, () => {}, // {Object} 返回空对象 ); ``` ### 添加好友 ```js socket.emit( 'addFriend', { userId, // {User ID} 目标的id }, (friend) => {}, // {User} 好友信息 ); ``` ### 删除好友 ```js socket.emit( 'deleteFriend', { userId, // {User ID} 目标的id }, () => {}, // {Object} 返回空对象 ); ``` ### 修改密码 ```js socket.emit( 'changePassword', { oldPassword, // {string} 旧密码 newPassword, // {string} 新密码 }, () => {}, // {Object} 返回空对象 ); ``` ### 修改用户名 ```js socket.emit( 'changeUsername', { username, // {string} 新用户名 }, () => {}, // {Object} 返回空对象 ); ``` ### 重置指定用户密码 仅管理员可调用 ```js socket.emit( 'resetUserPassword', { username, // {string} 新用户名 }, (data) => { // {Object} 返回数据 data.newPassword, // {string} 新密码 }, ); ``` ### 发送消息 通过 to 字段判断是发送给群, 还是发送给个人 发送群的话, to 就是群 id 发送个人的话, to 就是两个人的 id 拼接, 按字符串比较结果, 小的在前大的在后 ```js socket.emit( 'sendMessage', { to, // {string} 目标群组, 或者俩用户id拼接结果 type, // {string} 消息类型 content, // {string} 消息内容 }, (message) => {}, // {Message} 新消息 ); ``` ### 获取联系人最后消息 ```js socket.emit( 'getLinkmansLastMessages', { linkmans, // {[string]} 联系人id列表, 与to同规则 }, (messages) => {}, // {object} 所有联系人的最后消息, key: 联系人id, value: [Message] 消息列表 ); ``` ### 获取联系人历史消息 ```js socket.emit( 'getLinkmanHistoryMessages', { linkmanId, // {string} 联系人id existCount, // {number} 已有消息数量 }, (messages) => {}, // {[Message]} 消息列表 ); ``` ### 获取默认群组的历史消息 不需要登录态 ```js socket.emit( 'getDefaultGroupHistoryMessages', { existCount, // {number} 已有消息数量 }, (messages) => {}, // {[Message]} 消息列表 ); ``` ### 创建群组 ```js socket.emit( 'createGroup', { name, // {string} 群组名 }, (group) => {}, // {Group} 新创建的群组 ); ``` ### 加入群组 ```js socket.emit( 'joinGroup', { groupId, // {Group ID} 目标群id }, (group) => {}, // {Group} 新创建的群组 ); ``` ### 退出群组 ```js socket.emit( 'leaveGroup', { groupId, // {Group ID} 目标群id }, () => {}, // {object} 返回空数据 ); ``` ### 获取群组在线用户列表 ```js socket.emit( 'getGroupOnlineMembers', { groupId, // {Group ID} 目标群id }, (users) => {}, // {[User]} 在线用户列表 ); ``` ### 获取默认群组在线用户列表 ```js socket.emit( 'getDefaultGroupOnlineMembers', {}, (users) => {}, // {[User]} 在线用户列表 ); ``` ### 修改群头像 ```js socket.emit( 'changeGroupAvatar', { groupId, // {Group ID} 目标群id avatar, // {string} 新头像url }, () => {}, // {object} 返回空数据 ); ``` ### 获取七牛前端文件上传 token ```js socket.emit( 'uploadToken', { }, (data) => { // 服务端支持七牛 data.token, // 上传token data.urlPrefix, // 文件上传后的路径前缀 // 服务端不支持七牛 data.useUploadFile, // 不支持上传七牛, 需要客户端调用 uploadFile 上传文件到服务端 }, ); ``` ### 搜索用户/群组 ```js socket.emit( 'search', { keywords, // {string} 搜索关键字 }, (data) => { data.users, // {[User]} 命中的用户 data.groups, // {[Group]} 命中的群组 }, ); ``` ### 搜索表情包 ```js socket.emit( 'searchExpression', { keywords, // {string} 搜索关键字 }, (imageUrls) => {}, // {[string]} 图片列表 ); ``` ### 获取百度语言合成 token ```js socket.emit( 'getBaiduToken', { }, (data) => { data.token, // {string} token }, ); ``` ### 封禁用户 ```js socket.emit( 'sealUser', { username, // {string} 要封禁的用户名 }, () => {}, // {object} 返回空数据 ); ``` ### 获取封禁用户列表 ```js socket.emit( 'getSealList', {}, (users) => {}, // {[string]} 被封禁的用户名列表 ); ``` ### 上传文件到服务端 ```js socket.emit( 'uploadFile', { fileName, // {string} 文件名 file, // {blob} 文件内容, blob格式 }, (data) => { data.url, // 文件url }, ); ``` ================================================ FILE: packages/docs/i18n/zh-Hans/docusaurus-plugin-content-docs/current/App.md ================================================ --- id: app title: Fiora App sidebar_label: Fiora App --- fiora app 是基于 [expo](https://expo.io/) he [react-native](https://reactnative.dev/) 开发的, 支持 Android 和 iOS 系统 ## 下载 App ### Android 点击链接或者扫描二维码下载 APK [https://cdn.suisuijiang.com/fiora.apk](https://cdn.suisuijiang.com/fiora.apk) ![](/img/android-download-qrcode.png) ### iOS iOS app 已经提交给 App Store 审核了, 现在可以通过 testflight 来安装. 请联系碎碎酱或者发送邮件给 , 附上你的 Apple ID ## 如何运行 1. 安装 expo `yarn global add expo-cli` 2. 安装依赖 `yarn install` 3. 启动编译 `expo start` 4. 根据控制台输出的提示, 在模拟器或者真实设备上运行 app 想要了解更多信息, 请查看 [https://docs.expo.io/](https://docs.expo.io/) ## 构建 App 请参考 ================================================ FILE: packages/docs/i18n/zh-Hans/docusaurus-plugin-content-docs/current/CHANGELOG.md ================================================ --- id: changelog title: 更新日志 sidebar_label: 更新日志 --- ## 2021-6-24 - 支持带有日文字符的用户名和用户标签 ## 2021-5-11 - 使用阿里云 OSS 替代七牛 CDN ## 2021-3-24 - 修复搜索功能允许使用正则表达式匹配的问题 ## 2021-3-14 - 支持服务端计算未读消息数量 ## 2021-3-2 - 识别消息中的 URL 时, 支持 host 为 localhost 或者 ip ## 2021-3-1 - 管理员创建群组时, 不再限制数量 ## 2021-2-28 - 多个用户可以使用相同的 notification token ## 2021-2-27 - 修改 app 通知内容 - 自己发送的消息不再推送通知给自己 - webpack 构建生成环境版本时显示进度条 ## 2021-2-25 - 支持推送通知给 fiora app ## 2021-2-21 - **重要** 修复错误的服务端判断管理员的逻辑, 会将所有人当做管理员. 但是前端并不会展示管理员面板 ## 2021-2-17 - 支持向 fiora 外部分享群组 ## 2021-1-26 - 文件消息的文件大小计算错误 ## 2021-1-22 - 一个 ip 在 24 小时内只允许创建三个账号 ## 2020-12-17 - 支持根据输入框内容自动搜索表情, 该功能默认是关闭的, 可以在用户设置中打开 - 只限制发消息的频率 ## 2020-12-08 - **兼容性!!!** 使用 redis 缓存来替代内存缓存, 所以你需要在运行 fiora 之前配置并启动 redis ## 2020-11-15 - 使用 webpack 插件来生成 service worker script - 重构并新增服务端脚本 ## 2020-11-14 - 适配 iOS 全面屏设备 ## 2020-11-12 - 支持设置多个管理员 ## 2020-11-08 - 支持撤回自己发的消息 ## 2020-11-07 - 支持发送文件 - 支持展示实时(数据有 60s 缓存时间)的群组在线人数和用户在线状态 - 重构 webpack 构建配置 - 修复在图片查看大图时右键会关闭的问题 ## 2020-11-04 - **兼容性!!!** 修改 config 文件配置方法, 不再支持通过命令行参数来设置 ## 2020-11-03 - 重命名一部分 npm script 名 ================================================ FILE: packages/docs/i18n/zh-Hans/docusaurus-plugin-content-docs/current/Config.md ================================================ --- id: config title: 配置 sidebar_label: 配置 --- 服务器配置 `config/server.ts` 客户端配置 `config/client.ts` 相比于直接修改配置文件, 推荐用环境变量来修改配置 在 fiora 根目录创建 `.env` 文件, 在里面填写 `key=value` 键值对(每行一个), 即可修改相应配置. 比如修改端口号 `Port=8888` ## 服务端配置 **修改服务端配置需要重启应用** | Key | 类型 | 默认值 | 描述 | | ------------------ | ------- | ---------------------------------- | ---------------------------------------------------------------------------------- | | Host | string | your ip | 服务端 host | | Port | number | 9200 | 服务端端口号 | | Database | string | mongodb://localhost:27017/fiora | mongoDB 数据库地址 | | RedisHost | string | localhost | redis 地址主机名 | | RedisPort | number | 6379 | redis 端口 | | JwtSecret | string | jwtSecret (推荐修改它来保证安全性) | jwt token 加密 secret | | MaxGroupCount | number | 3 | 用户最大可以创建的群组个数 | | AllowOrigin | string | null | 允许的客户端 origin 列表, null 时允许所有 origin 连接, 多个值逗号分割 | | tokenExpiresTime | number | 2592000000 (30 天) | 登陆 token 过期时间 | | Administrator | string | '' | 管理员用户 id 列表, 多个值逗号分割 | | DisableRegister | boolean | false | 禁止注册账号 | | DisableCreateGroup | boolean | false | 禁止创建群组 | | ALIYUN_OSS | boolean | false | 启用阿里云 OSS | | ACCESS_KEY_ID | string | '' | 阿里云 OSS access key id. 参考: https://help.aliyun.com/document_detail/48699.html | | ACCESS_KEY_SECRET | string | '' | 阿里云 OSS access key secret. 参考和 ACCESS_KEY_ID 相同 | | ROLE_ARN | string | '' | 阿里云 OSS RoleARN. 参考: https://help.aliyun.com/document_detail/28649.html | | REGION | string | '' | 阿里云 OSS 地域. 例如: `oss-cn-zhangjiakou` | | BUCKET | string | '' | 阿里云 OSS bucket 名称 | | ENDPOINT | string | '' | 阿里云 OSS 域名. 例如: `cdn.suisuijiang.com` | ## 客户端配置 **修改客户端配置需要重新构建客户端** | Key | 类型 | 默认值 | 描述 | | ---------------------- | ------- | --------------- | -------------------------------------------------- | | Server | string | / | 客户端要连接的服务端地址 | | MaxImageSize | number | 3145728 (3MB) | 客户端可以上传的最大图片大小 | | MaxBackgroundImageSize | number | 5242880 (5MB) | 客户端可以上传的最大背景图大小 | | MaxAvatarSize | number | 1572864 (1.5MB) | 客户端可以上传的最大头像图片大小 | | MaxFileSize | number | 10485760 (10MB) | 客户端可以上传的最大文件大小 | | DefaultTheme | string | cool | 默认主题 | | Sound | string | default | 默认通知音 | | TagColorMode | string | fixedColor | 默认标签颜色模式 | | FrontendMonitorAppId | string | fixedColor | 岳鹰监控 appId | | DisableDeleteMessage | boolean | false | 禁止用户撤回消息 | ================================================ FILE: packages/docs/i18n/zh-Hans/docusaurus-plugin-content-docs/current/FAQ.md ================================================ --- id: faq title: 问题解答 sidebar_label: 问题解答 --- ## 如何设置管理员 1. 获取用户 id, 参考 [getUserId](/docs/script#getuserid) 2. 修改 `Administrator` 配置项, 改为上一步获取的 id 3. 重启服务端 ## 如何修改默认群组名称 参考 [updateDefaultGroupName](/docs/script#updatedefaultgroupname) ## 如何自定义域名 推荐使用 nginx 反向代理 示例配置, **请修改注释项的配置** ``` server { listen 80; # 修改为你的域名 server_name fiora.suisuijiang.com; location / { proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header Host $http_host; proxy_set_header Upgrade $http_upgrade; proxy_set_header X-NginX-Proxy true; proxy_set_header Connection "upgrade"; proxy_http_version 1.1; proxy_pass http://localhost:9200; } } ``` 配置 HTTPS + HTTP 2.0 ``` server { listen 80; # 修改为你的域名 server_name fiora.suisuijiang.com; return 301 https://fiora.suisuijiang.com$request_uri; } server { listen 443 ssl http2; # 修改为你的域名 server_name fiora.suisuijiang.com; ssl on; # 修改为你的ssl证书位置 ssl_certificate ./ssl/fiora.suisuijiang.com.crt; ssl_certificate_key ./ssl/fiora.suisuijiang.com.key; ssl_session_timeout 5m; ssl_protocols TLSv1 TLSv1.1 TLSv1.2; ssl_ciphers ECDHE-RSA-AES128-GCM-SHA256:HIGH:!aNULL:!MD5:!RC4:!DHE; ssl_prefer_server_ciphers on; location / { proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header Host $http_host; proxy_set_header Upgrade $http_upgrade; proxy_set_header X-NginX-Proxy true; proxy_set_header Connection "upgrade"; proxy_http_version 1.1; proxy_pass http://localhost:9200; } } ``` ## 如何禁止注册, 手动分配账号 将 `DisableRegister` 配置项设置为 true, 重启服务器生效 使用脚本手动注册新用户. 参考 [register](/docs/script#register) ## 如何删除用户 参考 [deleteUser](/docs/script#deleteuser) ## 客户端报错 "调用失败,处于萌新阶段" 为了避免新注册的用户乱发消息刷屏, 注册时间未满 24 小时的用户每分钟限制只能发 5 条消息 ## 执行命令时报错 "Couldn't find a package.json file in xxx" 先 cd 到 fiora 根目录下, 再执行相应命令 ## 为什么修改配置不生效 1. 先确认配置修改是否正确 - 如果是直接修改配置文件, 请确认修改的部分语法和格式正确 - 如果是通过 .env 文件修改配置, 请确认格式正确 2. 修改配置后 - 如果修改的是服务端配置, 需要重启服务端 - 如果修改的是客户端配置, 需要重新构建客户端 ## 怎么重新构建客户端 `yarn build:web` ================================================ FILE: packages/docs/i18n/zh-Hans/docusaurus-plugin-content-docs/current/Getting-Start.md ================================================ --- id: getting-start title: 入门指南 sidebar_label: 入门指南 --- import useBaseUrl from '@docusaurus/useBaseUrl'; fiora 是一款有趣的聊天应用. 基于 node.js, mongodb, react 和 socket.io 等技术开发 该项目起始于 [2015-11-04](https://github.com/yinxin630/chatroom-with-sails/commit/0a032372727550b8b4087f24ac299de03b677b9f) 在线地址: [https://fiora.suisuijiang.com/](https://fiora.suisuijiang.com/) 安卓/iOS app: [https://github.com/yinxin630/fiora-app](https://github.com/yinxin630/fiora-app) ## 功能 1. 注册账号并登录, 可以长久保存你的数据 2. 加入现有群组或者创建自己的群组, 来和大家交流 3. 和任意人私聊, 并添加其为好友 4. 多种消息类型, 包括文本 / 表情 / 图片 / 代码 / 文件 / 命令, 还可以搜索表情包 5. 当收到新消息时推送通知, 可以自定义通知铃声, 还可以把消息读出来 6. 选择你喜欢的主题, 并且可以设置为任何你喜欢的壁纸以及主题颜色 7. 设置管理员来管理用户 ## 运行截图 PC Phone ## 目录结构 |-- [.githubb] // github actions |-- [.vscode] // vscode 工作区配置 |-- [bin] // 服务端脚本 |-- [build] // webpack 配置 |-- [client] // web 客户端 |-- [config] // 应用配置 |-- [dist] // 构建客户端输出目录 |-- [docs] // 文档 |-- [public] // 服务端静态资源 |-- [server] // 服务端 |-- [test] // 单元测试 |-- [types] // typescript 类型 |-- [utils] // 工具方法 |-- .babelrc // babel 配置 |-- .eslintignore // eslint 忽略 |-- .eslintrc // eslint 配置 |-- .gitignore // git 忽略 |-- .nodemonrc // nodemon 配置 |-- .prettierrc // prettier 配置 |-- Dockerfile // docker 文件 |-- LICENSE // fiora 许可 |-- docker-compose.yaml // docker compose 配置 |-- jest.*.sj // jest 配置 |-- package.json // npm |-- tsconfig.json // typescript 配置 |-- yarn.lock // yarn ... ## 贡献代码 如果你想要添加功能或者修复 BUG. 请遵守下列流程. 1. fork 本仓库并克隆 fork 后的仓库到本地 2. 安装依赖 `yarn install` 3. 修改代码并确认无 bug 4. 提交代码, 如果 eslint 有报错, 请修复后再次提交 5. 创建一个 pull request ================================================ FILE: packages/docs/i18n/zh-Hans/docusaurus-plugin-content-docs/current/INSTALL.md ================================================ --- id: install title: 安装 sidebar_label: 安装 --- ## 环境准备 要运行 Fiora, 你需要 Node.js(推荐 v14 LTS 版本), MongoDB 和 redis - 安装 Node.js - 官网 - 更推荐使用 nvm 安装 Node.js - 安装 nvm - 通过 nvm 安装 Node.js - 安装 MongoDB - 官网 - 安装 redis - 官网 推荐在 Linux 或者 MacOS 系统上运行 ## 如何运行 1. 克隆项目到本地 `git clone https://github.com/yinxin630/fiora.git -b master` 2. 确保安装了 [yarn](https://www.npmjs.com/package/yarn), 如果没有安装请执行 `npm install -g yarn` 3. 安装项目依赖 `yarn install` 4. 构建客户端代码 `yarn build:web` 5. 配置 JwtSecret `echo "JwtSecret=" > .env2`. 要将 `` 替换为一个秘密文本 6. 启动服务端 `yarn start` 7. 使用浏览器打开 `http://[ip地址]:[端口]`(比如 `http://127.0.0.1:9200`) ### 在后台运行 使用 `yarn start` 运行服务端会在断开 ssh 连接后停止运行, 推荐使用 pm2 来运行 ```bash # 安装 pm2 npm install -g pm2 # 使用 pm2 运行 fiora pm2 start yarn --name fiora -- start # 查看 pm2 应用状态 pm2 ls # 查看 pm2 fiora 日志 pm2 logs fiora ``` ### 运行开发模式 1. 启动服务端 `yarn dev:server` 2. 启动客户端 `yarn dev:web` 3. 使用浏览器打开 `http://localhost:8080` ### docker 运行 首先安装 docker #### 直接从 DockerHub 镜像运行 ```bash # 拉取 mongo docker pull mongo # 拉取 redis docker pull redis # 拉取 fiora docker pull suisuijiang/fiora # 创建虚拟网络 docker network create fiora-network # 启动 mongodB docker run --name fioradb -p 27017:27017 --network fiora-network mongo # 启动 redis docker run --name fioraredis -p 6379:6379 --network fiora-network redis # 启动 fiora docker run --name fiora -p 9200:9200 --network fiora-network -e Database=mongodb://fioradb:27017/fiora -e RedisHost=fioraredis suisuijiang/fiora ``` #### 本地构建镜像运行 1. 克隆项目到本地 `git clone https://github.com/yinxin630/fiora.git -b master` 2. 构建镜像 `docker-compose build --no-cache --force-rm` 3. 运行 `docker-compose up` ================================================ FILE: packages/docs/i18n/zh-Hans/docusaurus-plugin-content-docs/current/Script.md ================================================ --- id: script title: 脚本 sidebar_label: 脚本 --- fiora 内置了一个命令行工具, 用来管理服务器. 执行 `fiora` 查看工具 > **注意!** 这些脚本大多会直接修改数据库, 推荐(但非必需)提前备份数据库并停止服务端后再执行 ## deleteMessages `fiora deleteMessages` 删除所有历史消息记录, 如果消息图片和文件是存储在服务器上, 也可以一并删除 ## deleteTodayRegisteredUsers `fiora deleteTodayRegisteredUsers` 删除当天(以服务器时间为准)新注册的所有用户 ## deleteUser `fiora deleteUser [userId]` 删除指定用户, 同时删除其历史消息, 退出其已加入的群组并删除其所有好友关系 ## doctor `fiora doctor` 检查服务端配置和状态, 可以用来定位服务端启动失败的原因 ## fixUsersAvatar `fiora fixUsersAvatar` 修复用户错误头像路径, 请根据你的实际情况修改脚本判断逻辑 ## getUserId `fiora getUserId [username]` 获取指定用户名的 userId ## register `fiora register [username] [password]` 注册新用户, 当禁止注册时可以由管理员通过其注册新用户 ## updateDefaultGroupName `fiora updateDefaultGroupName [newName]` 更新默认群组名 ================================================ FILE: packages/docs/i18n/zh-Hans/docusaurus-theme-classic/footer.json ================================================ { "link.title.Docs": { "message": "文档" }, "link.item.label.Overview": { "message": "首页" }, "link.item.label.Getting Start": { "message": "入门指南" }, "link.item.label.Change Log": { "message": "更新日志" }, "link.title.Community": { "message": "社区" }, "link.item.label.Feedback": { "message": "加入聊天群" }, "link.item.label.Issues": { "message": "Bug 反馈" }, "link.title.More": { "message": "更多" }, "link.item.label.Author": { "message": "关于作者" }, "link.item.label.GitHub": { "message": "GitHub" } } ================================================ FILE: packages/docs/i18n/zh-Hans/docusaurus-theme-classic/navbar.json ================================================ { "item.label.Docs": { "message": "文档" } } ================================================ FILE: packages/docs/package.json ================================================ { "name": "@fiora/docs", "version": "0.0.0", "private": true, "scripts": { "docusaurus": "docusaurus", "dev:docs": "docusaurus start", "build:docs": "docusaurus build", "swizzle": "docusaurus swizzle", "deploy:docs": "docusaurus deploy", "serve": "docusaurus serve", "clear": "docusaurus clear", "write-translations": "docusaurus write-translations" }, "dependencies": { "@docusaurus/core": "^2.0.0-alpha.71", "@docusaurus/preset-classic": "^2.0.0-alpha.71", "@mdx-js/react": "^1.6.21", "clsx": "^1.1.1", "react": "^16.8.4", "react-dom": "^16.8.4" }, "browserslist": { "production": [ ">0.5%", "not dead", "not op_mini all" ], "development": [ "last 1 chrome version", "last 1 firefox version", "last 1 safari version" ] } } ================================================ FILE: packages/docs/sidebars.js ================================================ module.exports = { docs: { fiora: ['getting-start', 'install', 'config', 'script', 'faq', 'changelog'], 'fiora-app': ['app'], }, }; ================================================ FILE: packages/docs/src/css/custom.css ================================================ /* stylelint-disable docusaurus/copyright-header */ /** * Any CSS included here will be global. The classic template * bundles Infima by default. Infima is a CSS framework designed to * work well for content-centric websites. */ /* You can override the default Infima variables here. */ :root { --ifm-color-primary: #25c2a0; --ifm-color-primary-dark: rgb(33, 175, 144); --ifm-color-primary-darker: rgb(31, 165, 136); --ifm-color-primary-darkest: rgb(26, 136, 112); --ifm-color-primary-light: rgb(70, 203, 174); --ifm-color-primary-lighter: rgb(102, 212, 189); --ifm-color-primary-lightest: rgb(146, 224, 208); --ifm-code-font-size: 95%; } .docusaurus-highlight-code-line { background-color: rgb(72, 77, 91); display: block; margin: 0 calc(-1 * var(--ifm-pre-padding)); padding: 0 var(--ifm-pre-padding); } ================================================ FILE: packages/docs/src/pages/index.js ================================================ import React from 'react'; import clsx from 'clsx'; import Layout from '@theme/Layout'; import Link from '@docusaurus/Link'; import useDocusaurusContext from '@docusaurus/useDocusaurusContext'; import useBaseUrl from '@docusaurus/useBaseUrl'; import Translate, { translate } from '@docusaurus/Translate'; import styles from './styles.module.css'; const features = [ { title: 'Richness', imageUrl: 'img/website-app.png', description: translate({ message: 'Richness', }), }, { title: 'Cross Platform', imageUrl: 'img/cross-platform.png', description: translate({ message: 'Cross Platform', }), }, { title: 'Open Source', imageUrl: 'img/open-source.png', description: translate({ message: 'Open Source', }), }, ]; function Feature({ imageUrl, title, description }) { const imgUrl = useBaseUrl(imageUrl); return (
{imgUrl && (
{title}
)}

{title}

{description}

); } const descriptions = [ { title: translate({ message: 'Join Chat Title' }), content: translate({ message: 'Join Chat Content' }), image: `img/undraw_youtube_tutorial.svg`, imageAlign: 'right', }, { title: translate({ message: 'Rich Feature Title' }), content: translate({ message: 'Rich Feature Content' }), image: `img/undraw_note_list.svg`, imageAlign: 'left', }, { title: translate({ message: 'Deploy By Yourself Title' }), content: translate({ message: 'Deploy By Yourself Content' }), image: `img/undraw_code_review.svg`, imageAlign: 'right', }, ]; function Description({ title, content, image, index }) { return (
{title}

{title}

{content}

); } function DeployByYourself({ url }) { return (

{translate({ message: 'Interested' })}

{translate({ message: 'Getting Start' })}
); } function Home() { const context = useDocusaurusContext(); const { siteConfig = {} } = context; const title = translate({ message: 'Title' }); const tagLine = translate({ message: 'TagLine' }); const keywords = translate({ message: 'Keywords' }); const description = translate({ message: 'Description' }); const docsUrl = translate({ message: 'DocsUrl' }); return (

{title}

{tagLine}