[
  {
    "path": ".dockerignore",
    "content": "**/node_module\npackages/docs/\npackages/web/.linaria-cache/\npackages/web/dist/\n\nyarn-error.log"
  },
  {
    "path": ".eslintignore",
    "content": "node_modules/\ndist/\npublic/\nbuild/\ndocs/\n*.d.ts\n"
  },
  {
    "path": ".eslintrc",
    "content": "{\n    \"extends\": [\"eslint-config-airbnb\", \"prettier\"],\n    \"parser\": \"@typescript-eslint/parser\",\n    \"parserOptions\": {\n        \"project\": \"./tsconfig.json\",\n        \"createDefaultProgram\": true\n    },\n    \"env\": {\n        \"browser\": true,\n        \"node\": true,\n        \"jest/globals\": true\n    },\n    \"plugins\": [\"@typescript-eslint\", \"react\", \"react-hooks\", \"jsx-a11y\", \"import\", \"jest\"],\n    \"globals\": {\n        \"importScripts\": true,\n        \"workbox\": true,\n        \"__TEST__\": true\n    },\n    \"settings\": {\n        \"import/resolver\": {\n            \"node\": {\n                \"extensions\": [\".js\", \".jsx\", \".ts\", \".tsx\"]\n            }\n        }\n    },\n    \"rules\": {\n        \"@typescript-eslint/no-unused-vars\": 2,\n        \"global-require\": 0,\n        \"implicit-arrow-linebreak\": 0,\n        \"import/extensions\": [\n            2,\n            {\n                \"ts\": \"never\",\n                \"tsx\": \"never\",\n                \"js\": \"never\",\n                \"jsx\": \"never\"\n            }\n        ],\n        \"indent\": [\n            2,\n            4,\n            {\n                \"SwitchCase\": 1\n            }\n        ],\n        \"jsx-a11y/click-events-have-key-events\": 0,\n        \"jsx-a11y/interactive-supports-focus\": 0,\n        \"jsx-a11y/no-noninteractive-element-interactions\": 0,\n        \"jsx-a11y/no-noninteractive-element-to-interactive-role\": 0,\n        \"no-param-reassign\": 0,\n        \"no-plusplus\": 0,\n        \"no-script-url\": 0,\n        \"no-underscore-dangle\": 0,\n        \"object-curly-newline\": 0,\n        \"react/jsx-filename-extension\": [\n            2,\n            {\n                \"extensions\": [\".js\", \".jsx\", \".tsx\"]\n            }\n        ],\n        \"react/jsx-indent\": [2, 4],\n        \"react/jsx-indent-props\": [2, 4],\n        \"react/jsx-props-no-spreading\": 0,\n        \"react/jsx-one-expression-per-line\": 0,\n        \"react/static-property-placement\": 0,\n        \"react-hooks/rules-of-hooks\": 2,\n        \"react-hooks/exhaustive-deps\": 0,\n        \"react/require-default-props\": [2, { \"ignoreFunctionalComponents\": true }],\n        \"import/prefer-default-export\": 0,\n        \"prefer-promise-reject-errors\": \"off\"\n    }\n}\n"
  },
  {
    "path": ".github/workflows/codeql-analysis.yml",
    "content": "# For most projects, this workflow file will not need changing; you simply need\n# to commit it to your repository.\n#\n# You may wish to alter this file to override the set of languages analyzed,\n# or to provide custom queries or build logic.\n#\n# ******** NOTE ********\n# We have attempted to detect the languages in your repository. Please check\n# the `language` matrix defined below to confirm you have the correct set of\n# supported CodeQL languages.\n#\nname: \"CodeQL\"\n\non:\n  push:\n    branches: [ master ]\n  pull_request:\n    # The branches below must be a subset of the branches above\n    branches: [ master ]\n  schedule:\n    - cron: '32 14 * * 5'\n\njobs:\n  analyze:\n    name: Analyze\n    runs-on: ubuntu-latest\n\n    strategy:\n      fail-fast: false\n      matrix:\n        language: [ 'typescript', 'javascript' ]\n        # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python' ]\n        # Learn more:\n        # 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\n\n    steps:\n    - name: Checkout repository\n      uses: actions/checkout@v2\n\n    # Initializes the CodeQL tools for scanning.\n    - name: Initialize CodeQL\n      uses: github/codeql-action/init@v1\n      with:\n        languages: ${{ matrix.language }}\n        # If you wish to specify custom queries, you can do so here or in a config file.\n        # By default, queries listed here will override any specified in a config file.\n        # Prefix the list here with \"+\" to use these queries and those in the config file.\n        # queries: ./path/to/local/query, your-org/your-repo/queries@main\n\n    # Autobuild attempts to build any compiled languages  (C/C++, C#, or Java).\n    # If this step fails, then you should remove it and run the build manually (see below)\n    - name: Autobuild\n      uses: github/codeql-action/autobuild@v1\n\n    # ℹ️ Command-line programs to run using the OS shell.\n    # 📚 https://git.io/JvXDl\n\n    # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines\n    #    and modify them (or add more) to build your code if your project\n    #    uses a compiled language\n\n    #- run: |\n    #   make bootstrap\n    #   make release\n\n    - name: Perform CodeQL Analysis\n      uses: github/codeql-action/analyze@v1\n"
  },
  {
    "path": ".github/workflows/lint.yml",
    "content": "# This workflow will do a clean install of node dependencies, build the source code and run tests across different versions of node\n# For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions\n\nname: Lint Code Style\n\non:\n  push:\n    branches: [ master ]\n  pull_request:\n    branches: [ master ]\n\njobs:\n  lint:\n    runs-on: ubuntu-latest\n    strategy:\n      matrix:\n        node-version: [10.x]\n    steps:\n    - uses: actions/checkout@master\n    - uses: bahmutov/npm-install@v1.4.5\n    - run: yarn lint\n"
  },
  {
    "path": ".github/workflows/test.yml",
    "content": "name: Unit Test\n\non:\n  push:\n    branches: [ master ]\n  pull_request:\n    branches: [ master ]\n\njobs:\n  test:\n    runs-on: ubuntu-latest\n    strategy:\n      matrix:\n        node-version: [10.x]\n    steps:\n    - uses: actions/checkout@master\n    - uses: bahmutov/npm-install@v1.4.5\n    - run: yarn test"
  },
  {
    "path": ".github/workflows/ts.yml",
    "content": "# This workflow will do a clean install of node dependencies, build the source code and run tests across different versions of node\n# For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions\n\nname: Typescript Type Check\n\non:\n  push:\n    branches: [ master ]\n  pull_request:\n    branches: [ master ]\n\njobs:\n  ts:\n    runs-on: ubuntu-latest\n    strategy:\n      matrix:\n        node-version: [10.x]\n    steps:\n    - uses: actions/checkout@master\n    - uses: bahmutov/npm-install@v1.4.5\n    - run: yarn ts-check\n"
  },
  {
    "path": ".gitignore",
    "content": ".DS_Store\nnode_modules/\ndist/\ncoverage/\n.idea/\n.linaria-cache/\n\nnpm-debug.log\nyarn-error.log\n.eslintcache\nlerna-debug.log\n\n.env\n\npackages/server/public/*\n!packages/server/public/avatar/\npackages/server/public/avatar/*_*.*\n!packages/server/public/favicon-96.png\n!packages/server/public/favicon-192.png\n!packages/server/public/favicon-512.png\n!packages/server/public/manifest.json\n!packages/server/public/index.html\n!packages/server/public/PrivacyPolicy.html"
  },
  {
    "path": ".prettierrc",
    "content": "{\n    \"tabWidth\": 4,\n    \"trailingComma\": \"all\",\n    \"singleQuote\": true,\n    \"arrowParens\": \"always\",\n    \"printWidth\": 80\n}"
  },
  {
    "path": ".vscode/settings.json",
    "content": "{\n    \"typescript.tsdk\": \"node_modules/typescript/lib\"\n}"
  },
  {
    "path": "Dockerfile",
    "content": "FROM node:14\n\nWORKDIR /usr/app/fiora\n\nCOPY packages ./packages\nCOPY package.json tsconfig.json yarn.lock lerna.json ./\nRUN touch .env\n\nRUN yarn install\n\nRUN yarn build:web\n\nCMD yarn start\n"
  },
  {
    "path": "LICENSE",
    "content": "MIT License\n\nCopyright (c) 2015-2021 碎碎酱\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n"
  },
  {
    "path": "README.md",
    "content": "# [Fiora](https://fiora.suisuijiang.com/) &middot; [![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)\n\n\nFiora 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\n\n- **Richness:** Fiora contains backend, frontend, Android and iOS apps\n- **Cross Platform:** Fiora is developed with node.js. Supports Windows / Linux / macOS systems\n- **Open Source:** Fiora follows the MIT open source license\n\nOnline Example: [https://fiora.suisuijiang.com/](https://fiora.suisuijiang.com/)   \nDocumentation: [https://yinxin630.github.io/fiora/](https://yinxin630.github.io/fiora/)\n\n**Other Client**   \nVscode Extension: [https://github.com/moonrailgun/fiora-for-vscode](https://github.com/moonrailgun/fiora-for-vscode)   \n\nIf you are seek for other open-source IM Application which like discord or slack, maybe try out `Tailchat`: https://tailchat.msgbyte.com/ \n\n## Features\n\n1. Register an account and log in, it can save your data for a long time\n2. Join an existing group or create your own group to communicate with everyone\n3. Chat privately with anyone and add them as friends\n4. Multiple message types, including text / emoticons / pictures / codes / files / commands, you can also search for emoticons\n5. Push notification when you receive a new message, you can customize the notification ringtone, and it can also read the message out\n6. Choose the theme you like, and you can set it as any wallpaper and theme color you like\n7. Set up an administrator to manage users\n\n## Screenshot\n\n<img src=\"https://github.com/yinxin630/fiora/raw/master/packages/docs/static/img/screenshots/screenshot-pc.png\" alt=\"PC\" style=\"max-width:800px\" />\n<img src=\"https://github.com/yinxin630/fiora/raw/master/packages/docs/static/img/screenshots/screenshot-phone.png\" alt=\"Phone\" height=\"667\" style=\"max-height:667px\" />\n<img src=\"https://github.com/yinxin630/fiora/raw/master/packages/docs/static/img/screenshots/screenshot-app.png\" alt=\"App\" height=\"896\" style=\"max-height:896px\" />\n\n## Install\n\nFiora provides two ways to install\n\n- [Install by source code](https://yinxin630.github.io/fiora/docs/install#how-to-run)\n- [Install by docker](https://yinxin630.github.io/fiora/docs/install#running-on-the-docker)\n\n## Change Log\n\nYou can find the Fiora changelog [on the website](https://yinxin630.github.io/fiora/docs/changelog)\n\n## Contribution\n\nPull 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\n\n1. Fork it (<https://github.com/yinxin630/fiora/fork>)\n2. Create your feature branch (`git checkout -b some-feature`)\n3. Commit your changes (`git commit -am 'Add some some features'`)\n4. Push to the branch (`git push origin some-feature`)\n5. Create a new Pull Request\n\n## License\n\nFiora is [MIT licensed](./LICENSE)\n"
  },
  {
    "path": "docker-compose.yaml",
    "content": "version: '3.2'\n\nservices:\n  mongodb:\n    image: mongo\n    restart: always\n  redis:\n    image: redis\n    restart: always\n  fiora:\n    build: .\n    restart: always\n    ports:\n      - \"9200:9200\"\n    environment:\n      - Database=mongodb://mongodb/fiora\n      - RedisHost=redis\n"
  },
  {
    "path": "index.ts",
    "content": "#!/usr/bin/env ./node_modules/.bin/ts-node\n\nimport { program } from 'commander';\nimport cp from 'child_process';\nimport i18n from './packages/i18n/node.index';\n\nfunction exec(commandStr: string) {\n    const [command, ...args] = commandStr.split(' ');\n    cp.execFileSync(command, args, { stdio: 'inherit' });\n}\n\nprogram\n    .command('getUserId <username>')\n    .description(i18n('getUserIdDescription'))\n    .action((username: string) => {\n        exec(\n            `npx ts-node --transpile-only packages/bin/index.ts getUserId ${username}`,\n        );\n    });\n\nprogram\n    .command('register <username> <password>')\n    .description(i18n('registerDescription'))\n    .action((username: string, password: string) => {\n        exec(\n            `npx ts-node --transpile-only packages/bin/index.ts register ${username} ${password}`,\n        );\n    });\n\nprogram\n    .command('deleteUser <userId>')\n    .description(i18n('deleteUserDescription'))\n    .action((userId: string) => {\n        exec(\n            `npx ts-node --transpile-only packages/bin/index.ts deleteUser ${userId}`,\n        );\n    });\n\nprogram\n    .command('fixUsersAvatar [searchValue] [replaceValue]')\n    .description(i18n('fixUsersAvatarDescription'))\n    .action((searchValue = '', replaceValue = '') => {\n        exec(\n            `npx ts-node --transpile-only packages/bin/index.ts fixUsersAvatar ${searchValue} ${replaceValue}`,\n        );\n    });\n\nprogram\n    .command('deleteTodayRegisteredUsers')\n    .description(i18n('deleteTodayRegisteredUsersDescription'))\n    .action(() => {\n        exec(\n            `npx ts-node --transpile-only packages/bin/index.ts deleteTodayRegisteredUsers`,\n        );\n    });\n\nprogram\n    .command('deleteMessages')\n    .description(i18n('deleteMessagesDescription'))\n    .action(() => {\n        exec(\n            `npx ts-node --transpile-only packages/bin/index.ts deleteMessages`,\n        );\n    });\n\nprogram\n    .command('updateDefaultGroupName <newName>')\n    .description(i18n('updateDefaultGroupNameDescription'))\n    .action((newName: string) => {\n        exec(\n            `npx ts-node --transpile-only packages/bin/index.ts updateDefaultGroupName ${newName}`,\n        );\n    });\n\nprogram\n    .command('doctor')\n    .description(i18n('doctorDescription'))\n    .action(() => {\n        exec(`npx ts-node --transpile-only packages/bin/index.ts doctor`);\n    });\n\nprogram.usage('[command]');\n\nprogram.parse(process.argv);\n"
  },
  {
    "path": "jest.config.js",
    "content": "module.exports = {\n    preset: 'ts-jest',\n    moduleNameMapper: {\n        '^.+\\\\.(css|less|jpg|png|gif|mp3)$': '<rootDir>/jest.transformer.js',\n    },\n    collectCoverage: true,\n    globals: {\n        'ts-jest': {\n            isolatedModules: true,\n        },\n        __TEST__: true,\n    },\n    setupFilesAfterEnv: ['./jest.setup.js'],\n    collectCoverageFrom: [\n        '**/*.{ts,tsx}',\n        '!**/node_modules/**',\n        '!**/config/**',\n        '!**/test/helpers/**',\n    ],\n};\n"
  },
  {
    "path": "jest.setup.js",
    "content": "jest.mock('./packages/web/node_modules/linaria', () => ({\n    css: jest.fn(() => ''),\n}));\n\njest.mock('./packages/database/node_modules/redis', () => jest.requireActual('redis-mock'));\n"
  },
  {
    "path": "jest.transformer.js",
    "content": "const path = require('path');\n\nmodule.exports = {\n    process(src, filename) {\n        return `module.exports = ${JSON.stringify(path.basename(filename))};`;\n    },\n};\n"
  },
  {
    "path": "lerna.json",
    "content": "{\n  \"packages\": [\n    \"packages/*\"\n  ],\n  \"version\": \"independent\",\n  \"npmClient\": \"yarn\"\n}\n"
  },
  {
    "path": "package.json",
    "content": "{\n  \"name\": \"fiora\",\n  \"version\": \"1.0.0\",\n  \"description\": \"An interesting chat application power by socket.io, koa, mongodb and react\",\n  \"license\": \"MIT\",\n  \"bin\": \"index.ts\",\n  \"scripts\": {\n    \"start\": \"npx lerna run start --stream\",\n    \"dev:server\": \"npx lerna run dev:server --stream\",\n    \"dev:web\": \"npx lerna run dev:web --stream\",\n    \"build:web\": \"npx lerna run build:web --stream\",\n    \"dev:app\": \"cd packages/app && yarn dev:app && cd ../../\",\n    \"build:android\": \"cd packages/app && yarn build:android && cd ../../\",\n    \"build:ios\": \"cd packages/app && yarn build:ios && cd ../../\",\n    \"script\": \"npx lerna run script --stream\",\n    \"dev:docs\": \"npx lerna run dev:docs --stream\",\n    \"build:docs\": \"npx lerna run build:docs --stream\",\n    \"deploy:docs\": \"npx lerna run deploy:docs --stream\",\n    \"lint\": \"eslint ./ --ext js,jsx,ts,tsx --ignore-pattern .eslintignore --cache --fix\",\n    \"test\": \"jest\",\n    \"ts-check\": \"tsc --noEmit\",\n    \"install\": \"npx lerna bootstrap && yarn link\"\n  },\n  \"engines\": {\n    \"node\": \">= 14\"\n  },\n  \"author\": {\n    \"name\": \"碎碎酱\",\n    \"email\": \"yinxin630@gmail.com\"\n  },\n  \"repository\": {\n    \"type\": \"git\",\n    \"url\": \"https://github.com/yinxin630/fiora\"\n  },\n  \"devDependencies\": {\n    \"@testing-library/jest-dom\": \"^4.2.4\",\n    \"@testing-library/react\": \"^12.0.0\",\n    \"@types/jest\": \"^24.0.18\",\n    \"@types/node\": \"^15.14.1\",\n    \"@typescript-eslint/eslint-plugin\": \"^2.0.0\",\n    \"@typescript-eslint/parser\": \"^2.0.0\",\n    \"eslint\": \"^7.30.0\",\n    \"eslint-config-airbnb\": \"^18.2.1\",\n    \"eslint-config-prettier\": \"^8.3.0\",\n    \"eslint-plugin-import\": \"^2.23.4\",\n    \"eslint-plugin-jest\": \"^24.3.6\",\n    \"eslint-plugin-jsx-a11y\": \"^6.4.1\",\n    \"eslint-plugin-react\": \"^7.24.0\",\n    \"eslint-plugin-react-hooks\": \"^4.2.0\",\n    \"jest\": \"^26.1.0\",\n    \"lerna\": \"^4.0.0\",\n    \"redis-mock\": \"^0.56.3\",\n    \"ts-jest\": \"^26.1.3\",\n    \"ts-node\": \"^10.1.0\",\n    \"typescript\": \"^3.8.2\"\n  },\n  \"dependencies\": {\n    \"commander\": \"^8.0.0\"\n  }\n}\n"
  },
  {
    "path": "packages/app/.babelrc",
    "content": "{\n  \"presets\": [\"babel-preset-expo\"]\n}\n"
  },
  {
    "path": "packages/app/.eslintrc",
    "content": "{\n  \"extends\": \"../../.eslintrc\",\n  \"rules\": {\n    \"no-use-before-define\": \"off\",\n    \"consistent-return\": \"off\"\n  }\n}"
  },
  {
    "path": "packages/app/.gitignore",
    "content": "# See https://help.github.com/ignore-files/ for more about ignoring files.\n\n# expo\n.expo/\n.expo-shared/\n\n# dependencies\n/node_modules\n\n# misc\n.env.local\n.env.development.local\n.env.test.local\n.env.production.local\n\nnpm-debug.log*\nyarn-debug.log*\nyarn-error.log*\n"
  },
  {
    "path": "packages/app/.watchmanconfig",
    "content": "{}\n"
  },
  {
    "path": "packages/app/App.tsx",
    "content": "/* eslint-disable react/jsx-props-no-spreading */\nimport React from 'react';\nimport { Provider } from 'react-redux';\nimport App from './src/App';\nimport store from './src/state/store';\n\nexport default function Main(props: any) {\n    return (\n        <Provider store={store}>\n            <App {...props} />\n        </Provider>\n    );\n}\n"
  },
  {
    "path": "packages/app/app.json",
    "content": "{\n  \"expo\": {\n    \"privacy\": \"public\",\n    \"name\": \"fiora\",\n    \"icon\": \"./icon.png\",\n    \"version\": \"1.1.4\",\n    \"description\": \"App for fiora. An online chatroom\",\n    \"slug\": \"fiora\",\n    \"scheme\": \"fiora\",\n    \"ios\": {\n      \"bundleIdentifier\": \"com.suisuijiang.fiora\",\n      \"buildNumber\": \"1.1.4\",\n      \"infoPlist\": {\n        \"LSApplicationQueriesSchemes\": [\"wxp\"],\n        \"CFBundleLocalizations\" : [\"zh_CN\"],\n        \"CFBundleDevelopmentRegion\": \"zh_CN\"\n      }\n    },\n    \"android\": {\n      \"package\": \"com.suisuijiang.fiora\",\n      \"versionCode\": 10,\n      \"useNextNotificationsApi\": true\n    },\n    \"updates\": {\n      \"enabled\": false\n    }\n  }\n}\n"
  },
  {
    "path": "packages/app/package.json",
    "content": "{\n  \"name\": \"@fiora/app\",\n  \"version\": \"1.0.0\",\n  \"license\": \"MIT\",\n  \"private\": true,\n  \"main\": \"./node_modules/expo/AppEntry.js\",\n  \"scripts\": {\n    \"dev:app\": \"expo start\",\n    \"eject\": \"expo eject\",\n    \"build:android\": \"expo build:android -t apk\",\n    \"build:ios\": \"expo build:ios -t archive\"\n  },\n  \"dependencies\": {\n    \"@expo/vector-icons\": \"^12.0.5\",\n    \"@react-native-async-storage/async-storage\": \"~1.15.0\",\n    \"@react-native-community/masked-view\": \"0.1.10\",\n    \"@react-native-toolkit/triangle\": \"^0.0.1\",\n    \"autobind-decorator\": \"^2.1.0\",\n    \"deepmerge\": \"^4.2.2\",\n    \"expo\": \"^42.0.0\",\n    \"expo-constants\": \"~11.0.1\",\n    \"expo-image-picker\": \"~10.2.2\",\n    \"expo-notifications\": \"~0.12.3\",\n    \"expo-web-browser\": \"~9.2.0\",\n    \"immer\": \"^9.0.6\",\n    \"native-base\": \"^2.4.5\",\n    \"prop-types\": \"^15.6.1\",\n    \"randomcolor\": \"^0.6.2\",\n    \"react\": \"16.13.1\",\n    \"react-native\": \"https://github.com/expo/react-native/archive/sdk-42.0.0.tar.gz\",\n    \"react-native-dialog\": \"^6.2.0\",\n    \"react-native-gesture-handler\": \"~1.10.2\",\n    \"react-native-image-zoom-viewer\": \"^3.0.1\",\n    \"react-native-reanimated\": \"~2.2.0\",\n    \"react-native-router-flux\": \"^4.3.1\",\n    \"react-native-safe-area-context\": \"3.2.0\",\n    \"react-native-screens\": \"~3.4.0\",\n    \"react-redux\": \"^7.2.2\",\n    \"redux\": \"^4.0.0\",\n    \"socket.io-client\": \"^4.1.3\"\n  },\n  \"devDependencies\": {\n    \"@types/randomcolor\": \"^0.5.5\",\n    \"@types/react\": \"^17.0.14\",\n    \"@types/react-native\": \"^0.64.12\",\n    \"@types/react-redux\": \"^7.1.16\",\n    \"@types/redux\": \"^3.6.0\",\n    \"@types/socket.io-client\": \"^3.0.0\",\n    \"expo-cli\": \"^4.7.3\",\n    \"jest-expo\": \"^42.0.0\",\n    \"react-test-renderer\": \"16.3.1\"\n  }\n}\n"
  },
  {
    "path": "packages/app/src/App.tsx",
    "content": "import React from 'react';\nimport { StyleSheet, View } from 'react-native';\nimport { Scene, Router, Stack, Tabs, Lightbox } from 'react-native-router-flux';\nimport { Icon, Root } from 'native-base';\n\nimport { connect } from 'react-redux';\nimport ChatList from './pages/ChatList/ChatList';\nimport Chat from './pages/Chat/Chat';\nimport Login from './pages/LoginSignup/Login';\nimport Signup from './pages/LoginSignup/Signup';\n\nimport Loading from './components/Loading';\nimport Other from './pages/Other/Other';\nimport Notification from './components/Nofitication';\nimport { State, User } from './types/redux';\nimport SelfInfo from './pages/ChatList/SelfInfo';\nimport ChatBackButton from './pages/Chat/ChatBackButton';\nimport GroupProfile from './pages/GroupProfile/GroupProfile';\nimport ChatRightButton from './pages/Chat/ChatRightButton';\nimport UserInfo from './pages/UserInfo/UserInfo';\nimport ChatListRightButton from './pages/ChatList/ChatListRightButton';\nimport SearchResult from './pages/SearchResult/SearchResult';\nimport GroupInfo from './pages/GroupInfo/GroupInfo';\nimport BackButton from './components/BackButton';\n\ntype Props = {\n    title: string;\n    primaryColor: string;\n    isLogin: boolean;\n};\n\nfunction App({ title, primaryColor, isLogin }: Props) {\n    const primaryColor10 = `rgba(${primaryColor}, 1)`;\n    const primaryColor8 = `rgba(${primaryColor}, 0.8)`;\n\n    const sceneCommonProps = {\n        hideNavBar: false,\n        navigationBarStyle: {\n            backgroundColor: primaryColor10,\n            borderBottomWidth: 0,\n        },\n        navBarButtonColor: '#f9f9f9',\n        renderLeftButton: () => <BackButton />,\n    };\n\n    return (\n        <View style={styles.container}>\n            <Root>\n                <Router>\n                    <Stack hideNavBar>\n                        <Lightbox>\n                            <Tabs\n                                key=\"tabs\"\n                                hideNavBar\n                                tabBarStyle={{\n                                    backgroundColor: primaryColor8,\n                                    borderTopWidth: 0,\n                                }}\n                                showLabel={false}\n                            >\n                                <Scene\n                                    key=\"chatlist\"\n                                    navBarButtonColor=\"transparent\"\n                                    component={ChatList}\n                                    initial\n                                    hideNavBar={!isLogin}\n                                    icon={({ focused }) => (\n                                        <Icon\n                                            name=\"chatbubble-ellipses-outline\"\n                                            style={{\n                                                fontSize: 24,\n                                                color: focused\n                                                    ? 'white'\n                                                    : '#bbb',\n                                            }}\n                                        />\n                                    )}\n                                    renderLeftButton={() => <SelfInfo />}\n                                    renderRightButton={() => (\n                                        <ChatListRightButton />\n                                    )}\n                                    navigationBarStyle={{\n                                        backgroundColor: primaryColor10,\n                                        borderBottomWidth: 0,\n                                    }}\n                                />\n                                <Scene\n                                    key=\"other\"\n                                    component={Other}\n                                    hideNavBar\n                                    title=\"其它\"\n                                    icon={({ focused }) => (\n                                        <Icon\n                                            name=\"aperture-outline\"\n                                            style={{\n                                                fontSize: 24,\n                                                color: focused\n                                                    ? 'white'\n                                                    : '#bbb',\n                                            }}\n                                        />\n                                    )}\n                                />\n                            </Tabs>\n                        </Lightbox>\n                        <Scene\n                            key=\"chat\"\n                            component={Chat}\n                            title=\"聊天\"\n                            getTitle={title}\n                            hideNavBar={false}\n                            navigationBarStyle={{\n                                backgroundColor: primaryColor10,\n                                borderBottomWidth: 0,\n                            }}\n                            navBarButtonColor=\"#f9f9f9\"\n                            renderLeftButton={() => <ChatBackButton />}\n                            renderRightButton={() => <ChatRightButton />}\n                        />\n                        <Scene\n                            key=\"login\"\n                            component={Login}\n                            title=\"登录\"\n                            {...sceneCommonProps}\n                        />\n                        <Scene\n                            key=\"signup\"\n                            component={Signup}\n                            title=\"注册\"\n                            {...sceneCommonProps}\n                        />\n                        <Scene\n                            key=\"groupProfile\"\n                            component={GroupProfile}\n                            title=\"群组资料\"\n                            {...sceneCommonProps}\n                        />\n                        <Scene\n                            key=\"userInfo\"\n                            component={UserInfo}\n                            title=\"个人信息\"\n                            {...sceneCommonProps}\n                        />\n                        <Scene\n                            key=\"groupInfo\"\n                            component={GroupInfo}\n                            title=\"群组信息\"\n                            {...sceneCommonProps}\n                        />\n                        <Scene\n                            key=\"searchResult\"\n                            component={SearchResult}\n                            title=\"搜索结果\"\n                            {...sceneCommonProps}\n                        />\n                    </Stack>\n                </Router>\n            </Root>\n\n            <Loading />\n            <Notification />\n        </View>\n    );\n}\n\nexport default connect((state: State) => ({\n    primaryColor: state.ui.primaryColor,\n    isLogin: !!(state.user as User)?._id,\n}))(App);\n\nconst styles = StyleSheet.create({\n    container: {\n        flex: 1,\n    },\n});\n"
  },
  {
    "path": "packages/app/src/components/Avatar.tsx",
    "content": "import React from 'react';\nimport { getOSSFileUrl } from '../utils/uploadFile';\n\nimport Image from './Image';\n\ntype Props = {\n    src: string;\n    size: number;\n};\nexport default function Avatar({ src, size }: Props) {\n    const targetUrl = getOSSFileUrl(\n        src,\n        `image/resize,w_${size * 2},h_${size * 2}/quality,q_90`,\n    ) as string;\n    return (\n        <Image\n            src={targetUrl}\n            width={size}\n            height={size}\n            style={{ borderRadius: size / 2 }}\n        />\n    );\n}\n"
  },
  {
    "path": "packages/app/src/components/BackButton.tsx",
    "content": "import { View, Icon, Text } from 'native-base';\nimport React from 'react';\nimport { TouchableOpacity } from 'react-native';\nimport { Actions } from 'react-native-router-flux';\n\ntype Props = {\n    text?: string;\n};\n\nfunction BackButton({ text = '' }: Props) {\n    return (\n        <TouchableOpacity onPress={() => Actions.pop()}>\n            <View style={{ flexDirection: 'row', alignItems: 'center' }}>\n                <Icon\n                    name=\"chevron-back-outline\"\n                    style={{ color: 'white', fontSize: 28 }}\n                />\n                <Text\n                    style={{\n                        color: 'white',\n                        fontSize: 16,\n                        fontWeight: 'bold',\n                    }}\n                >\n                    {text}\n                </Text>\n            </View>\n        </TouchableOpacity>\n    );\n}\n\nexport default BackButton;\n"
  },
  {
    "path": "packages/app/src/components/Expression.tsx",
    "content": "import React from 'react';\nimport { View } from 'react-native';\n\nimport Image from './Image';\nimport uri from '../assets/images/baidu.png';\n\ntype Props = {\n    size: number;\n    index: number;\n    style?: any;\n};\n\nexport default function Expression({ size, index, style }: Props) {\n    return (\n        <View\n            style={[{ width: size, height: size, overflow: 'hidden' }, style]}\n        >\n            <Image\n                src={uri}\n                width={size}\n                height={(size * 3200) / 64}\n                style={{ marginTop: -size * index }}\n            />\n        </View>\n    );\n}\n"
  },
  {
    "path": "packages/app/src/components/Image.tsx",
    "content": "import React from 'react';\nimport { Image as BaseImage, ImageSourcePropType } from 'react-native';\nimport { getOSSFileUrl } from '../utils/uploadFile';\nimport { referer } from '../utils/constant';\n\ntype Props = {\n    src: string;\n    width?: string | number;\n    height?: string | number;\n    style?: any;\n};\n\nexport default function Image({\n    src,\n    width = '100%',\n    height = '100%',\n    style,\n}: Props) {\n    // @ts-ignore\n    let source: ImageSourcePropType = src;\n    if (typeof src === 'string') {\n        let uri = getOSSFileUrl(src, `image/quality,q_80`);\n        if (width !== '100%' && height !== '100%') {\n            uri = getOSSFileUrl(\n                src,\n                `image/resize,w_${Math.ceil(width as number)},h_${Math.ceil(\n                    height as number,\n                )}/quality,q_80`,\n            );\n        }\n        source = {\n            uri: uri as string,\n            cache: 'force-cache',\n            headers: {\n                Referer: referer,\n            },\n        };\n    }\n    return <BaseImage source={source} style={[style, { width, height }]} />;\n}\n"
  },
  {
    "path": "packages/app/src/components/Loading.tsx",
    "content": "import React from 'react';\nimport { View, Text, Dimensions, StyleSheet } from 'react-native';\nimport { Spinner } from 'native-base';\nimport { useStore } from '../hooks/useStore';\n\nconst { width: ScreenWidth, height: ScreenHeight } = Dimensions.get('window');\n\nexport default function Loading() {\n    const { loading } = useStore().ui;\n    if (!loading) {\n        return null;\n    }\n\n    return (\n        <View style={styles.loadingView}>\n            <View style={styles.loadingBox}>\n                <Spinner color=\"white\" />\n                <Text style={styles.loadingText}>{loading}</Text>\n            </View>\n        </View>\n    );\n}\n\nconst styles = StyleSheet.create({\n    loadingView: {\n        width: ScreenWidth,\n        height: ScreenHeight,\n        position: 'absolute',\n        backgroundColor: 'rgba(0,0,0,0.15)',\n        alignItems: 'center',\n        justifyContent: 'center',\n    },\n    loadingBox: {\n        width: 120,\n        height: 120,\n        backgroundColor: 'rgba(0,0,0,0.7)',\n        borderRadius: 10,\n        alignItems: 'center',\n    },\n    loadingText: {\n        color: 'white',\n    },\n});\n"
  },
  {
    "path": "packages/app/src/components/Nofitication.tsx",
    "content": "import Constants from 'expo-constants';\nimport * as Notifications from 'expo-notifications';\nimport { useState, useEffect } from 'react';\nimport { Platform, AppState } from 'react-native';\nimport { Actions } from 'react-native-router-flux';\nimport { setNotificationToken } from '../service';\nimport action from '../state/action';\nimport { State, User } from '../types/redux';\nimport { isiOS } from '../utils/platform';\nimport { useIsLogin, useStore } from '../hooks/useStore';\nimport store from '../state/store';\n\nfunction enableNotification() {\n    Notifications.setNotificationHandler({\n        handleNotification: async () => ({\n            shouldShowAlert: true,\n            shouldPlaySound: true,\n            shouldSetBadge: false,\n        }),\n    });\n}\nfunction disableNotification() {\n    Notifications.setNotificationHandler({\n        handleNotification: async () => ({\n            shouldShowAlert: false,\n            shouldPlaySound: false,\n            shouldSetBadge: false,\n        }),\n    });\n}\n\nfunction Nofitication() {\n    const isLogin = useIsLogin();\n    const state = useStore();\n    const notificationTokens = (state.user as User)?.notificationTokens || [];\n    const { connect } = state;\n\n    const [notificationToken, updateNotificationToken] = useState('');\n\n    async function registerForPushNotificationsAsync() {\n        // Push notification to Android device need google service\n        // Not supported in China\n        if (Constants.isDevice && isiOS) {\n            const {\n                status: existingStatus,\n            } = await Notifications.getPermissionsAsync();\n            let finalStatus = existingStatus;\n            if (existingStatus !== 'granted') {\n                const {\n                    status,\n                } = await Notifications.requestPermissionsAsync();\n                finalStatus = status;\n            }\n            if (finalStatus !== 'granted') {\n                return;\n            }\n            const token = (await Notifications.getExpoPushTokenAsync()).data;\n            updateNotificationToken(token);\n\n            if (Platform.OS === 'android') {\n                Notifications.setNotificationChannelAsync('default', {\n                    name: 'default',\n                    importance: Notifications.AndroidImportance.MAX,\n                    vibrationPattern: [0, 250, 250, 250],\n                    lightColor: '#FF231F7C',\n                });\n            }\n        }\n    }\n    function handleClickNotification(response: any) {\n        const { focus } = response.notification.request.content.data;\n        setTimeout(() => {\n            const currentState = store.getState() as State;\n            const linkmans = currentState.linkmans || [];\n            if (linkmans.find((linkman) => linkman._id === focus)) {\n                action.setFocus(focus);\n                if (Actions.currentScene !== 'chat') {\n                    Actions.chat();\n                }\n            }\n        }, 1000);\n    }\n    useEffect(() => {\n        disableNotification();\n        registerForPushNotificationsAsync();\n\n        Notifications.addNotificationResponseReceivedListener(\n            handleClickNotification,\n        );\n    }, []);\n\n    useEffect(() => {\n        if (\n            connect &&\n            isLogin &&\n            notificationToken &&\n            !notificationTokens.includes(notificationToken)\n        ) {\n            setNotificationToken(notificationToken);\n        }\n    }, [connect, isLogin, notificationToken]);\n\n    function handleAppStateChange(nextAppState: string) {\n        if (nextAppState === 'active') {\n            disableNotification();\n        } else if (nextAppState === 'background') {\n            enableNotification();\n        }\n    }\n    useEffect(() => {\n        AppState.addEventListener('change', handleAppStateChange);\n        return () => {\n            AppState.removeEventListener('change', handleAppStateChange);\n        };\n    }, []);\n\n    return null;\n}\n\nexport default Nofitication;\n"
  },
  {
    "path": "packages/app/src/components/PageContainer.tsx",
    "content": "import { View } from 'native-base';\nimport React from 'react';\nimport { ImageBackground, SafeAreaView, StyleSheet } from 'react-native';\n\ntype Props = {\n    children: any;\n    disableSafeAreaView?: boolean;\n};\n\nfunction PageContainer({ children, disableSafeAreaView = false }: Props) {\n    return (\n        <ImageBackground\n            source={require('../assets/images/background-cool.jpg')}\n            style={styles.backgroundImage}\n            blurRadius={10}\n        >\n            <View style={styles.children}>\n                {disableSafeAreaView ? (\n                    children\n                ) : (\n                    <SafeAreaView style={[styles.container]}>\n                        {children}\n                    </SafeAreaView>\n                )}\n            </View>\n        </ImageBackground>\n    );\n}\n\nexport default PageContainer;\n\nconst styles = StyleSheet.create({\n    container: {\n        flex: 1,\n    },\n    backgroundImage: {\n        flex: 1,\n        resizeMode: 'cover',\n    },\n    children: {\n        flex: 1,\n        backgroundColor: 'rgba(241, 241, 241, 0.6)',\n    },\n});\n"
  },
  {
    "path": "packages/app/src/components/Toast.tsx",
    "content": "import { Toast } from 'native-base';\n\nexport default {\n    success(message: string) {\n        Toast.show({\n            text: message,\n            type: 'success',\n            position: 'top',\n        });\n    },\n    warning(message: string) {\n        Toast.show({\n            text: message,\n            type: 'warning',\n            position: 'top',\n        });\n    },\n    danger(message: string) {\n        Toast.show({\n            text: message,\n            type: 'danger',\n            position: 'top',\n        });\n    },\n};\n"
  },
  {
    "path": "packages/app/src/hooks/useStore.tsx",
    "content": "import { useSelector } from 'react-redux';\nimport { State, User } from '../types/redux';\n\nexport function useStore() {\n    return useSelector((state: State) => state);\n}\n\nexport function useUser() {\n    return useStore().user as User;\n}\n\nexport function useSelfId() {\n    const user = useUser();\n    return (user && user._id) || '';\n}\n\nexport function useIsLogin() {\n    return !!useSelfId();\n}\n\nexport function useIsAdmin() {\n    const user = useUser();\n    return (user && user.isAdmin) || false;\n}\n\nexport function useTheme() {\n    const { ui } = useStore();\n    const { primaryColor, primaryTextColor } = ui;\n    return {\n        primaryColor8: `rgba(${primaryColor}, 0.8)`,\n        primaryColor10: `rgba(${primaryColor}, 1)`,\n        primaryTextColor10: `rgba(${primaryTextColor}, 1)`,\n    };\n}\n\nexport function useLinkmans() {\n    const data = useStore();\n    return data.linkmans || [];\n}\n\nexport function useFocusLinkman() {\n    const data = useStore();\n    const { linkmans, focus = '' } = data;\n    if (linkmans) {\n        return linkmans.find((linkman) => linkman._id === focus);\n    }\n    return null;\n}\n\nexport function useFocus() {\n    const data = useStore();\n    return data.focus || '';\n}\n"
  },
  {
    "path": "packages/app/src/pages/Chat/Chat.tsx",
    "content": "import React, { useEffect, useRef } from 'react';\nimport {\n    StyleSheet,\n    KeyboardAvoidingView,\n    ScrollView,\n    Dimensions,\n} from 'react-native';\nimport Constants from 'expo-constants';\nimport { Actions } from 'react-native-router-flux';\n\nimport { isiOS } from '../../utils/platform';\n\nimport MessageList from './MessageList';\nimport Input from './Input';\nimport PageContainer from '../../components/PageContainer';\nimport { Friend, Group, Linkman } from '../../types/redux';\nimport {\n    useFocusLinkman,\n    useIsLogin,\n    useSelfId,\n    useStore,\n} from '../../hooks/useStore';\nimport {\n    getDefaultGroupOnlineMembers,\n    getGroupOnlineMembers,\n    getUserOnlineStatus,\n} from '../../service';\nimport action from '../../state/action';\nimport { formatLinkmanName } from '../../utils/linkman';\nimport fetch from '../../utils/fetch';\n\nlet lastMessageIdCache = '';\n\nconst keyboardOffset = (() => {\n    const { width, height } = Dimensions.get('window');\n    const screenRatio = height / width;\n    if (screenRatio === 667 / 375) {\n        // iPhone 6 / 7 / 8\n        return 64;\n    }\n    if (screenRatio === 736 / 414) {\n        // iPhone 6 / 7 / 8 PLUS\n        return 64;\n    }\n    if (screenRatio === 812 / 375) {\n        // iPhone X / 12mini\n        return 86;\n    }\n    if (screenRatio === 896 / 414) {\n        // iPhone Xr / 11 / 11 Pro Max\n        return 86;\n    }\n    if (screenRatio === 844 / 390) {\n        // iPhone 12 / 12 Prop\n        return 64;\n    }\n    if (screenRatio === 926 / 428) {\n        // iPhone 12 Pro Max\n        return 64;\n    }\n    return Constants.statusBarHeight + 44;\n})();\n\nexport default function Chat() {\n    const isLogin = useIsLogin();\n    const self = useSelfId();\n    const { focus } = useStore();\n    const linkman = useFocusLinkman();\n    const $messageList = useRef<ScrollView>();\n\n    async function fetchGroupOnlineMembers() {\n        let onlineMembers: Group['members'] = [];\n        if (isLogin) {\n            onlineMembers = await getGroupOnlineMembers(focus);\n        } else {\n            onlineMembers = await getDefaultGroupOnlineMembers();\n        }\n        if (onlineMembers) {\n            action.updateGroupProperty(focus, 'members', onlineMembers);\n        }\n    }\n    async function fetchUserOnlineStatus() {\n        const isOnline = await getUserOnlineStatus(focus.replace(self, ''));\n        action.updateFriendProperty(focus, 'isOnline', isOnline);\n    }\n    useEffect(() => {\n        if (!linkman || !isLogin) {\n            return;\n        }\n        const request =\n            linkman.type === 'group'\n                ? fetchGroupOnlineMembers\n                : fetchUserOnlineStatus;\n        request();\n        const timer = setInterval(() => request(), 1000 * 60);\n        return () => clearInterval(timer);\n    }, [focus, isLogin]);\n\n    useEffect(() => {\n        if (Actions.currentScene !== 'chat') {\n            return;\n        }\n        Actions.refresh({\n            title: formatLinkmanName(linkman as Linkman),\n        });\n    }, [(linkman as Group).members, (linkman as Friend).isOnline]);\n\n    async function intervalUpdateHistory() {\n        if (isLogin && linkman) {\n            if (linkman.messages.length > 0) {\n                const lastMessageId =\n                    linkman.messages[linkman.messages.length - 1]._id;\n                if (lastMessageId !== lastMessageIdCache) {\n                    lastMessageIdCache = lastMessageId;\n                    await fetch('updateHistory', {\n                        linkmanId: focus,\n                        messageId: lastMessageId,\n                    });\n                }\n            }\n        }\n    }\n    useEffect(() => {\n        const timer = setInterval(intervalUpdateHistory, 1000 * 5);\n        return () => clearInterval(timer);\n    }, [focus]);\n\n    function scrollToEnd(time = 0) {\n        if (time > 200) {\n            return;\n        }\n        if ($messageList.current) {\n            $messageList.current!.scrollToEnd({ animated: false });\n        }\n\n        setTimeout(() => {\n            scrollToEnd(time + 50);\n        }, 50);\n    }\n\n    function handleInputHeightChange() {\n        if ($messageList.current) {\n            scrollToEnd();\n        }\n    }\n\n    return (\n        <PageContainer disableSafeAreaView>\n            <KeyboardAvoidingView\n                style={styles.container}\n                behavior={isiOS ? 'padding' : 'height'}\n                keyboardVerticalOffset={keyboardOffset}\n            >\n                {/* \n                // @ts-ignore */}\n                <MessageList $scrollView={$messageList} />\n                <Input onHeightChange={handleInputHeightChange} />\n            </KeyboardAvoidingView>\n        </PageContainer>\n    );\n}\n\nconst styles = StyleSheet.create({\n    container: {\n        flex: 1,\n    },\n});\n"
  },
  {
    "path": "packages/app/src/pages/Chat/ChatBackButton.tsx",
    "content": "import React from 'react';\nimport BackButton from '../../components/BackButton';\nimport { useStore } from '../../hooks/useStore';\n\nfunction ChatBackButton() {\n    const store = useStore();\n    const unread = store.linkmans.reduce((result, linkman) => {\n        result += linkman.unread;\n        return result;\n    }, 0);\n\n    return <BackButton text={unread.toString()} />;\n}\n\nexport default ChatBackButton;\n"
  },
  {
    "path": "packages/app/src/pages/Chat/ChatRightButton.tsx",
    "content": "import { View, Icon } from 'native-base';\nimport React from 'react';\nimport { StyleSheet, TouchableOpacity } from 'react-native';\nimport { Actions } from 'react-native-router-flux';\nimport { useFocusLinkman } from '../../hooks/useStore';\n\nfunction ChatRightButton() {\n    const linkman = useFocusLinkman();\n\n    function handleClick() {\n        if (linkman?.type === 'group') {\n            Actions.push('groupProfile');\n        } else {\n            Actions.push('userInfo', { user: linkman });\n        }\n    }\n\n    return (\n        <TouchableOpacity onPress={handleClick}>\n            <View style={styles.container}>\n                <Icon name=\"ellipsis-horizontal\" style={styles.icon} />\n            </View>\n        </TouchableOpacity>\n    );\n}\n\nexport default ChatRightButton;\n\nconst styles = StyleSheet.create({\n    container: {\n        width: 44,\n        height: 44,\n        flexDirection: 'row',\n        alignItems: 'center',\n        justifyContent: 'center',\n    },\n    icon: {\n        color: 'white',\n        fontSize: 26,\n    },\n});\n"
  },
  {
    "path": "packages/app/src/pages/Chat/ImageMessage.tsx",
    "content": "/* eslint-disable react/jsx-props-no-spreading */\nimport { View } from 'native-base';\nimport React from 'react';\nimport { Dimensions, StyleSheet, TouchableOpacity } from 'react-native';\nimport Image from '../../components/Image';\nimport { Message } from '../../types/redux';\n\nconst { width: ScreenWidth } = Dimensions.get('window');\n\ntype Props = {\n    message: Message;\n    openImageViewer: (imageUrl: string) => void;\n    couldDelete: boolean;\n    onLongPress: () => void;\n};\n\nfunction ImageMessage({\n    message,\n    openImageViewer,\n    couldDelete,\n    onLongPress,\n}: Props) {\n    const maxWidth = ScreenWidth - 130 - 16;\n    const maxHeight = 200;\n    let scale = 1;\n    let width = 0;\n    let height = 0;\n    const parseResult = /width=([0-9]+)&height=([0-9]+)/.exec(message.content);\n    if (parseResult) {\n        width = parseInt(parseResult[1], 10);\n        height = parseInt(parseResult[2], 10);\n        if (width * scale > maxWidth) {\n            scale = maxWidth / width;\n        }\n        if (height * scale > maxHeight) {\n            scale = maxHeight / height;\n        }\n    }\n\n    function handleImageClick() {\n        const imageUrl = message.content;\n        openImageViewer(imageUrl);\n    }\n\n    return (\n        <View\n            style={[\n                styles.container,\n                { width: width * scale, height: height * scale },\n            ]}\n        >\n            <TouchableOpacity\n                onPress={handleImageClick}\n                {...(couldDelete ? { onLongPress } : {})}\n            >\n                <Image\n                    src={message.content}\n                    style={{ width: width * scale, height: height * scale }}\n                />\n            </TouchableOpacity>\n        </View>\n    );\n}\n\nexport default ImageMessage;\n\nconst styles = StyleSheet.create({\n    container: {\n        height: 200,\n        width: ScreenWidth - 130 - 16,\n        borderRadius: 3,\n        overflow: 'hidden',\n    },\n});\n"
  },
  {
    "path": "packages/app/src/pages/Chat/Input.tsx",
    "content": "import React, { useRef, useState } from 'react';\nimport {\n    StyleSheet,\n    View,\n    TextInput,\n    Text,\n    Dimensions,\n    TouchableOpacity,\n    SafeAreaView,\n} from 'react-native';\nimport { Button } from 'native-base';\nimport { Actions } from 'react-native-router-flux';\nimport { Ionicons } from '@expo/vector-icons';\nimport * as ImagePicker from 'expo-image-picker';\n\nimport action from '../../state/action';\nimport fetch from '../../utils/fetch';\nimport { isiOS } from '../../utils/platform';\nimport expressions from '../../utils/expressions';\n\nimport Expression from '../../components/Expression';\nimport { useIsLogin, useStore, useUser } from '../../hooks/useStore';\nimport { Message } from '../../types/redux';\nimport uploadFile from '../../utils/uploadFile';\n\nconst { width: ScreenWidth } = Dimensions.get('window');\nconst ExpressionSize = (ScreenWidth - 16) / 10;\n\ntype Props = {\n    onHeightChange: () => void;\n};\n\nexport default function Input({ onHeightChange }: Props) {\n    const isLogin = useIsLogin();\n    const user = useUser();\n    const { focus } = useStore();\n\n    const [message, setMessage] = useState('');\n    const [showFunctionList, toggleShowFunctionList] = useState(true);\n    const [showExpression, toggleShowExpression] = useState(false);\n    const [cursorPosition, setCursorPosition] = useState({ start: 0, end: 0 });\n\n    const $input = useRef<TextInput>();\n\n    function setInputText(text = '') {\n        // iossetNativeProps无效, 解决办法参考:https://github.com/facebook/react-native/issues/18272\n        if (isiOS) {\n            $input.current!.setNativeProps({ text: text || ' ' });\n        }\n        setTimeout(() => {\n            $input.current!.setNativeProps({ text: text || '' });\n        });\n    }\n\n    function addSelfMessage(type: string, content: string) {\n        const _id = focus + Date.now();\n        const newMessage: Message = {\n            _id,\n            type,\n            content,\n            createTime: Date.now(),\n            from: {\n                _id: user._id,\n                username: user.username,\n                avatar: user.avatar,\n                tag: user.tag,\n            },\n            to: '',\n            loading: true,\n        };\n\n        if (type === 'image') {\n            newMessage.percent = 0;\n        }\n        action.addLinkmanMessage(focus, newMessage);\n\n        return _id;\n    }\n\n    async function sendMessage(localId: string, type: string, content: string) {\n        const [err, res] = await fetch('sendMessage', {\n            to: focus,\n            type,\n            content,\n        });\n        if (!err) {\n            res.loading = false;\n            action.updateSelfMessage(focus, localId, res);\n        }\n    }\n\n    function handleSubmit() {\n        if (message === '') {\n            return;\n        }\n\n        const id = addSelfMessage('text', message);\n        sendMessage(id, 'text', message);\n\n        setMessage('');\n        toggleShowFunctionList(true);\n        toggleShowExpression(false);\n        setInputText();\n    }\n\n    function handleSelectionChange(event: any) {\n        const { start, end } = event.nativeEvent.selection;\n        setCursorPosition({\n            start,\n            end,\n        });\n    }\n\n    function handleFocus() {\n        toggleShowFunctionList(true);\n        toggleShowExpression(false);\n    }\n\n    function openExpression() {\n        $input.current!.blur();\n\n        toggleShowFunctionList(false);\n        toggleShowExpression(true);\n\n        onHeightChange();\n    }\n\n    async function handleClickImage() {\n        const currentPermission = await ImagePicker.getMediaLibraryPermissionsAsync();\n        if (currentPermission.accessPrivileges === 'none') {\n            if (currentPermission.canAskAgain) {\n                const permission = await ImagePicker.requestMediaLibraryPermissionsAsync();\n                if (permission.accessPrivileges === 'none') {\n                    return;\n                }\n            } else {\n                return;\n            }\n        }\n\n        const result = await ImagePicker.launchImageLibraryAsync({\n            mediaTypes: ImagePicker.MediaTypeOptions.Images,\n            base64: true,\n        });\n\n        if (!result.cancelled) {\n            const id = addSelfMessage(\n                'image',\n                `${result.uri}?width=${result.width}&height=${result.height}`,\n            );\n            const key = `ImageMessage/${user._id}_${Date.now()}`;\n            const imageUrl = await uploadFile(\n                result.base64 as string,\n                key,\n                true,\n            );\n            sendMessage(\n                id,\n                'image',\n                `${imageUrl}?width=${result.width}&height=${result.height}`,\n            );\n        }\n    }\n\n    async function handleClickCamera() {\n        const currentPermission = await ImagePicker.getCameraPermissionsAsync();\n        if (currentPermission.status === 'undetermined') {\n            if (currentPermission.canAskAgain) {\n                const permission = await ImagePicker.requestCameraPermissionsAsync();\n                if (permission.status === 'undetermined') {\n                    return;\n                }\n            } else {\n                return;\n            }\n        }\n\n        const result = await ImagePicker.launchCameraAsync({\n            mediaTypes: ImagePicker.MediaTypeOptions.Images,\n            base64: true,\n        });\n\n        if (!result.cancelled) {\n            const id = addSelfMessage(\n                'image',\n                `${result.uri}?width=${result.width}&height=${result.height}`,\n            );\n            const key = `ImageMessage/${user._id}_${Date.now()}`;\n            const imageUrl = await uploadFile(\n                result.base64 as string,\n                key,\n                true,\n            );\n            sendMessage(\n                id,\n                'image',\n                `${imageUrl}?width=${result.width}&height=${result.height}`,\n            );\n        }\n    }\n\n    function handleChangeText(value: string) {\n        setMessage(value);\n    }\n\n    function insertExpression(e: string) {\n        const expression = `#(${e})`;\n        const newValue = `${message.substring(\n            0,\n            cursorPosition.start,\n        )}${expression}${message.substring(\n            cursorPosition.end,\n            message.length,\n        )}`;\n        setMessage(newValue);\n        setCursorPosition({\n            start: cursorPosition.start + expression.length,\n            end: cursorPosition.start + expression.length,\n        });\n        setInputText(newValue);\n    }\n\n    return (\n        <SafeAreaView style={styles.safeView}>\n            <View style={styles.container}>\n                {isLogin ? (\n                    <View style={styles.inputContainer}>\n                        <TextInput\n                            // @ts-ignore\n                            ref={$input}\n                            style={styles.input}\n                            placeholder=\"随便聊点啥吧, 不要无意义刷屏~~\"\n                            onChangeText={handleChangeText}\n                            onSubmitEditing={handleSubmit}\n                            autoCapitalize=\"none\"\n                            blurOnSubmit={false}\n                            maxLength={2048}\n                            returnKeyType=\"send\"\n                            enablesReturnKeyAutomatically\n                            underlineColorAndroid=\"transparent\"\n                            onSelectionChange={handleSelectionChange}\n                            onFocus={handleFocus}\n                        />\n                    </View>\n                ) : (\n                    <Button block style={styles.button} onPress={Actions.login}>\n                        <Text style={styles.buttonText}>\n                            登录 / 注册, 参与聊天\n                        </Text>\n                    </Button>\n                )}\n                {isLogin && showFunctionList ? (\n                    <View style={styles.iconButtonContainer}>\n                        <Button\n                            transparent\n                            style={styles.iconButton}\n                            onPress={openExpression}\n                        >\n                            <Ionicons name=\"ios-happy\" size={28} color=\"#999\" />\n                        </Button>\n                        <Button\n                            transparent\n                            style={styles.iconButton}\n                            onPress={handleClickImage}\n                        >\n                            <Ionicons name=\"ios-image\" size={28} color=\"#999\" />\n                        </Button>\n                        <Button\n                            transparent\n                            style={styles.iconButton}\n                            onPress={handleClickCamera}\n                        >\n                            <Ionicons\n                                name=\"ios-camera\"\n                                size={28}\n                                color=\"#999\"\n                            />\n                        </Button>\n                    </View>\n                ) : null}\n                {showExpression ? (\n                    <View style={styles.expressionContainer}>\n                        {expressions.default.map((e, i) => (\n                            <TouchableOpacity\n                                key={e}\n                                onPress={() => insertExpression(e)}\n                            >\n                                <View style={styles.expression}>\n                                    <Expression index={i} size={30} />\n                                </View>\n                            </TouchableOpacity>\n                        ))}\n                    </View>\n                ) : null}\n            </View>\n        </SafeAreaView>\n    );\n}\n\nconst styles = StyleSheet.create({\n    safeView: {\n        backgroundColor: 'rgba(255, 255, 255, 0.5)',\n    },\n    container: {\n        paddingTop: 4,\n    },\n    inputContainer: {\n        flexDirection: 'row',\n        paddingLeft: 10,\n        paddingRight: 10,\n    },\n    input: {\n        flex: 1,\n        height: 36,\n        paddingLeft: 8,\n        paddingRight: 8,\n        backgroundColor: 'white',\n        borderWidth: 1,\n        borderColor: '#e5e5e5',\n        borderRadius: 5,\n    },\n    sendButton: {\n        width: 50,\n        height: 36,\n        marginLeft: 8,\n        paddingLeft: 10,\n    },\n    button: {\n        height: 36,\n        marginTop: 4,\n        marginLeft: 10,\n        marginRight: 10,\n        marginBottom: 8,\n    },\n    buttonText: {\n        color: 'white',\n    },\n    iconContainer: {\n        height: 40,\n    },\n    icon: {\n        transform: [\n            {\n                // @ts-ignore\n                translate: [0, -3],\n            },\n        ],\n    },\n\n    iconButtonContainer: {\n        flexDirection: 'row',\n        paddingLeft: 15,\n        paddingRight: 15,\n        height: 44,\n    },\n    iconButton: {\n        width: '15%',\n    },\n\n    cancelButton: {\n        borderTopWidth: 1,\n        borderTopColor: '#e6e6e6',\n    },\n    cancelButtonText: {\n        color: '#666',\n    },\n\n    // 表情框\n    expressionContainer: {\n        height: (isiOS ? 34 : 30) * 5 + 6,\n        flexDirection: 'row',\n        flexWrap: 'wrap',\n        paddingTop: 3,\n        paddingBottom: 3,\n        paddingLeft: 8,\n        paddingRight: 8,\n    },\n    expression: {\n        width: ExpressionSize,\n        height: isiOS ? 34 : 30,\n        alignItems: 'center',\n        justifyContent: 'center',\n    },\n});\n"
  },
  {
    "path": "packages/app/src/pages/Chat/InviteMessage.tsx",
    "content": "import { View, Text } from 'native-base';\nimport React from 'react';\nimport { StyleSheet, TouchableNativeFeedback } from 'react-native';\nimport { Actions } from 'react-native-router-flux';\nimport Toast from '../../components/Toast';\nimport { getLinkmanHistoryMessages, joinGroup } from '../../service';\nimport action from '../../state/action';\nimport { Message } from '../../types/redux';\n\ntype Props = {\n    message: Message;\n    isSelf: boolean;\n};\n\nfunction InviteMessage({ message, isSelf }: Props) {\n    const invite = JSON.parse(message.content);\n\n    async function handleJoinGroup() {\n        const group = await joinGroup(invite.group);\n        if (group) {\n            group.type = 'group';\n            action.addLinkman(group, true);\n            Actions.refresh({ title: group.name });\n            Toast.success('加入群组成功');\n            const messages = await getLinkmanHistoryMessages(invite.group, 0);\n            if (messages) {\n                action.addLinkmanHistoryMessages(invite.group, messages);\n            }\n        }\n    }\n\n    return (\n        <TouchableNativeFeedback onPress={handleJoinGroup}>\n            <View style={styles.container}>\n                <View\n                    style={[\n                        styles.info,\n                        { borderBottomColor: isSelf ? 'white' : '#aaa' },\n                    ]}\n                >\n                    <Text style={styles.text}>\n                        &quot;\n                        {invite.inviterName}\n                        &quot; 邀请你加入群组「\n                        {invite.groupName}」\n                    </Text>\n                </View>\n                <View style={styles.join}>\n                    <Text style={styles.text}>加入</Text>\n                </View>\n            </View>\n        </TouchableNativeFeedback>\n    );\n}\n\nexport default InviteMessage;\n\nconst styles = StyleSheet.create({\n    container: {\n        width: '90%',\n        alignItems: 'center',\n    },\n    text: {\n        fontSize: 14,\n        textAlign: 'center',\n        lineHeight: 16,\n    },\n    info: {\n        width: '100%',\n        borderBottomWidth: 1,\n        borderBottomColor: 'white',\n        paddingBottom: 4,\n    },\n    join: {\n        width: '100%',\n        paddingTop: 4,\n        paddingBottom: 2,\n    },\n});\n"
  },
  {
    "path": "packages/app/src/pages/Chat/Message.tsx",
    "content": "import React, { useEffect } from 'react';\nimport {\n    View,\n    Text,\n    StyleSheet,\n    Dimensions,\n    TouchableOpacity,\n} from 'react-native';\nimport Triangle from '@react-native-toolkit/triangle';\n\nimport { ActionSheet } from 'native-base';\nimport { Actions } from 'react-native-router-flux';\nimport Time from '../../utils/time';\nimport Avatar from '../../components/Avatar';\nimport { Message as MessageType } from '../../types/redux';\nimport SystemMessage from './SystemMessage';\nimport ImageMessage from './ImageMessage';\nimport TextMessage from './TextMessage';\nimport { getRandomColor } from '../../utils/getRandomColor';\nimport InviteMessage from './InviteMessage';\nimport {\n    useFocus,\n    useIsAdmin,\n    useSelfId,\n    useTheme,\n} from '../../hooks/useStore';\nimport { deleteMessage } from '../../service';\nimport action from '../../state/action';\n\nconst { width: ScreenWidth } = Dimensions.get('window');\n\ntype Props = {\n    message: MessageType;\n    isSelf: boolean;\n    shouldScroll: boolean;\n    scrollToEnd: () => void;\n    openImageViewer: (imageUrl: string) => void;\n};\n\nfunction Message({\n    message,\n    isSelf,\n    shouldScroll,\n    scrollToEnd,\n    openImageViewer,\n}: Props) {\n    const { primaryColor8 } = useTheme();\n    const isAdmin = useIsAdmin();\n    const self = useSelfId();\n    const focus = useFocus();\n\n    const couldDelete =\n        message.type !== 'system' && (isAdmin || message.from._id === self);\n\n    useEffect(() => {\n        if (shouldScroll) {\n            scrollToEnd();\n        }\n    }, []);\n\n    async function handleDeleteMessage() {\n        const options = ['撤回', '取消'];\n        ActionSheet.show(\n            {\n                options: ['确定', '取消'],\n                cancelButtonIndex: options.findIndex(\n                    (option) => option === '取消',\n                ),\n                title: '是否撤回消息?',\n            },\n            async (buttonIndex) => {\n                switch (buttonIndex) {\n                    case 0: {\n                        const isSuccess = await deleteMessage(message._id);\n                        if (isSuccess) {\n                            action.deleteLinkmanMessage(focus, message._id);\n                        }\n                        break;\n                    }\n                    default: {\n                        break;\n                    }\n                }\n            },\n        );\n    }\n\n    function formatTime() {\n        const createTime = new Date(message.createTime);\n        const nowTime = new Date();\n        if (Time.isToday(nowTime, createTime)) {\n            return Time.getHourMinute(createTime);\n        }\n        if (Time.isYesterday(nowTime, createTime)) {\n            return `昨天 ${Time.getHourMinute(createTime)}`;\n        }\n        if (Time.isSameYear(nowTime, createTime)) {\n            return `${Time.getMonthDate(createTime)} ${Time.getHourMinute(\n                createTime,\n            )}`;\n        }\n        return `${Time.getYearMonthDate(createTime)} ${Time.getHourMinute(\n            createTime,\n        )}`;\n    }\n\n    function handleClickAvatar() {\n        Actions.push('userInfo', { user: message.from });\n    }\n\n    function renderContent() {\n        switch (message.type) {\n            case 'text': {\n                return <TextMessage message={message} isSelf={isSelf} />;\n            }\n            case 'image': {\n                return (\n                    <ImageMessage\n                        message={message}\n                        openImageViewer={openImageViewer}\n                        couldDelete={couldDelete}\n                        onLongPress={handleDeleteMessage}\n                    />\n                );\n            }\n            case 'system': {\n                return <SystemMessage message={message} />;\n            }\n            case 'inviteV2': {\n                return <InviteMessage message={message} isSelf={isSelf} />;\n            }\n            case 'file':\n            case 'code': {\n                return (\n                    <Text style={{ color: isSelf ? 'white' : '#666' }}>\n                        暂未支持的消息类型[\n                        {message.type}\n                        ], 请在Web端查看\n                    </Text>\n                );\n            }\n            default:\n                return (\n                    <Text style={{ color: isSelf ? 'white' : '#666' }}>\n                        不支持的消息类型\n                    </Text>\n                );\n        }\n    }\n\n    return (\n        <View style={[styles.container, isSelf && styles.containerSelf]}>\n            {isSelf ? (\n                <Avatar src={message.from.avatar} size={44} />\n            ) : (\n                <TouchableOpacity onPress={handleClickAvatar}>\n                    <Avatar src={message.from.avatar} size={44} />\n                </TouchableOpacity>\n            )}\n            <View style={[styles.info, isSelf && styles.infoSelf]}>\n                <View style={[styles.nickTime, isSelf && styles.nickTimeSelf]}>\n                    {!!message.from.tag && (\n                        <View\n                            style={[\n                                styles.tag,\n                                {\n                                    backgroundColor: getRandomColor(\n                                        message.from.tag,\n                                    ),\n                                },\n                            ]}\n                        >\n                            <Text style={styles.tagText}>\n                                {message.from.tag}\n                            </Text>\n                        </View>\n                    )}\n                    <Text\n                        style={[\n                            styles.nick,\n                            isSelf ? styles.nickSelf : styles.nickOther,\n                        ]}\n                    >\n                        {message.from.username}\n                    </Text>\n                    <Text style={[styles.time, isSelf && styles.timeSelf]}>\n                        {formatTime()}\n                    </Text>\n                </View>\n                {couldDelete ? (\n                    <TouchableOpacity onLongPress={handleDeleteMessage}>\n                        <View\n                            style={[\n                                styles.content,\n                                {\n                                    backgroundColor: isSelf\n                                        ? primaryColor8\n                                        : 'white',\n                                },\n                            ]}\n                        >\n                            {renderContent()}\n                        </View>\n                    </TouchableOpacity>\n                ) : (\n                    <View\n                        style={[\n                            styles.content,\n                            {\n                                backgroundColor: isSelf\n                                    ? primaryColor8\n                                    : 'white',\n                            },\n                        ]}\n                    >\n                        {renderContent()}\n                    </View>\n                )}\n                <View\n                    style={[\n                        styles.triangle,\n                        isSelf ? styles.triangleSelf : styles.triangleOther,\n                    ]}\n                >\n                    <Triangle\n                        type=\"isosceles\"\n                        mode={isSelf ? 'right' : 'left'}\n                        base={10}\n                        height={5}\n                        color={isSelf ? primaryColor8 : 'white'}\n                    />\n                </View>\n            </View>\n        </View>\n    );\n}\n\nexport default React.memo(Message);\n\nconst styles = StyleSheet.create({\n    container: {\n        flexDirection: 'row',\n        marginBottom: 6,\n        paddingLeft: 8,\n        paddingRight: 8,\n    },\n    containerSelf: {\n        flexDirection: 'row-reverse',\n    },\n    info: {\n        position: 'relative',\n        marginLeft: 8,\n        marginRight: 8,\n        maxWidth: ScreenWidth - 120,\n        alignItems: 'flex-start',\n    },\n    infoSelf: {\n        alignItems: 'flex-end',\n    },\n    nickTime: {\n        flexDirection: 'row',\n    },\n    nickTimeSelf: {\n        flexDirection: 'row-reverse',\n    },\n    nick: {\n        fontSize: 13,\n        color: '#333',\n    },\n    nickSelf: {\n        marginRight: 4,\n    },\n    nickOther: {\n        marginLeft: 4,\n    },\n    time: {\n        fontSize: 12,\n        color: '#666',\n        marginLeft: 4,\n    },\n    timeSelf: {\n        marginRight: 4,\n    },\n    content: {\n        marginTop: 3,\n        borderRadius: 6,\n        padding: 5,\n        paddingLeft: 8,\n        paddingRight: 8,\n        backgroundColor: 'white',\n        minHeight: 26,\n        minWidth: 20,\n        marginBottom: 6,\n    },\n    triangle: {\n        position: 'absolute',\n        top: 25,\n    },\n    triangleSelf: {\n        right: -5,\n    },\n    triangleOther: {\n        left: -5,\n    },\n    tag: {\n        height: 14,\n        alignItems: 'center',\n        justifyContent: 'center',\n        paddingLeft: 3,\n        paddingRight: 3,\n        borderRadius: 3,\n    },\n    tagText: {\n        fontSize: 11,\n        color: 'white',\n    },\n});\n"
  },
  {
    "path": "packages/app/src/pages/Chat/MessageList.tsx",
    "content": "import React, { useEffect, useState } from 'react';\nimport { ScrollView, StyleSheet, Keyboard, Modal, Image } from 'react-native';\nimport ImageViewer from 'react-native-image-zoom-viewer';\n\nimport action from '../../state/action';\nimport fetch from '../../utils/fetch';\n\nimport Message from './Message';\nimport {\n    useFocusLinkman,\n    useIsLogin,\n    useSelfId,\n    useStore,\n} from '../../hooks/useStore';\nimport { Message as MessageType } from '../../types/redux';\nimport Toast from '../../components/Toast';\nimport { isAndroid, isiOS } from '../../utils/platform';\nimport { referer } from '../../utils/constant';\n\ntype Props = {\n    $scrollView: React.MutableRefObject<ScrollView>;\n};\n\nlet prevContentHeight = 0;\nlet prevMessageCount = 0;\nlet shouldScroll = true;\nlet isFirstTimeFetchHistory = true;\n\nfunction MessageList({ $scrollView }: Props) {\n    const isLogin = useIsLogin();\n    const self = useSelfId();\n    const focusLinkman = useFocusLinkman();\n    const { focus } = useStore();\n    const messages = focusLinkman?.messages || [];\n\n    const [refreshing, setRefreshing] = useState(false);\n    const [showImageViewerDialog, toggleShowImageViewerDialog] = useState(\n        false,\n    );\n    const [imageViewerIndex, setImageViewerIndex] = useState(0);\n\n    useEffect(() => {\n        const keyboardDidShowListener = Keyboard.addListener(\n            'keyboardWillShow',\n            handleKeyboardShow,\n        );\n\n        return () => {\n            prevContentHeight = 0;\n            prevMessageCount = 0;\n            shouldScroll = true;\n            isFirstTimeFetchHistory = true;\n            keyboardDidShowListener.remove();\n        };\n    }, []);\n\n    function getImages() {\n        const imageMessages = messages.filter(\n            (message) => message.type === 'image',\n        );\n        const images = imageMessages.map((message) => {\n            const url = message.content;\n            const parseResult = /width=(\\d+)&height=(\\d+)/.exec(url);\n            return {\n                url: `${url.startsWith('//') ? 'https:' : ''}${url}`,\n                ...(parseResult\n                    ? {\n                        width: +parseResult[1],\n                        height: +parseResult[2],\n                    }\n                    : {}),\n            };\n        });\n        return images;\n    }\n\n    function scrollToEnd(time = 0) {\n        if (time > 200) {\n            return;\n        }\n        if ($scrollView.current) {\n            $scrollView.current!.scrollToEnd({ animated: false });\n        }\n\n        setTimeout(() => {\n            scrollToEnd(time + 50);\n        }, 50);\n    }\n\n    function handleKeyboardShow() {\n        scrollToEnd();\n    }\n\n    async function handleRefresh() {\n        if (refreshing) {\n            return;\n        }\n\n        if (isFirstTimeFetchHistory && isAndroid) {\n            isFirstTimeFetchHistory = false;\n            return;\n        }\n\n        setRefreshing(true);\n\n        let err = null;\n        let result = null;\n        if (isLogin) {\n            [err, result] = await fetch('getLinkmanHistoryMessages', {\n                linkmanId: focus,\n                existCount: messages.length,\n            });\n        } else {\n            [err, result] = await fetch('getDefalutGroupHistoryMessages', {\n                existCount: messages.length,\n            });\n        }\n        if (!err) {\n            if (result.length > 0) {\n                action.addLinkmanHistoryMessages(focus, result);\n            } else {\n                Toast.warning('没有更多消息了');\n            }\n        }\n\n        setTimeout(() => {\n            setRefreshing(false);\n        }, 1000);\n    }\n    /**\n     * 加载历史消息后, 自动滚动到合适位置\n     */\n    function handleContentSizeChange(\n        contentWidth: number,\n        contentHeight: number,\n    ) {\n        if (prevContentHeight === 0) {\n            $scrollView.current!.scrollTo({\n                x: 0,\n                y: 0,\n                animated: false,\n            });\n        } else if (\n            contentHeight !== prevContentHeight &&\n            messages.length - prevMessageCount > 1\n        ) {\n            $scrollView.current!.scrollTo({\n                x: 0,\n                y: contentHeight - prevContentHeight - 60,\n                animated: false,\n            });\n        }\n        prevContentHeight = contentHeight;\n        prevMessageCount = messages.length;\n    }\n\n    function handleScroll(event: any) {\n        const {\n            layoutMeasurement,\n            contentSize,\n            contentOffset,\n        } = event.nativeEvent;\n        shouldScroll =\n            contentOffset.y >\n            contentSize.height - layoutMeasurement.height * 1.2;\n\n        if (contentOffset.y < (isiOS ? 0 : 50)) {\n            handleRefresh();\n        }\n    }\n\n    function openImageViewer(url: string) {\n        const images = getImages();\n        const index = images.findIndex(\n            (image) => image.url.indexOf(url) !== -1,\n        );\n        toggleShowImageViewerDialog(true);\n        setImageViewerIndex(index);\n    }\n\n    function renderMessage(message: MessageType) {\n        return (\n            <Message\n                key={message._id}\n                message={message}\n                isSelf={self === message.from._id}\n                shouldScroll={shouldScroll}\n                scrollToEnd={scrollToEnd}\n                openImageViewer={openImageViewer}\n            />\n        );\n    }\n\n    function closeImageViewerDialog() {\n        toggleShowImageViewerDialog(false);\n    }\n\n    return (\n        <ScrollView\n            style={styles.container}\n            ref={$scrollView}\n            onContentSizeChange={handleContentSizeChange}\n            scrollEventThrottle={50}\n            onScroll={handleScroll}\n        >\n            {messages.map((message) => renderMessage(message))}\n            <Modal\n                visible={showImageViewerDialog}\n                transparent\n                onRequestClose={closeImageViewerDialog}\n            >\n                <ImageViewer\n                    imageUrls={getImages()}\n                    index={imageViewerIndex}\n                    onClick={closeImageViewerDialog}\n                    onSwipeDown={closeImageViewerDialog}\n                    saveToLocalByLongPress={false}\n                    renderImage={(image) => (\n                        <Image\n                            source={{\n                                uri: image.source.uri,\n                                cache: 'force-cache',\n                                headers: {\n                                    Referer: referer,\n                                },\n                            }}\n                            style={image.style}\n                        />\n                    )}\n                />\n            </Modal>\n        </ScrollView>\n    );\n}\n\nexport default MessageList;\n\nconst styles = StyleSheet.create({\n    container: {\n        paddingTop: 8,\n        paddingBottom: 8,\n    },\n});\n"
  },
  {
    "path": "packages/app/src/pages/Chat/SystemMessage.tsx",
    "content": "import { View, Text } from 'native-base';\nimport React from 'react';\nimport { StyleSheet } from 'react-native';\nimport { Message } from '../../types/redux';\nimport { getPerRandomColor } from '../../utils/getRandomColor';\n\ntype Props = {\n    message: Message;\n};\n\nfunction SystemMessage({ message }: Props) {\n    const { content, from } = message;\n    return (\n        <View style={styles.container}>\n            <Text\n                style={[\n                    styles.text,\n                    { color: getPerRandomColor(from.originUsername as string) },\n                ]}\n            >\n                {from.originUsername}\n                &nbsp;\n            </Text>\n            <Text style={styles.text}>{content}</Text>\n        </View>\n    );\n}\n\nexport default SystemMessage;\n\nconst styles = StyleSheet.create({\n    container: {\n        flexDirection: 'row',\n        alignItems: 'center',\n    },\n    text: {\n        fontSize: 14,\n    },\n});\n"
  },
  {
    "path": "packages/app/src/pages/Chat/TextMessage.tsx",
    "content": "import { View, Text } from 'native-base';\nimport React from 'react';\nimport { TouchableOpacity, Linking, StyleSheet } from 'react-native';\nimport Expression from '../../components/Expression';\nimport { Message } from '../../types/redux';\nimport expressions from '../../utils/expressions';\n\ntype Props = {\n    message: Message;\n    isSelf: boolean;\n};\n\nfunction TextMessage({ message, isSelf }: Props) {\n    const children = [];\n    let copy = message.content;\n\n    function push(str: string) {\n        children.push(\n            <Text\n                key={Math.random()}\n                style={{ color: isSelf ? 'white' : '#444' }}\n            >\n                {str}\n            </Text>,\n        );\n    }\n\n    // 处理文本消息中的表情和链接\n    let offset = 0;\n    while (copy.length > 0) {\n        const regex = /#\\(([\\u4e00-\\u9fa5a-z]+)\\)|https?:\\/\\/(www\\.)?[-a-zA-Z0-9@:%._+~#=]{2,256}\\.[a-z]{2,6}\\b([-a-zA-Z0-9@:%_+.~#?&//=]*)/g;\n        const matchResult = regex.exec(copy);\n        if (matchResult) {\n            const r = matchResult[0];\n            const e = matchResult[1];\n            const i = copy.indexOf(r);\n            if (r[0] === '#') {\n                // 表情消息\n                const index = expressions.default.indexOf(e);\n                if (index !== -1) {\n                    // 处理从开头到匹配位置的文本\n                    if (i > 0) {\n                        push(copy.substring(0, i));\n                    }\n                    children.push(\n                        <Expression\n                            key={Math.random()}\n                            style={styles.expression}\n                            size={30}\n                            index={index}\n                        />,\n                    );\n                    offset += i + r.length;\n                }\n            } else {\n                // 链接消息\n                if (i > 0) {\n                    push(copy.substring(0, i));\n                }\n                children.push(\n                    <TouchableOpacity\n                        key={Math.random()}\n                        onPress={() => Linking.openURL(r)}\n                    >\n                        {// Do not nest in view error in dev environment\n                            process.env.NODE_ENV === 'development' ? (\n                                <View>\n                                    <Text style={{ color: '#001be5' }}>{r}</Text>\n                                </View>\n                            ) : (\n                                <Text style={{ color: '#001be5' }}>{r}</Text>\n                            )}\n                    </TouchableOpacity>,\n                );\n                offset += i + r.length;\n            }\n            copy = copy.substr(i + r.length);\n        } else {\n            break;\n        }\n    }\n\n    // 处理剩余文本\n    if (offset < message.content.length) {\n        push(message.content.substring(offset, message.content.length));\n    }\n\n    return <View style={[styles.container]}>{children}</View>;\n}\n\nexport default TextMessage;\n\nconst styles = StyleSheet.create({\n    container: {\n        // width: '100%',\n        flexDirection: 'row',\n        flexWrap: 'wrap',\n        alignItems: 'flex-end',\n    },\n    text: {\n        flexShrink: 1,\n    },\n    textSelf: {\n        color: 'white',\n    },\n    expression: {\n        marginLeft: 1,\n        marginRight: 1,\n        transform: [\n            {\n                translateY: 3,\n            },\n        ],\n    },\n});\n"
  },
  {
    "path": "packages/app/src/pages/ChatList/ChatList.tsx",
    "content": "import React, { useState } from 'react';\nimport { ScrollView, StyleSheet } from 'react-native';\n\nimport { Header, Item, Icon, Input } from 'native-base';\nimport { Actions } from 'react-native-router-flux';\nimport Linkman from './Linkman';\nimport { useLinkmans } from '../../hooks/useStore';\nimport { Linkman as LinkmanType } from '../../types/redux';\nimport PageContainer from '../../components/PageContainer';\nimport { search } from '../../service';\nimport { isiOS } from '../../utils/platform';\n\nexport default function ChatList() {\n    const [searchKeywords, updateSearchKeywords] = useState('');\n    const linkmans = useLinkmans();\n\n    async function handleSearch() {\n        const result = await search(searchKeywords);\n        updateSearchKeywords('');\n        Actions.push('searchResult', result);\n    }\n\n    function renderLinkman(linkman: LinkmanType) {\n        const { _id: linkmanId, unread, messages, createTime } = linkman;\n        const lastMessage =\n            messages.length > 0 ? messages[messages.length - 1] : null;\n\n        let time = new Date(createTime);\n        let preview = '暂无消息';\n        if (lastMessage) {\n            time = new Date(lastMessage.createTime);\n            preview =\n                lastMessage.type === 'text'\n                    ? `${lastMessage.content}`\n                    : `[${lastMessage.type}]`;\n            if (linkman.type === 'group') {\n                preview = `${lastMessage.from.username}: ${preview}`;\n            }\n        }\n        return (\n            <Linkman\n                key={linkmanId}\n                id={linkmanId}\n                name={linkman.name}\n                avatar={linkman.avatar}\n                preview={preview}\n                time={time}\n                unread={unread}\n                linkman={linkman}\n                lastMessageId={lastMessage ? lastMessage._id : ''}\n            />\n        );\n    }\n\n    return (\n        <PageContainer>\n            <Header searchBar rounded noShadow style={styles.searchContainer}>\n                <Item style={styles.searchItem}>\n                    <Icon name=\"ios-search\" style={styles.searchIcon} />\n                    <Input\n                        style={styles.searchText}\n                        placeholder=\"搜索群组/用户\"\n                        autoCapitalize=\"none\"\n                        autoCorrect={false}\n                        returnKeyType=\"search\"\n                        value={searchKeywords}\n                        onChangeText={updateSearchKeywords}\n                        onSubmitEditing={handleSearch}\n                    />\n                </Item>\n            </Header>\n            <ScrollView style={styles.messageList}>\n                {linkmans && linkmans.map((linkman) => renderLinkman(linkman))}\n            </ScrollView>\n        </PageContainer>\n    );\n}\n\nconst styles = StyleSheet.create({\n    messageList: {},\n    searchContainer: {\n        marginTop: isiOS ? 0 : 5,\n        backgroundColor: 'transparent',\n        height: 42,\n        borderBottomWidth: 0,\n    },\n    searchItem: {\n        backgroundColor: 'rgba(255,255,255,0.5)',\n    },\n    searchIcon: {\n        color: '#555',\n    },\n    searchText: {\n        fontSize: 14,\n    },\n});\n"
  },
  {
    "path": "packages/app/src/pages/ChatList/ChatListRightButton.tsx",
    "content": "import { View, Icon } from 'native-base';\nimport React, { useState } from 'react';\nimport { StyleSheet, TouchableOpacity } from 'react-native';\nimport { Actions } from 'react-native-router-flux';\nimport Dialog from 'react-native-dialog';\nimport { createGroup } from '../../service';\nimport action from '../../state/action';\n\nfunction ChatListRightButton() {\n    const [showDialog, toggleDialog] = useState(false);\n    const [groupName, updateGroupName] = useState('');\n\n    function handleCloseDialog() {\n        updateGroupName('');\n        toggleDialog(false);\n    }\n\n    async function handleCreateGroup() {\n        const group = await createGroup(groupName);\n        if (group) {\n            action.addLinkman({\n                ...group,\n                type: 'group',\n                unread: 1,\n                messages: [],\n            });\n            action.setFocus(group._id);\n            handleCloseDialog();\n            Actions.push('chat', { title: group.name });\n        }\n    }\n\n    return (\n        <>\n            <TouchableOpacity onPress={() => toggleDialog(true)}>\n                <View style={styles.container}>\n                    <Icon name=\"add-outline\" style={styles.icon} />\n                </View>\n            </TouchableOpacity>\n            <Dialog.Container visible={showDialog}>\n                <Dialog.Title>创建群组</Dialog.Title>\n                <Dialog.Description>请输入群组名</Dialog.Description>\n                <Dialog.Input\n                    value={groupName}\n                    onChangeText={updateGroupName}\n                    autoCapitalize=\"none\"\n                    autoFocus\n                    autoCorrect={false}\n                />\n                <Dialog.Button label=\"取消\" onPress={handleCloseDialog} />\n                <Dialog.Button label=\"创建\" onPress={handleCreateGroup} />\n            </Dialog.Container>\n        </>\n    );\n}\n\nexport default ChatListRightButton;\n\nconst styles = StyleSheet.create({\n    container: {\n        width: 44,\n        height: 44,\n        flexDirection: 'row',\n        alignItems: 'center',\n        justifyContent: 'center',\n    },\n    icon: {\n        color: 'white',\n        fontSize: 32,\n    },\n});\n"
  },
  {
    "path": "packages/app/src/pages/ChatList/Linkman.tsx",
    "content": "import React from 'react';\nimport { Text, StyleSheet, View, TouchableOpacity } from 'react-native';\nimport { Actions } from 'react-native-router-flux';\n\nimport Time from '../../utils/time';\nimport action from '../../state/action';\n\nimport Avatar from '../../components/Avatar';\nimport { Linkman as LinkmanType } from '../../types/redux';\nimport { formatLinkmanName } from '../../utils/linkman';\nimport fetch from '../../utils/fetch';\n\ntype Props = {\n    id: string;\n    name: string;\n    avatar: string;\n    preview: string;\n    time: Date;\n    unread: number;\n    lastMessageId: string;\n    linkman: LinkmanType;\n};\n\nexport default function Linkman({\n    id,\n    name,\n    avatar,\n    preview,\n    time,\n    unread,\n    lastMessageId,\n    linkman,\n}: Props) {\n    function formatTime() {\n        const nowTime = new Date();\n        if (Time.isToday(nowTime, time)) {\n            return Time.getHourMinute(time);\n        }\n        if (Time.isYesterday(nowTime, time)) {\n            return '昨天';\n        }\n        if (Time.isSameYear(nowTime, time)) {\n            return Time.getMonthDate(time);\n        }\n        return Time.getYearMonthDate(time);\n    }\n\n    function handlePress() {\n        action.setFocus(id);\n        Actions.chat({ title: formatLinkmanName(linkman) });\n\n        if (id && lastMessageId) {\n            fetch('updateHistory', { linkmanId: id, messageId: lastMessageId });\n        }\n    }\n\n    return (\n        <TouchableOpacity onPress={handlePress}>\n            <View style={styles.container}>\n                <Avatar src={avatar} size={50} />\n                <View style={styles.content}>\n                    <View style={styles.nickTime}>\n                        <Text style={styles.nick}>{name}</Text>\n                        <Text style={styles.time}>{formatTime()}</Text>\n                    </View>\n                    <View style={styles.previewUnread}>\n                        <Text style={styles.preview} numberOfLines={1}>\n                            {preview}\n                        </Text>\n                        {unread > 0 ? (\n                            <View style={styles.unread}>\n                                <Text style={styles.unreadText}>\n                                    {unread > 99 ? '99' : unread}\n                                </Text>\n                            </View>\n                        ) : null}\n                    </View>\n                </View>\n            </View>\n        </TouchableOpacity>\n    );\n}\n\nconst styles = StyleSheet.create({\n    container: {\n        flexDirection: 'row',\n        height: 70,\n        alignItems: 'center',\n        paddingLeft: 16,\n        paddingRight: 16,\n    },\n    content: {\n        flex: 1,\n        marginLeft: 8,\n    },\n    nickTime: {\n        flexDirection: 'row',\n        justifyContent: 'space-between',\n    },\n    nick: {\n        fontSize: 16,\n        color: '#333',\n    },\n    time: {\n        fontSize: 14,\n        color: '#888',\n    },\n    previewUnread: {\n        marginTop: 8,\n        flexDirection: 'row',\n        justifyContent: 'space-between',\n    },\n    preview: {\n        flex: 1,\n        fontSize: 14,\n        color: '#666',\n    },\n    unread: {\n        backgroundColor: '#2a7bf6',\n        width: 18,\n        height: 18,\n        borderRadius: 9,\n        alignItems: 'center',\n        justifyContent: 'center',\n        marginLeft: 5,\n    },\n    unreadText: {\n        fontSize: 10,\n        color: 'white',\n    },\n});\n"
  },
  {
    "path": "packages/app/src/pages/ChatList/SelfInfo.tsx",
    "content": "import { Text, View } from 'native-base';\nimport React from 'react';\nimport { StyleSheet } from 'react-native';\nimport Avatar from '../../components/Avatar';\nimport { useIsLogin, useStore, useTheme, useUser } from '../../hooks/useStore';\n\nfunction SelfInfo() {\n    const isLogin = useIsLogin();\n    const user = useUser();\n    const { primaryTextColor10 } = useTheme();\n    const { connect } = useStore();\n\n    if (!isLogin) {\n        return null;\n    }\n\n    const { avatar, username } = user;\n\n    return (\n        <View style={[styles.container]}>\n            <View>\n                <Avatar src={avatar} size={32} />\n                <View\n                    style={[\n                        styles.onlineStatus,\n                        connect ? styles.online : styles.offline,\n                    ]}\n                />\n            </View>\n            <View>\n                <Text style={[styles.nickname, { color: primaryTextColor10 }]}>\n                    {username}\n                </Text>\n            </View>\n        </View>\n    );\n}\n\nexport default SelfInfo;\n\nconst styles = StyleSheet.create({\n    container: {\n        flexDirection: 'row',\n        alignItems: 'center',\n        height: 38,\n        paddingLeft: 8,\n        paddingRight: 8,\n    },\n    avatar: {\n        position: 'relative',\n    },\n    nickname: {\n        marginLeft: 8,\n    },\n    onlineStatus: {\n        width: 10,\n        height: 10,\n        borderRadius: 5,\n        position: 'absolute',\n        right: 0,\n        bottom: 0,\n    },\n    online: {\n        backgroundColor: 'rgba(94, 212, 92, 1)',\n    },\n    offline: {\n        backgroundColor: 'rgba(206, 12, 35, 1)',\n    },\n});\n"
  },
  {
    "path": "packages/app/src/pages/GroupInfo/GroupInfo.tsx",
    "content": "import React from 'react';\nimport { Button, Text, View } from 'native-base';\nimport { StyleSheet } from 'react-native';\nimport { Actions } from 'react-native-router-flux';\nimport PageContainer from '../../components/PageContainer';\nimport Avatar from '../../components/Avatar';\nimport { useFocusLinkman, useLinkmans } from '../../hooks/useStore';\nimport { Linkman } from '../../types/redux';\nimport action from '../../state/action';\nimport { getLinkmanHistoryMessages, joinGroup } from '../../service';\n\ntype Props = {\n    group: {\n        _id: string;\n        avatar: string;\n        name: string;\n        members: number;\n    };\n};\n\nfunction GroupInfo({ group }: Props) {\n    const { _id, avatar, name, members } = group;\n    const linkmans = useLinkmans();\n    const linkman = linkmans.find(\n        (x) => x._id === _id && x.type === 'group',\n    ) as Linkman;\n    const isJoined = !!linkman;\n    const currentLinkman = useFocusLinkman() as Linkman;\n\n    function handleSendMessage() {\n        action.setFocus(group._id);\n        if (currentLinkman._id === group._id) {\n            Actions.popTo('chat');\n        } else {\n            Actions.popTo('_chatlist');\n            Actions.push('chat', { title: group.name });\n        }\n    }\n\n    async function handleJoinGroup() {\n        const newLinkman = await joinGroup(_id);\n        if (newLinkman) {\n            action.addLinkman({\n                ...newLinkman,\n                type: 'group',\n                unread: 0,\n                messages: [],\n            });\n            const messages = await getLinkmanHistoryMessages(_id, 0);\n            action.addLinkmanHistoryMessages(_id, messages);\n            action.setFocus(_id);\n\n            Actions.popTo('_chatlist');\n            Actions.push('chat', { title: newLinkman.name });\n        }\n    }\n\n    return (\n        <PageContainer>\n            <View style={styles.container}>\n                <View style={styles.userContainer}>\n                    <Avatar src={avatar} size={88} />\n                    <Text style={styles.nick}>{name}</Text>\n                </View>\n                <View style={styles.infoContainer}>\n                    <View style={styles.infoRow}>\n                        <Text style={styles.infoLabel}>人数:</Text>\n                        <Text style={styles.infoValue}>{members}</Text>\n                    </View>\n                </View>\n                <View style={styles.buttonContainer}>\n                    {isJoined ? (\n                        <Button\n                            primary\n                            block\n                            style={styles.button}\n                            onPress={handleSendMessage}\n                        >\n                            <Text>发送消息</Text>\n                        </Button>\n                    ) : (\n                        <Button\n                            primary\n                            block\n                            style={styles.button}\n                            onPress={handleJoinGroup}\n                        >\n                            <Text>加入群组</Text>\n                        </Button>\n                    )}\n                </View>\n            </View>\n        </PageContainer>\n    );\n}\n\nexport default GroupInfo;\n\nconst styles = StyleSheet.create({\n    container: {\n        paddingTop: 20,\n        paddingLeft: 16,\n        paddingRight: 16,\n    },\n    userContainer: {\n        alignItems: 'center',\n    },\n    infoContainer: {\n        marginTop: 20,\n    },\n    infoRow: {\n        flexDirection: 'row',\n    },\n    infoLabel: {\n        color: '#666',\n    },\n    infoValue: {\n        color: '#333',\n        marginLeft: 12,\n    },\n    nick: {\n        color: '#333',\n        marginTop: 6,\n    },\n    buttonContainer: {\n        marginTop: 20,\n    },\n    button: {\n        marginBottom: 12,\n    },\n});\n"
  },
  {
    "path": "packages/app/src/pages/GroupProfile/GroupProfile.tsx",
    "content": "import { View, Text, Button } from 'native-base';\nimport React from 'react';\nimport { Alert, Pressable, ScrollView, StyleSheet } from 'react-native';\nimport { Actions } from 'react-native-router-flux';\nimport Avatar from '../../components/Avatar';\nimport PageContainer from '../../components/PageContainer';\nimport { useFocusLinkman, useSelfId } from '../../hooks/useStore';\nimport { deleteGroup, leaveGroup } from '../../service';\nimport action from '../../state/action';\nimport { Group } from '../../types/redux';\n\nfunction GroupProfile() {\n    const linkman = useFocusLinkman() as Group;\n    const self = useSelfId();\n    const isGroupCreator = linkman.creator === self;\n\n    function getOS(os: string) {\n        return os === 'Windows Server 2008 R2 / 7' ? 'Windows 7' : os;\n    }\n\n    function ShowEnvironment(environment: string) {\n        Alert.alert('设备信息', environment);\n    }\n\n    async function handleLeaveGroup() {\n        if (isGroupCreator) {\n            const isSuccess = await deleteGroup(linkman._id);\n            if (isSuccess) {\n                action.removeLinkman(linkman._id);\n                Actions.popTo('_chatlist', { title: '' });\n            }\n        } else {\n            const isSuccess = await leaveGroup(linkman._id);\n            if (isSuccess) {\n                action.removeLinkman(linkman._id);\n                Actions.popTo('_chatlist', { title: '' });\n            }\n        }\n    }\n\n    return (\n        <PageContainer>\n            <ScrollView style={styles.container}>\n                <View style={styles.section}>\n                    <Text style={styles.sectionTitle}>功能</Text>\n                    <Button danger onPress={handleLeaveGroup}>\n                        <Text>{isGroupCreator ? '解散群组' : '退出群组'}</Text>\n                    </Button>\n                </View>\n                <View style={styles.section}>\n                    <Text style={styles.sectionTitle}>在线成员</Text>\n                    {linkman.members.map((member) => (\n                        <View key={member._id} style={styles.member}>\n                            <Avatar src={member.user.avatar} size={24} />\n                            <Text style={styles.memberName}>\n                                {member.user.username}\n                            </Text>\n                            <Pressable\n                                style={styles.memberInfoContainer}\n                                onPress={() =>\n                                    ShowEnvironment(member.environment)\n                                }\n                            >\n                                <Text style={styles.memberInfo}>\n                                    {member.browser} {getOS(member.os)}\n                                </Text>\n                            </Pressable>\n                        </View>\n                    ))}\n                </View>\n            </ScrollView>\n        </PageContainer>\n    );\n}\n\nexport default GroupProfile;\n\nconst styles = StyleSheet.create({\n    container: {\n        paddingLeft: 12,\n        paddingRight: 12,\n        paddingTop: 8,\n        paddingBottom: 8,\n    },\n    section: {\n        marginBottom: 24,\n    },\n    sectionTitle: {\n        fontSize: 18,\n        fontWeight: 'bold',\n        marginBottom: 12,\n    },\n    member: {\n        flexDirection: 'row',\n        alignItems: 'center',\n        height: 32,\n    },\n    memberName: {\n        fontSize: 14,\n        color: '#333',\n        marginLeft: 8,\n    },\n    memberInfoContainer: {\n        flex: 1,\n    },\n    memberInfo: {\n        fontSize: 12,\n        color: '#666',\n        textAlign: 'right',\n    },\n});\n"
  },
  {
    "path": "packages/app/src/pages/LoginSignup/Base.tsx",
    "content": "import React, { useRef, useState } from 'react';\nimport { Alert, StyleSheet, Text, TextInput } from 'react-native';\nimport { Form, Label, Button, View } from 'native-base';\nimport { Actions } from 'react-native-router-flux';\n\nimport PageContainer from '../../components/PageContainer';\n\ntype Props = {\n    buttonText: string;\n    jumpText: string;\n    jumpPage: string;\n    onSubmit: (username: string, password: string) => void;\n};\n\nexport default function Base({\n    buttonText,\n    jumpText,\n    jumpPage,\n    onSubmit,\n}: Props) {\n    const [username, setUsername] = useState('');\n    const [password, setPassword] = useState('');\n\n    const $username = useRef<TextInput>();\n    const $password = useRef<TextInput>();\n\n    function handlePress() {\n        $username.current!.blur();\n        $password.current!.blur();\n        onSubmit(username, password);\n    }\n\n    function handleJump() {\n        if (Actions[jumpPage]) {\n            Actions.replace(jumpPage);\n        } else {\n            Alert.alert(`跳转 ${jumpPage} 失败`);\n        }\n    }\n    return (\n        <PageContainer>\n            <View style={styles.container}>\n                <Form>\n                    <Label style={styles.label}>用户名</Label>\n                    <TextInput\n                        style={[styles.input]}\n                        // @ts-ignore\n                        ref={$username}\n                        clearButtonMode=\"while-editing\"\n                        onChangeText={setUsername}\n                        autoCapitalize=\"none\"\n                        autoCompleteType=\"username\"\n                    />\n                    <Label style={styles.label}>密码</Label>\n                    <TextInput\n                        style={[styles.input]}\n                        // @ts-ignore\n                        ref={$password}\n                        secureTextEntry\n                        clearButtonMode=\"while-editing\"\n                        onChangeText={setPassword}\n                        autoCapitalize=\"none\"\n                        autoCompleteType=\"password\"\n                    />\n                </Form>\n                <Button\n                    primary\n                    block\n                    style={styles.button}\n                    onPress={handlePress}\n                >\n                    <Text style={styles.buttonText}>{buttonText}</Text>\n                </Button>\n                <Button transparent style={styles.signup} onPress={handleJump}>\n                    <Text style={styles.signupText}>{jumpText}</Text>\n                </Button>\n            </View>\n        </PageContainer>\n    );\n}\n\nconst styles = StyleSheet.create({\n    container: {\n        paddingLeft: 12,\n        paddingRight: 12,\n        paddingTop: 20,\n    },\n    button: {\n        marginTop: 18,\n    },\n    buttonText: {\n        fontSize: 18,\n        color: '#fafafa',\n    },\n    signup: {\n        alignSelf: 'flex-end',\n    },\n    signupText: {\n        color: '#2a7bf6',\n        fontSize: 14,\n    },\n    label: {\n        marginBottom: 8,\n    },\n    input: {\n        height: 42,\n        fontSize: 16,\n        borderRadius: 6,\n        marginBottom: 12,\n        paddingLeft: 6,\n        borderWidth: 1,\n        borderColor: '#777',\n    },\n});\n"
  },
  {
    "path": "packages/app/src/pages/LoginSignup/Login.tsx",
    "content": "import React from 'react';\nimport { Container } from 'native-base';\nimport { Actions } from 'react-native-router-flux';\n\nimport fetch from '../../utils/fetch';\nimport platform from '../../utils/platform';\nimport action from '../../state/action';\n\nimport Base from './Base';\nimport { setStorageValue } from '../../utils/storage';\nimport { Friend, Group } from '../../types/redux';\n\nexport default function Login() {\n    async function handleSubmit(username: string, password: string) {\n        const [err, res] = await fetch('login', {\n            username,\n            password,\n            ...platform,\n        });\n        if (!err) {\n            const user = res;\n            action.setUser(user);\n\n            const linkmanIds = [\n                ...user.groups.map((g: Group) => g._id),\n                ...user.friends.map((f: Friend) => f._id),\n            ];\n            const [err2, linkmans] = await fetch('getLinkmansLastMessagesV2', {\n                linkmans: linkmanIds,\n            });\n            if (!err2) {\n                action.setLinkmansLastMessages(linkmans);\n            }\n\n            Actions.pop();\n            await setStorageValue('token', res.token);\n        }\n    }\n    return (\n        <Container>\n            <Base\n                buttonText=\"登录\"\n                jumpText=\"注册新用户\"\n                jumpPage=\"signup\"\n                onSubmit={handleSubmit}\n            />\n        </Container>\n    );\n}\n"
  },
  {
    "path": "packages/app/src/pages/LoginSignup/Signup.tsx",
    "content": "import React from 'react';\nimport { Container, Toast } from 'native-base';\nimport { Actions } from 'react-native-router-flux';\n\nimport fetch from '../../utils/fetch';\nimport platform from '../../utils/platform';\nimport action from '../../state/action';\n\nimport Base from './Base';\nimport { setStorageValue } from '../../utils/storage';\nimport { Friend, Group } from '../../types/redux';\n\nexport default function Signup() {\n    async function handleSubmit(username: string, password: string) {\n        const [err, res] = await fetch('register', {\n            username,\n            password,\n            ...platform,\n        });\n        if (!err) {\n            Toast.show({\n                text: '创建成功',\n                type: 'success',\n            });\n\n            const user = res;\n            action.setUser(user);\n\n            const linkmanIds = [\n                ...user.groups.map((g: Group) => g._id),\n                ...user.friends.map((f: Friend) => f._id),\n            ];\n            const [err2, linkmans] = await fetch('getLinkmansLastMessagesV2', {\n                linkmans: linkmanIds,\n            });\n            if (!err2) {\n                action.setLinkmansLastMessages(linkmans);\n            }\n\n            Actions.chatlist();\n            await setStorageValue('token', res.token);\n        }\n    }\n    return (\n        <Container>\n            <Base\n                buttonText=\"注册\"\n                jumpText=\"已有账号? 去登陆\"\n                jumpPage=\"login\"\n                onSubmit={handleSubmit}\n            />\n        </Container>\n    );\n}\n"
  },
  {
    "path": "packages/app/src/pages/Other/Other.tsx",
    "content": "import {\n    Body,\n    Button,\n    Content,\n    Icon,\n    List,\n    ListItem,\n    Right,\n    Text,\n    Toast,\n    View,\n} from 'native-base';\nimport React, { useEffect, useState } from 'react';\nimport { Linking, StyleSheet } from 'react-native';\nimport { Actions } from 'react-native-router-flux';\nimport PageContainer from '../../components/PageContainer';\n\nimport { useIsLogin } from '../../hooks/useStore';\nimport socket from '../../socket';\nimport action from '../../state/action';\nimport { getStorageValue, removeStorageValue } from '../../utils/storage';\nimport appInfo from '../../../app.json';\nimport Avatar from '../../components/Avatar';\nimport PrivacyPolicy, { PrivacyPolicyStorageKey } from './PrivacyPolicy';\n\nfunction getIsNight() {\n    const hour = new Date().getHours();\n    return hour >= 18 || hour < 6;\n}\n\nfunction Other() {\n    const isLogin = useIsLogin();\n    const [isNight, setIsNight] = useState(getIsNight());\n    const [showPrivacyPolicy, togglePrivacyPolicy] = useState(false);\n\n    async function getPrivacyPolicyStatus() {\n        const privacyPoliceStorageValue = await getStorageValue(\n            PrivacyPolicyStorageKey,\n        );\n        togglePrivacyPolicy(privacyPoliceStorageValue !== 'true');\n    }\n\n    useEffect(() => {\n        const timer = setInterval(() => {\n            setIsNight(getIsNight());\n        }, 1000);\n\n        getPrivacyPolicyStatus();\n\n        return () => {\n            clearInterval(timer);\n        };\n    }, []);\n\n    async function logout() {\n        action.logout();\n        await removeStorageValue('token');\n        Toast.show({ text: '您已经退出登录' });\n        socket.disconnect();\n        socket.connect();\n    }\n\n    async function login() {\n        const privacyPoliceStorageValue = await getStorageValue(\n            PrivacyPolicyStorageKey,\n        );\n        if (privacyPoliceStorageValue !== 'true') {\n            togglePrivacyPolicy(true);\n            return;\n        }\n\n        Actions.push('login');\n    }\n\n    return (\n        <PageContainer>\n            <Content>\n                <View style={styles.app}>\n                    <Avatar\n                        src={\n                            isNight\n                                ? require('../../../icon.png')\n                                : require('../../assets/images/wuzeiniang.gif')\n                        }\n                        size={100}\n                    />\n                    <Text style={styles.name}>\n                        fiora v{appInfo.expo.version}\n                    </Text>\n                </View>\n                <List style={styles.list}>\n                    <ListItem\n                        icon\n                        onPress={() =>\n                            Linking.openURL(\n                                'https://github.com/yinxin630/fiora-app',\n                            )\n                        }\n                    >\n                        <Body>\n                            <Text style={styles.listItemTitle}>源码</Text>\n                        </Body>\n                        <Right>\n                            <Icon\n                                active\n                                name=\"arrow-forward\"\n                                style={styles.listItemArrow}\n                            />\n                        </Right>\n                    </ListItem>\n                    <ListItem\n                        icon\n                        onPress={() =>\n                            Linking.openURL('https://www.suisuijiang.com')\n                        }\n                    >\n                        <Body>\n                            <Text style={styles.listItemTitle}>作者</Text>\n                        </Body>\n                        <Right>\n                            <Icon\n                                active\n                                name=\"arrow-forward\"\n                                style={styles.listItemArrow}\n                            />\n                        </Right>\n                    </ListItem>\n                    <ListItem\n                        icon\n                        onPress={() =>\n                            Linking.openURL('https://fiora.suisuijiang.com')\n                        }\n                    >\n                        <Body>\n                            <Text style={styles.listItemTitle}>\n                                fiora 网页版\n                            </Text>\n                        </Body>\n                        <Right>\n                            <Icon\n                                active\n                                name=\"arrow-forward\"\n                                style={styles.listItemArrow}\n                            />\n                        </Right>\n                    </ListItem>\n                </List>\n            </Content>\n            {isLogin ? (\n                <Button\n                    danger\n                    block\n                    style={styles.logoutButton}\n                    onPress={logout}\n                >\n                    <Text>退出登录</Text>\n                </Button>\n            ) : (\n                <Button block style={styles.logoutButton} onPress={login}>\n                    <Text>登录 / 注册</Text>\n                </Button>\n            )}\n            <View style={styles.copyrightContainer}>\n                <Text style={styles.copyright}>\n                    Copyright© 2015-\n                    {new Date().getFullYear()} 碎碎酱\n                </Text>\n            </View>\n            <PrivacyPolicy\n                visible={showPrivacyPolicy}\n                onClose={() => togglePrivacyPolicy(false)}\n            />\n        </PageContainer>\n    );\n}\n\nconst styles = StyleSheet.create({\n    logoutButton: {\n        marginLeft: 12,\n        marginRight: 12,\n    },\n    app: {\n        alignItems: 'center',\n        paddingTop: 12,\n    },\n    name: {\n        marginTop: 6,\n        color: '#222',\n    },\n    list: {\n        marginTop: 20,\n        backgroundColor: 'rgba(255, 255, 255, 0.4)',\n    },\n    listItemTitle: {\n        color: '#333',\n    },\n    listItemArrow: {\n        color: '#999',\n    },\n    github: {\n        fontSize: 26,\n        color: '#000',\n    },\n    copyrightContainer: {\n        marginTop: 12,\n        marginBottom: 6,\n    },\n    copyright: {\n        fontSize: 10,\n        textAlign: 'center',\n        color: '#666',\n    },\n});\n\nexport default Other;\n"
  },
  {
    "path": "packages/app/src/pages/Other/PrivacyPolicy.tsx",
    "content": "import { Text } from 'native-base';\nimport React from 'react';\nimport { Linking, StyleSheet, TouchableOpacity } from 'react-native';\nimport Dialog from 'react-native-dialog';\nimport { removeStorageValue, setStorageValue } from '../../utils/storage';\n\nexport const PrivacyPolicyStorageKey = 'privacy-policy';\n\ntype Props = {\n    visible: boolean;\n    onClose: () => void;\n};\n\nfunction PrivacyPolicy({ visible, onClose }: Props) {\n    function handleClickPrivacyPolicy() {\n        Linking.openURL('https://fiora.suisuijiang.com/PrivacyPolicy.html');\n    }\n\n    async function handleAgree() {\n        await setStorageValue(PrivacyPolicyStorageKey, 'true');\n        onClose();\n    }\n\n    async function handleDisagree() {\n        await removeStorageValue(PrivacyPolicyStorageKey);\n        onClose();\n    }\n\n    return (\n        <Dialog.Container visible={visible}>\n            <Dialog.Title>服务协议和隐私条款</Dialog.Title>\n            <Dialog.Description style={styles.container}>\n                欢迎使用 fiora\n                APP。我们非常重视您的个人信息和隐私保护，在您使用之前，请务必审慎阅读\n                <TouchableOpacity onPress={handleClickPrivacyPolicy}>\n                    <Text style={styles.text}>《隐私政策》</Text>\n                </TouchableOpacity>\n                ，并充分理解协议条款内容。我们将严格按照您同意的各项条款使用您的个人信息，以便为您提供更好的服务。\n            </Dialog.Description>\n            <Dialog.Button label=\"不同意\" onPress={handleDisagree} />\n            <Dialog.Button label=\"同意\" onPress={handleAgree} />\n        </Dialog.Container>\n    );\n}\n\nexport default PrivacyPolicy;\n\nconst styles = StyleSheet.create({\n    container: {\n        textAlign: 'left',\n    },\n    text: {\n        fontSize: 12,\n        color: '#2a7bf6',\n    },\n});\n"
  },
  {
    "path": "packages/app/src/pages/Other/Sponsor.tsx",
    "content": "import { View, Text } from 'native-base';\nimport React from 'react';\nimport { StyleSheet } from 'react-native';\nimport Dialog from 'react-native-dialog';\n\ntype Props = {\n    visible: boolean;\n    onClose: () => void;\n    onOK: () => void;\n};\n\nfunction Sponsor({ visible, onClose, onOK }: Props) {\n    return (\n        <Dialog.Container visible={visible}>\n            <Dialog.Title>赞助</Dialog.Title>\n            <Dialog.Description>\n                <View>\n                    <Text style={styles.text}>\n                        如果你觉得这个聊天室还不错的话, 希望能赞助一下~~\n                    </Text>\n                    <Text style={styles.tip}>\n                        请在转账备注中填写您的 fiora 账号\n                    </Text>\n                </View>\n            </Dialog.Description>\n            <Dialog.Button label=\"关闭\" onPress={onClose} />\n            <Dialog.Button label=\"赞助\" onPress={onOK} />\n        </Dialog.Container>\n    );\n}\n\nexport default Sponsor;\n\nconst styles = StyleSheet.create({\n    text: {\n        fontSize: 14,\n        color: '#333',\n        marginTop: 16,\n    },\n    tip: {\n        fontSize: 12,\n        color: '#666',\n        textAlign: 'center',\n        marginTop: 12,\n    },\n});\n"
  },
  {
    "path": "packages/app/src/pages/SearchResult/SearchResult.tsx",
    "content": "import React from 'react';\nimport { Tab, Tabs, Text, View } from 'native-base';\nimport { ScrollView, StyleSheet, TouchableOpacity } from 'react-native';\nimport { Actions } from 'react-native-router-flux';\nimport PageContainer from '../../components/PageContainer';\nimport Avatar from '../../components/Avatar';\n\ntype Props = {\n    groups: {\n        _id: string;\n        name: string;\n        avatar: string;\n        members: number;\n    }[];\n    users: {\n        _id: string;\n        username: string;\n        avatar: string;\n    }[];\n};\n\nfunction SearchResult({ groups, users }: Props) {\n    function handleClickGroup(group: any) {\n        Actions.push('groupInfo', { group });\n    }\n\n    function handleClickUser(user: any) {\n        Actions.push('userInfo', { user });\n    }\n\n    return (\n        <PageContainer disableSafeAreaView>\n            <Tabs\n                style={styles.container}\n                tabContainerStyle={{ backgroundColor: 'transparent' }}\n            >\n                <Tab\n                    heading={`群组(${groups.length})`}\n                    tabStyle={{ backgroundColor: 'transparent' }}\n                    activeTabStyle={{ backgroundColor: 'transparent' }}\n                >\n                    <PageContainer>\n                        <ScrollView>\n                            {groups.map((group) => (\n                                <TouchableOpacity\n                                    key={group._id}\n                                    onPress={() => handleClickGroup(group)}\n                                >\n                                    <View style={styles.item}>\n                                        <Avatar src={group.avatar} size={40} />\n                                        <View style={styles.groupInfo}>\n                                            <Text style={styles.groupName}>\n                                                {group.name}\n                                            </Text>\n                                            <Text style={styles.groupMembers}>\n                                                {group.members}人\n                                            </Text>\n                                        </View>\n                                    </View>\n                                </TouchableOpacity>\n                            ))}\n                        </ScrollView>\n                    </PageContainer>\n                </Tab>\n                <Tab\n                    heading={`用户(${users.length})`}\n                    tabStyle={{ backgroundColor: 'transparent' }}\n                    activeTabStyle={{ backgroundColor: 'transparent' }}\n                >\n                    <PageContainer>\n                        <ScrollView>\n                            {users.map((user) => (\n                                <TouchableOpacity\n                                    key={user._id}\n                                    onPress={() => handleClickUser(user)}\n                                >\n                                    <View style={styles.item}>\n                                        <Avatar src={user.avatar} size={40} />\n                                        <Text style={styles.username}>\n                                            {user.username}\n                                        </Text>\n                                    </View>\n                                </TouchableOpacity>\n                            ))}\n                        </ScrollView>\n                    </PageContainer>\n                </Tab>\n            </Tabs>\n        </PageContainer>\n    );\n}\n\nexport default SearchResult;\n\nconst styles = StyleSheet.create({\n    container: {\n        backgroundColor: 'transparent',\n    },\n    item: {\n        height: 56,\n        flexDirection: 'row',\n        alignItems: 'center',\n        paddingLeft: 16,\n        paddingRight: 16,\n    },\n    groupInfo: {\n        marginLeft: 8,\n    },\n    groupName: {\n        color: '#444',\n    },\n    groupMembers: {\n        fontSize: 14,\n        color: '#888',\n        marginTop: 1,\n    },\n    username: {\n        color: '#444',\n        marginLeft: 8,\n    },\n});\n"
  },
  {
    "path": "packages/app/src/pages/UserInfo/UserInfo.tsx",
    "content": "import React from 'react';\nimport { Button, Text, View } from 'native-base';\nimport { StyleSheet } from 'react-native';\nimport { Actions } from 'react-native-router-flux';\nimport PageContainer from '../../components/PageContainer';\nimport Avatar from '../../components/Avatar';\nimport {\n    useFocusLinkman,\n    useIsAdmin,\n    useLinkmans,\n    useSelfId,\n} from '../../hooks/useStore';\nimport { Linkman } from '../../types/redux';\nimport action from '../../state/action';\nimport {\n    addFriend,\n    deleteFriend,\n    getLinkmanHistoryMessages,\n    sealUser,\n    sealUserOnlineIp,\n} from '../../service';\nimport getFriendId from '../../utils/getFriendId';\nimport Toast from '../../components/Toast';\n\ntype Props = {\n    user: {\n        _id: string;\n        avatar: string;\n        tag: string;\n        username: string;\n    };\n};\n\nfunction UserInfo({ user }: Props) {\n    const { _id, avatar, username } = user;\n    const linkmans = useLinkmans();\n    const friend = linkmans.find((linkman) =>\n        linkman._id.includes(_id),\n    ) as Linkman;\n    const isFriend = friend && friend.type === 'friend';\n    const isAdmin = useIsAdmin();\n    const currentLinkman = useFocusLinkman() as Linkman;\n    const self = useSelfId();\n\n    function handleSendMessage() {\n        action.setFocus(friend._id);\n        if (currentLinkman._id === friend._id) {\n            Actions.pop();\n        } else {\n            Actions.popTo('_chatlist');\n            Actions.push('chat', { title: friend.name });\n        }\n    }\n\n    async function handleDeleteFriend() {\n        const isSuccess = await deleteFriend(_id);\n        if (isSuccess) {\n            action.removeLinkman(friend._id);\n            if (currentLinkman._id === friend._id) {\n                Actions.popTo('_chatlist');\n            } else {\n                Actions.pop();\n            }\n        }\n    }\n\n    async function handleAddFriend() {\n        const newLinkman = await addFriend(_id);\n        const friendId = getFriendId(_id, self);\n        if (newLinkman) {\n            if (friend) {\n                action.updateFriendProperty(friend._id, 'type', 'friend');\n                const messages = await getLinkmanHistoryMessages(\n                    friend._id,\n                    friend.messages.length,\n                );\n                action.addLinkmanHistoryMessages(friend._id, messages);\n            } else {\n                action.addLinkman({\n                    ...newLinkman,\n                    _id: friendId,\n                    name: username,\n                    type: 'friend',\n                    unread: 0,\n                    messages: [],\n                    from: self,\n                    to: {\n                        _id,\n                        avatar,\n                        username,\n                    },\n                });\n                const messages = await getLinkmanHistoryMessages(friendId, 0);\n                action.addLinkmanHistoryMessages(friendId, messages);\n            }\n            action.setFocus(friendId);\n\n            if (currentLinkman._id === friend?._id) {\n                Actions.pop();\n            } else {\n                Actions.popTo('_chatlist');\n                Actions.push('chat', { title: newLinkman.username });\n            }\n        }\n    }\n\n    async function handleSealUser() {\n        const isSuccess = await sealUser(username);\n        if (isSuccess) {\n            Toast.success('封禁用户成功');\n        }\n    }\n\n    async function handleSealIp() {\n        const isSuccess = await sealUserOnlineIp(_id);\n        if (isSuccess) {\n            Toast.success('封禁用户当前ip成功');\n        }\n    }\n\n    return (\n        <PageContainer>\n            <View style={styles.container}>\n                <View style={styles.userContainer}>\n                    <Avatar src={avatar} size={88} />\n                    <Text style={styles.nick}>{username}</Text>\n                </View>\n                <View style={styles.buttonContainer}>\n                    {isFriend ? (\n                        <>\n                            <Button\n                                primary\n                                block\n                                style={styles.button}\n                                onPress={handleSendMessage}\n                            >\n                                <Text>发送消息</Text>\n                            </Button>\n                            <Button\n                                primary\n                                block\n                                danger\n                                style={styles.button}\n                                onPress={handleDeleteFriend}\n                            >\n                                <Text>删除好友</Text>\n                            </Button>\n                        </>\n                    ) : (\n                        <Button\n                            primary\n                            block\n                            style={styles.button}\n                            onPress={handleAddFriend}\n                        >\n                            <Text>加为好友</Text>\n                        </Button>\n                    )}\n                    {isAdmin && (\n                        <>\n                            <Button\n                                primary\n                                block\n                                danger\n                                style={styles.button}\n                                onPress={handleSealUser}\n                            >\n                                <Text>封禁用户</Text>\n                            </Button>\n                            <Button\n                                primary\n                                block\n                                danger\n                                style={styles.button}\n                                onPress={handleSealIp}\n                            >\n                                <Text>封禁 ip</Text>\n                            </Button>\n                        </>\n                    )}\n                </View>\n            </View>\n        </PageContainer>\n    );\n}\n\nexport default UserInfo;\n\nconst styles = StyleSheet.create({\n    container: {\n        paddingTop: 20,\n        paddingLeft: 16,\n        paddingRight: 16,\n    },\n    userContainer: {\n        alignItems: 'center',\n    },\n    nick: {\n        color: '#333',\n        marginTop: 6,\n    },\n    buttonContainer: {\n        marginTop: 20,\n    },\n    button: {\n        marginBottom: 12,\n    },\n});\n"
  },
  {
    "path": "packages/app/src/service.ts",
    "content": "import { User } from './types/redux';\nimport fetch from './utils/fetch';\n\nfunction saveUsername(username: string) {\n    window.localStorage.setItem('username', username);\n}\n\n/**\n * 注册新用户\n * @param username 用户名\n * @param password 密码\n * @param os 系统\n * @param browser 浏览器\n * @param environment 环境信息\n */\nexport async function register(\n    username: string,\n    password: string,\n    os = '',\n    browser = '',\n    environment = '',\n) {\n    const [err, user] = await fetch('register', {\n        username,\n        password,\n        os,\n        browser,\n        environment,\n    });\n\n    if (err) {\n        return null;\n    }\n\n    saveUsername(user.username);\n    return user;\n}\n\n/**\n * 使用账密登录\n * @param username 用户名\n * @param password 密码\n * @param os 系统\n * @param browser 浏览器\n * @param environment 环境信息\n */\nexport async function login(\n    username: string,\n    password: string,\n    os = '',\n    browser = '',\n    environment = '',\n) {\n    const [err, user] = await fetch('login', {\n        username,\n        password,\n        os,\n        browser,\n        environment,\n    });\n\n    if (err) {\n        return null;\n    }\n\n    saveUsername(user.username);\n    return user;\n}\n\n/**\n * 使用token登录\n * @param token 登录token\n * @param os 系统\n * @param browser 浏览器\n * @param environment 环境信息\n */\nexport async function loginByToken(\n    token: string,\n    os = '',\n    browser = '',\n    environment = '',\n) {\n    const [err, user] = await fetch(\n        'loginByToken',\n        {\n            token,\n            os,\n            browser,\n            environment,\n        },\n        { toast: false },\n    );\n\n    if (err) {\n        return null;\n    }\n\n    saveUsername(user.username);\n    return user;\n}\n\n/**\n * 游客模式登陆\n * @param os 系统\n * @param browser 浏览器\n * @param environment 环境信息\n */\nexport async function guest(os = '', browser = '', environment = '') {\n    const [err, res] = await fetch('guest', { os, browser, environment });\n    if (err) {\n        return null;\n    }\n    return res;\n}\n\n/**\n * 修用户头像\n * @param avatar 新头像链接\n */\nexport async function changeAvatar(avatar: string) {\n    const [error] = await fetch('changeAvatar', { avatar });\n    return !error;\n}\n\n/**\n * 修改用户密码\n * @param oldPassword 旧密码\n * @param newPassword 新密码\n */\nexport async function changePassword(oldPassword: string, newPassword: string) {\n    const [error] = await fetch('changePassword', {\n        oldPassword,\n        newPassword,\n    });\n    return !error;\n}\n\n/**\n * 修改用户名\n * @param username 新用户名\n */\nexport async function changeUsername(username: string) {\n    const [error] = await fetch('changeUsername', {\n        username,\n    });\n    return !error;\n}\n\n/**\n * 修改群组名\n * @param groupId 目标群组\n * @param name 新名字\n */\nexport async function changeGroupName(groupId: string, name: string) {\n    const [error] = await fetch('changeGroupName', { groupId, name });\n    return !error;\n}\n\n/**\n * 修改群头像\n * @param groupId 目标群组\n * @param name 新头像\n */\nexport async function changeGroupAvatar(groupId: string, avatar: string) {\n    const [error] = await fetch('changeGroupAvatar', { groupId, avatar });\n    return !error;\n}\n\n/**\n * 创建群组\n * @param name 群组名\n */\nexport async function createGroup(name: string) {\n    const [, group] = await fetch('createGroup', { name });\n    return group;\n}\n\n/**\n * 删除群组\n * @param groupId 群组id\n */\nexport async function deleteGroup(groupId: string) {\n    const [error] = await fetch('deleteGroup', { groupId });\n    return !error;\n}\n\n/**\n * 加入群组\n * @param groupId 群组id\n */\nexport async function joinGroup(groupId: string) {\n    const [, group] = await fetch('joinGroup', { groupId });\n    return group;\n}\n\n/**\n * 离开群组\n * @param groupId 群组id\n */\nexport async function leaveGroup(groupId: string) {\n    const [error] = await fetch('leaveGroup', { groupId });\n    return !error;\n}\n\n/**\n * 添加好友\n * @param userId 目标用户id\n */\nexport async function addFriend(userId: string) {\n    const [, user] = await fetch<User>('addFriend', { userId });\n    return user;\n}\n\n/**\n * 删除好友\n * @param userId 目标用户id\n */\nexport async function deleteFriend(userId: string) {\n    const [err] = await fetch('deleteFriend', { userId });\n    return !err;\n}\n\n/**\n * 获取联系人历史消息\n * @param linkmanId 联系人id\n * @param existCount 客户端已有消息条数\n */\nexport async function getLinkmanHistoryMessages(\n    linkmanId: string,\n    existCount: number,\n) {\n    const [, messages] = await fetch('getLinkmanHistoryMessages', {\n        linkmanId,\n        existCount,\n    });\n    return messages;\n}\n\n/**\n * 获取默认群组的历史消息\n * @param existCount 客户端已有消息条数\n */\nexport async function getDefaultGroupHistoryMessages(existCount: number) {\n    const [, messages] = await fetch('getDefaultGroupHistoryMessages', {\n        existCount,\n    });\n    return messages;\n}\n\n/**\n * 搜索用户和群组\n * @param keywords 关键字\n */\nexport async function search(keywords: string) {\n    const [, result] = await fetch('search', { keywords });\n    return result;\n}\n\n/**\n * 搜索表情包\n * @param keywords 关键字\n */\nexport async function searchExpression(keywords: string) {\n    const [, result] = await fetch('searchExpression', { keywords });\n    return result;\n}\n\n/**\n * 发送消息\n * @param to 目标\n * @param type 消息类型\n * @param content 消息内容\n */\nexport async function sendMessage(to: string, type: string, content: string) {\n    return fetch('sendMessage', { to, type, content });\n}\n\n/**\n * 删除消息\n * @param messageId 要删除的消息id\n */\nexport async function deleteMessage(messageId: string) {\n    const [err] = await fetch('deleteMessage', { messageId });\n    return !err;\n}\n\n/**\n * 获取目标群组的在线用户列表\n * @param groupId 目标群id\n */\nexport async function getGroupOnlineMembers(groupId: string) {\n    const [, members] = await fetch('getGroupOnlineMembers', { groupId });\n    return members;\n}\n\n/**\n * 获取默认群组的在线用户列表\n */\nexport async function getDefaultGroupOnlineMembers() {\n    const [, members] = await fetch('getDefaultGroupOnlineMembers');\n    return members;\n}\n\n/**\n * 封禁用户\n * @param username 目标用户名\n */\nexport async function sealUser(username: string) {\n    const [err] = await fetch('sealUser', { username });\n    return !err;\n}\n\n/**\n * 封禁ip\n * @param ip ip地址\n */\nexport async function sealIp(ip: string) {\n    const [err] = await fetch('sealIp', { ip });\n    return !err;\n}\n\n/**\n * 封禁用户所有在线ip\n * @param userId 用户id\n */\nexport async function sealUserOnlineIp(userId: string) {\n    const [err] = await fetch('sealUserOnlineIp', { userId });\n    return !err;\n}\n\n/**\n * 获取封禁用户列表\n */\nexport async function getSealList() {\n    const [, sealList] = await fetch('getSealList');\n    return sealList;\n}\n\n/**\n * 重置指定用户的密码\n * @param username 目标用户名\n */\nexport async function resetUserPassword(username: string) {\n    const [, res] = await fetch('resetUserPassword', { username });\n    return res;\n}\n\n/**\n * 更新指定用户的标签\n * @param username 目标用户名\n * @param tag 标签\n */\nexport async function setUserTag(username: string, tag: string) {\n    const [err] = await fetch('setUserTag', { username, tag });\n    return !err;\n}\n\n/**\n * 获取在线用户 ip\n * @param userId 用户id\n */\nexport async function getUserIps(userId: string) {\n    const [, res] = await fetch('getUserIps', { userId });\n    return res;\n}\n\nexport async function getUserOnlineStatus(userId: string) {\n    const [, res] = await fetch('getUserOnlineStatus', { userId });\n    return res && res.isOnline;\n}\n\nexport async function setNotificationToken(token: string) {\n    const [, res] = await fetch(\n        'setNotificationToken',\n        { token },\n        { toast: false },\n    );\n    return res && res.isOK;\n}\n"
  },
  {
    "path": "packages/app/src/socket.ts",
    "content": "import IO from 'socket.io-client';\nimport Toast from './components/Toast';\nimport action from './state/action';\nimport store from './state/store';\nimport {\n    AddLinkmanAction,\n    AddLinkmanActionType,\n    AddLinkmanHistoryMessagesAction,\n    AddLinkmanHistoryMessagesActionType,\n    AddlinkmanMessageAction,\n    AddlinkmanMessageActionType,\n    ConnectAction,\n    ConnectActionType,\n    DeleteLinkmanMessageAction,\n    DeleteLinkmanMessageActionType,\n    Friend,\n    Group,\n    Message,\n    RemoveLinkmanAction,\n    RemoveLinkmanActionType,\n    SetGuestAction,\n    SetGuestActionType,\n    State,\n    Temporary,\n    UpdateGroupPropertyAction,\n    UpdateGroupPropertyActionType,\n    UpdateUserPropertyAction,\n    UpdateUserPropertyActionType,\n    User,\n} from './types/redux';\nimport getFriendId from './utils/getFriendId';\nimport platform from './utils/platform';\nimport { getStorageValue } from './utils/storage';\n\nconst { dispatch } = store;\n\nconst options = {\n    transports: ['websocket'],\n};\n\nconst host = 'http://10.132.67.127:9200';\nconst socket = IO(host, options);\n\nfunction fetch<T = any>(\n    event: string,\n    data: any = {},\n    { toast = true } = {},\n): Promise<[string | null, T | null]> {\n    return new Promise((resolve) => {\n        socket.emit(event, data, (res: any) => {\n            if (typeof res === 'string') {\n                if (toast) {\n                    Toast.danger(res);\n                }\n                resolve([res, null]);\n            } else {\n                resolve([null, res]);\n            }\n        });\n    });\n}\n\nasync function guest() {\n    const [err, res] = await fetch('guest', {});\n    if (!err) {\n        dispatch({\n            type: SetGuestActionType,\n            linkmans: [res],\n        } as SetGuestAction);\n    }\n}\n\nsocket.on('connect', async () => {\n    dispatch({\n        type: ConnectActionType,\n        value: true,\n    } as ConnectAction);\n\n    const token = await getStorageValue('token');\n\n    if (token) {\n        const [err, res] = await fetch(\n            'loginByToken',\n            {\n                token,\n                ...platform,\n            },\n            { toast: false },\n        );\n        if (err) {\n            guest();\n        } else {\n            const user = res;\n            action.setUser(user);\n\n            const linkmanIds = [\n                ...user.groups.map((g: Group) => g._id),\n                ...user.friends.map((f: Friend) => f._id),\n            ];\n            const [err2, linkmans] = await fetch('getLinkmansLastMessagesV2', {\n                linkmans: linkmanIds,\n            });\n            if (!err2) {\n                action.setLinkmansLastMessages(linkmans);\n            }\n        }\n    } else {\n        guest();\n    }\n});\nsocket.on('disconnect', () => {\n    dispatch({\n        type: ConnectActionType,\n        value: false,\n    } as ConnectAction);\n});\nsocket.on('message', (message: Message) => {\n    const state = store.getState() as State;\n    const linkman = state.linkmans.find((x) => x._id === message.to);\n    if (linkman) {\n        dispatch({\n            type: AddlinkmanMessageActionType,\n            linkmanId: message.to,\n            message,\n        } as AddlinkmanMessageAction);\n    } else {\n        const newLinkman: Temporary = {\n            _id: getFriendId((state.user as User)._id, message.from._id),\n            type: 'temporary',\n            createTime: Date.now(),\n            avatar: message.from.avatar,\n            name: message.from.username,\n            messages: [],\n            unread: 1,\n        };\n        dispatch({\n            type: AddLinkmanActionType,\n            linkman: newLinkman,\n            focus: false,\n        } as AddLinkmanAction);\n\n        fetch('getLinkmanHistoryMessages', {\n            linkmanId: newLinkman._id,\n            existCount: 0,\n        }).then(([err, res]) => {\n            if (!err) {\n                dispatch({\n                    type: AddLinkmanHistoryMessagesActionType,\n                    linkmanId: newLinkman._id,\n                    messages: res,\n                } as AddLinkmanHistoryMessagesAction);\n            }\n        });\n    }\n});\n\nsocket.on(\n    'changeGroupName',\n    ({ groupId, name }: { groupId: string; name: string }) => {\n        dispatch({\n            type: UpdateGroupPropertyActionType,\n            groupId,\n            key: 'name',\n            value: name,\n        } as UpdateGroupPropertyAction);\n    },\n);\n\nsocket.on('deleteGroup', ({ groupId }: { groupId: string }) => {\n    dispatch({\n        type: RemoveLinkmanActionType,\n        linkmanId: groupId,\n    } as RemoveLinkmanAction);\n});\n\nsocket.on('changeTag', (tag: string) => {\n    dispatch({\n        type: UpdateUserPropertyActionType,\n        key: 'tag',\n        value: tag,\n    } as UpdateUserPropertyAction);\n});\n\nsocket.on(\n    'deleteMessage',\n    ({ linkmanId, messageId }: { linkmanId: string; messageId: string }) => {\n        dispatch({\n            type: DeleteLinkmanMessageActionType,\n            linkmanId,\n            messageId,\n        } as DeleteLinkmanMessageAction);\n    },\n);\n\nsocket.connect();\n\nexport default socket;\n"
  },
  {
    "path": "packages/app/src/state/action.ts",
    "content": "import getFriendId from '../utils/getFriendId';\nimport store from './store';\nimport {\n    ConnectActionType,\n    ConnectAction,\n    Friend,\n    SetUserActionType,\n    SetUserAction,\n    SetLinkmanMessagesAction,\n    SetGuestActionType,\n    SetGuestAction,\n    LogoutActionType,\n    UpdateUserPropertyActionType,\n    UpdateUserPropertyAction,\n    AddlinkmanMessageActionType,\n    AddlinkmanMessageAction,\n    AddLinkmanHistoryMessagesActionType,\n    AddLinkmanHistoryMessagesAction,\n    UpdateSelfMessageActionType,\n    UpdateSelfMessageAction,\n    SetFocusAction,\n    AddLinkmanAction,\n    RemoveLinkmanActionType,\n    RemoveLinkmanAction,\n    SetFriendAction,\n    UpdateUIPropertyActionType,\n    UpdateUIPropertyAction,\n    Group,\n    Message,\n    Linkman,\n    UpdateGroupPropertyActionType,\n    UpdateGroupPropertyAction,\n    UpdateFriendPropertyActionType,\n    UpdateFriendPropertyAction,\n    DeleteLinkmanMessageAction,\n    DeleteLinkmanMessageActionType,\n} from '../types/redux';\n\nconst { dispatch } = store;\n\nfunction connect() {\n    dispatch({\n        type: ConnectActionType,\n        value: true,\n    } as ConnectAction);\n}\nfunction disconnect() {\n    dispatch({\n        type: ConnectActionType,\n        value: false,\n    } as ConnectAction);\n}\n\nfunction setUser(user: any) {\n    user.groups.forEach((group: Group) => {\n        Object.assign(group, {\n            type: 'group',\n            unread: 0,\n            messages: [],\n            members: [],\n        });\n    });\n    user.friends.forEach((friend: Friend) => {\n        Object.assign(friend, {\n            type: 'friend',\n            _id: getFriendId(friend.from, friend.to._id),\n            messages: [],\n            unread: 0,\n            avatar: friend.to.avatar,\n            name: friend.to.username,\n            to: friend.to._id,\n        });\n    });\n\n    const linkmans = [...user.groups, ...user.friends];\n    dispatch({\n        type: SetUserActionType,\n        user: {\n            ...user,\n            groups: null,\n            friends: null,\n        },\n        linkmans,\n    } as SetUserAction);\n}\nfunction setLinkmansLastMessages(\n    linkmans: SetLinkmanMessagesAction['linkmans'],\n) {\n    dispatch({\n        type: 'SetLinkmanMessages',\n        linkmans,\n    } as SetLinkmanMessagesAction);\n}\nfunction setGuest(defaultGroup: Group) {\n    dispatch({\n        type: SetGuestActionType,\n        linkmans: [\n            Object.assign(defaultGroup, {\n                type: 'group',\n                unread: 0,\n                members: [],\n            }),\n        ],\n    } as SetGuestAction);\n}\nfunction logout() {\n    dispatch({\n        type: LogoutActionType,\n    });\n}\nfunction setAvatar(avatar: string) {\n    dispatch({\n        type: UpdateUserPropertyActionType,\n        key: 'avatar',\n        value: avatar,\n    } as UpdateUserPropertyAction);\n}\nfunction updateUserProperty(key: string, value: any) {\n    dispatch({\n        type: UpdateUserPropertyActionType,\n        key,\n        value,\n    } as UpdateUserPropertyAction);\n}\n\nfunction addLinkmanMessage(linkmanId: string, message: Message) {\n    dispatch({\n        type: AddlinkmanMessageActionType,\n        linkmanId,\n        message,\n    } as AddlinkmanMessageAction);\n}\n\nfunction deleteLinkmanMessage(linkmanId: string, messageId: string) {\n    dispatch({\n        type: DeleteLinkmanMessageActionType,\n        linkmanId,\n        messageId,\n    } as DeleteLinkmanMessageAction);\n}\n\nfunction addLinkmanHistoryMessages(linkmanId: string, messages: Message[]) {\n    dispatch({\n        type: AddLinkmanHistoryMessagesActionType,\n        linkmanId,\n        messages,\n    } as AddLinkmanHistoryMessagesAction);\n}\nfunction updateSelfMessage(\n    linkmanId: string,\n    messageId: string,\n    message: Message,\n) {\n    dispatch({\n        type: UpdateSelfMessageActionType,\n        linkmanId,\n        messageId,\n        message,\n    } as UpdateSelfMessageAction);\n}\n\nfunction setFocus(linkmanId: string) {\n    dispatch({\n        type: 'SetFocus',\n        linkmanId,\n    } as SetFocusAction);\n}\nfunction setGroupMembers(groupId: string, members: Group['members']) {\n    dispatch({\n        type: UpdateGroupPropertyActionType,\n        groupId,\n        key: 'members',\n        value: members,\n    } as UpdateGroupPropertyAction);\n}\nfunction setGroupAvatar(groupId: string, avatar: string) {\n    dispatch({\n        type: UpdateGroupPropertyActionType,\n        groupId,\n        key: 'avatar',\n        value: avatar,\n    } as UpdateGroupPropertyAction);\n}\nfunction updateGroupProperty(groupId: string, key: string, value: any) {\n    dispatch({\n        type: UpdateGroupPropertyActionType,\n        groupId,\n        key,\n        value,\n    } as UpdateGroupPropertyAction);\n}\nfunction updateFriendProperty(userId: string, key: string, value: any) {\n    dispatch({\n        type: UpdateFriendPropertyActionType,\n        userId,\n        key,\n        value,\n    } as UpdateFriendPropertyAction);\n}\nfunction addLinkman(linkman: Linkman, focus = false) {\n    if (linkman.type === 'group') {\n        linkman.members = [];\n        linkman.messages = [];\n        linkman.unread = 0;\n    }\n    dispatch({\n        type: 'AddLinkman',\n        linkman,\n        focus,\n    } as AddLinkmanAction);\n}\nfunction removeLinkman(linkmanId: string) {\n    dispatch({\n        type: RemoveLinkmanActionType,\n        linkmanId,\n    } as RemoveLinkmanAction);\n}\nfunction setFriend(linkmanId: string, from: Friend['from'], to: Friend['to']) {\n    dispatch({\n        type: 'SetFriend',\n        linkmanId,\n        from,\n        to,\n    } as SetFriendAction);\n}\n\nfunction loading(text: string) {\n    dispatch({\n        type: UpdateUIPropertyActionType,\n        key: 'loading',\n        value: text,\n    } as UpdateUIPropertyAction);\n}\n\nexport default {\n    setUser,\n    setGuest,\n    connect,\n    disconnect,\n    logout,\n    setAvatar,\n    updateUserProperty,\n    setLinkmansLastMessages,\n\n    setFocus,\n    setGroupMembers,\n    setGroupAvatar,\n    addLinkman,\n    removeLinkman,\n    setFriend,\n    updateGroupProperty,\n    updateFriendProperty,\n\n    addLinkmanMessage,\n    addLinkmanHistoryMessages,\n    updateSelfMessage,\n    deleteLinkmanMessage,\n\n    loading,\n};\n"
  },
  {
    "path": "packages/app/src/state/reducer.ts",
    "content": "import produce from 'immer';\nimport deepmerge from 'deepmerge';\nimport {\n    State,\n    ActionTypes,\n    ConnectActionType,\n    LogoutActionType,\n    SetUserActionType,\n    SetGuestActionType,\n    UpdateUserPropertyActionType,\n    SetLinkmanMessagesActionType,\n    SetFocusActionType,\n    SetFriendActionType,\n    Friend,\n    AddLinkmanActionType,\n    RemoveLinkmanActionType,\n    AddlinkmanMessageActionType,\n    AddLinkmanHistoryMessagesActionType,\n    UpdateSelfMessageActionType,\n    UpdateUIPropertyActionType,\n    Group,\n    UpdateGroupPropertyActionType,\n    UpdateFriendPropertyActionType,\n    DeleteLinkmanMessageActionType,\n    User,\n    Linkman,\n} from '../types/redux';\nimport convertMessage from '../utils/convertMessage';\n\nexport function mergeLinkmans(\n    linkmans1: Linkman[],\n    linkmans2: Linkman[],\n): Linkman[] {\n    const linkmansMap2 = linkmans2.reduce(\n        (map: { [key: string]: Linkman }, linkman) => {\n            map[linkman._id] = linkman;\n            return map;\n        },\n        {},\n    );\n    const unionListingsIdSet = new Set(\n        linkmans1\n            .map((linkman) => linkman._id)\n            .filter((linkmanId) => !!linkmansMap2[linkmanId]),\n    );\n\n    const linkmans = [\n        ...linkmans1.filter((linkman) => unionListingsIdSet.has(linkman._id)),\n        ...linkmans2.filter((linkman) => !unionListingsIdSet.has(linkman._id)),\n    ];\n    return linkmans.map((linkman) => {\n        if (unionListingsIdSet.has(linkman._id)) {\n            return deepmerge(linkman as any, linkmansMap2[linkman._id] as any, {\n                customMerge: (key) => {\n                    if (key === 'messages') {\n                        // The new linkman data at this time does not have messages\n                        // So keep the old messages\n                        return () => linkman.messages;\n                    }\n                },\n            });\n        }\n        return linkman;\n    });\n}\n\nconst initialState = {\n    linkmans: [],\n    focus: '',\n    connect: true,\n    ui: {\n        ready: false,\n        loading: '', // 全局loading文本内容, 为空则不展示\n        primaryColor: '5,159,149',\n        primaryTextColor: '255, 255, 255',\n    },\n};\n\nconst reducer = produce((state: State = initialState, action: ActionTypes) => {\n    switch (action.type) {\n        case ConnectActionType: {\n            state.connect = action.value;\n            return state;\n        }\n        case LogoutActionType: {\n            return initialState;\n        }\n        case SetUserActionType: {\n            const currentUserId = (state.user as User)?._id;\n            if (!currentUserId || currentUserId !== action.user._id) {\n                // No user or guest user or different user\n                state.user = action.user;\n                state.linkmans = action.linkmans;\n            } else {\n                // Same user. Deep merge to reserve history messages;\n                // But these history messages must be overwritten in SetLinkmanMessagesAction\n                // Otherwise, there may be errors when fetch history messages later\n                state.user = action.user;\n                state.linkmans = mergeLinkmans(state.linkmans, action.linkmans);\n            }\n            return state;\n        }\n        case SetGuestActionType: {\n            action.linkmans.forEach((linkman) => {\n                linkman.messages.forEach(convertMessage);\n            });\n            state.linkmans = action.linkmans;\n            return state;\n        }\n        case UpdateUserPropertyActionType: {\n            // @ts-ignore\n            state!.user[action.key] = action.value;\n            return state;\n        }\n        case SetLinkmanMessagesActionType: {\n            state.linkmans = state.linkmans.map((linkman) => ({\n                ...linkman,\n                ...(action.linkmans[linkman._id]\n                    ? {\n                        messages: action.linkmans[linkman._id].messages.map(convertMessage),\n                        unread: action.linkmans[linkman._id].unread,\n                    }\n                    : {}),\n            })) as Linkman[];\n            state.linkmans.sort((linkman1, linkman2) => {\n                const lastMessageTime1 =\n                    linkman1.messages.length > 0\n                        ? linkman1.messages[linkman1.messages.length - 1]\n                            .createTime\n                        : linkman1.createTime;\n                const lastMessageTime2 =\n                    linkman2.messages.length > 0\n                        ? linkman2.messages[linkman2.messages.length - 1]\n                            .createTime\n                        : linkman2.createTime;\n                return new Date(lastMessageTime1) < new Date(lastMessageTime2)\n                    ? 1\n                    : -1;\n            });\n            if (\n                !state.focus ||\n                !state.linkmans.find((linkman) => linkman._id === state.focus)\n            ) {\n                state.focus =\n                    state.linkmans.length > 0 ? state.linkmans[0]._id : '';\n            }\n            return state;\n        }\n        case UpdateGroupPropertyActionType: {\n            const group = state.linkmans.find(\n                (linkman) =>\n                    linkman.type === 'group' && linkman._id === action.groupId,\n            ) as Group;\n            if (group) {\n                // @ts-ignore\n                group[action.key] = action.value;\n            }\n            return state;\n        }\n        case UpdateFriendPropertyActionType: {\n            const friend = state.linkmans.find(\n                (linkman) =>\n                    linkman.type !== 'group' && linkman._id === action.userId,\n            ) as Friend;\n            if (friend) {\n                // @ts-ignore\n                friend[action.key] = action.value;\n            }\n            return state;\n        }\n        case SetFocusActionType: {\n            const targetLinkman = state.linkmans.find(\n                (linkman) => linkman._id === action.linkmanId,\n            );\n            if (targetLinkman) {\n                state.focus = action.linkmanId;\n                targetLinkman.unread = 0;\n            }\n            return state;\n        }\n        case SetFriendActionType: {\n            const friend = state.linkmans.find(\n                (linkman) => linkman._id === action.linkmanId,\n            ) as Friend;\n            if (friend) {\n                friend.type = 'friend';\n                friend.from = action.from;\n                friend.to = action.to;\n                friend.unread = 0;\n                state.focus = action.linkmanId;\n            }\n            return state;\n        }\n        case AddLinkmanActionType: {\n            state.linkmans.unshift(action.linkman);\n            if (action.focus) {\n                state.focus = action.linkman._id;\n            }\n            return state;\n        }\n        case RemoveLinkmanActionType: {\n            const index = state.linkmans.findIndex(\n                (linkman) => linkman._id === action.linkmanId,\n            );\n            if (index !== -1) {\n                state.linkmans.splice(index, 1);\n                if (state.focus === action.linkmanId) {\n                    state.focus =\n                        state.linkmans.length > 0 ? state.linkmans[0]._id : '';\n                }\n            }\n            return state;\n        }\n        case AddlinkmanMessageActionType: {\n            const targetLinkman = state.linkmans.find(\n                (linkman) => linkman._id === action.linkmanId,\n            );\n            if (targetLinkman) {\n                if (state.focus !== targetLinkman._id) {\n                    targetLinkman.unread += 1;\n                }\n                targetLinkman.messages.push(convertMessage(action.message));\n                if (targetLinkman.messages.length > 500) {\n                    targetLinkman.messages.slice(250);\n                }\n            }\n            return state;\n        }\n        case AddLinkmanHistoryMessagesActionType: {\n            const targetLinkman = state.linkmans.find(\n                (linkman) => linkman._id === action.linkmanId,\n            );\n            if (targetLinkman) {\n                targetLinkman.messages.unshift(\n                    ...action.messages.map(convertMessage),\n                );\n            }\n            return state;\n        }\n        case UpdateSelfMessageActionType: {\n            const targetLinkman = state.linkmans.find(\n                (linkman) => linkman._id === action.linkmanId,\n            );\n            if (targetLinkman) {\n                const targetMessage = targetLinkman.messages.find(\n                    (message) => message._id === action.messageId,\n                );\n                if (targetMessage) {\n                    Object.assign(\n                        targetMessage,\n                        convertMessage(action.message),\n                    );\n                }\n            }\n            return state;\n        }\n        case DeleteLinkmanMessageActionType: {\n            const targetLinkman = state.linkmans.find(\n                (linkman) => linkman._id === action.linkmanId,\n            );\n            if (targetLinkman) {\n                const targetMessage = targetLinkman.messages.find(\n                    (message) => message._id === action.messageId,\n                );\n                if (targetMessage) {\n                    targetMessage.deleted = true;\n                    convertMessage(targetMessage);\n                }\n            }\n            return state;\n        }\n        case UpdateUIPropertyActionType: {\n            state.ui[action.key] = action.value;\n            return state;\n        }\n        default: {\n            return state;\n        }\n    }\n}, initialState);\n\nexport default reducer;\n"
  },
  {
    "path": "packages/app/src/state/store.ts",
    "content": "import { createStore } from 'redux';\nimport reducer from './reducer';\n\nconst store = createStore(\n    // @ts-ignore\n    reducer,\n    // @ts-ignore\n    window.__REDUX_DEVTOOLS_EXTENSION__ &&\n        window.__REDUX_DEVTOOLS_EXTENSION__(),\n);\nexport default store;\n"
  },
  {
    "path": "packages/app/src/types/global.d.ts",
    "content": "declare module '@react-native-toolkit/triangle';\ndeclare module 'react-native-dialog';\n\ndeclare module '*.png';\n"
  },
  {
    "path": "packages/app/src/types/redux.ts",
    "content": "export const ConnectActionType = 'SetConnect';\nexport type ConnectAction = {\n    type: typeof ConnectActionType;\n    value: boolean;\n};\n\nexport const SetUserActionType = 'SetUser';\nexport type SetUserAction = {\n    type: typeof SetUserActionType;\n    user: User;\n    linkmans: Linkman[];\n};\n\nexport const SetLinkmanMessagesActionType = 'SetLinkmanMessages';\nexport type SetLinkmanMessagesAction = {\n    type: typeof SetLinkmanMessagesActionType;\n    linkmans: Record<\n        string,\n        {\n            messages: Message[];\n            unread: number;\n        }\n    >;\n};\n\nexport const SetGuestActionType = 'SetGuest';\nexport type SetGuestAction = {\n    type: typeof SetGuestActionType;\n    linkmans: Linkman[];\n};\n\nexport const LogoutActionType = 'Logout';\nexport type LogoutAction = {\n    type: typeof LogoutActionType;\n};\n\nexport const UpdateUserPropertyActionType = 'UpdateUserProperty';\nexport type UpdateUserPropertyAction = {\n    type: typeof UpdateUserPropertyActionType;\n    key: keyof User;\n    value: any;\n};\n\nexport const AddlinkmanMessageActionType = 'AddlinkmanMessage';\nexport type AddlinkmanMessageAction = {\n    type: typeof AddlinkmanMessageActionType;\n    linkmanId: string;\n    message: Message;\n};\n\nexport const DeleteLinkmanMessageActionType = 'DeleteLinkmanMessage';\nexport type DeleteLinkmanMessageAction = {\n    type: typeof DeleteLinkmanMessageActionType;\n    linkmanId: string;\n    messageId: string;\n};\n\nexport const AddLinkmanHistoryMessagesActionType = 'AddLinkmanHistoryMessages';\nexport type AddLinkmanHistoryMessagesAction = {\n    type: typeof AddLinkmanHistoryMessagesActionType;\n    linkmanId: string;\n    messages: Message[];\n};\n\nexport const UpdateSelfMessageActionType = 'UpdateSelfMessageActionType';\nexport type UpdateSelfMessageAction = {\n    type: typeof UpdateSelfMessageActionType;\n    linkmanId: string;\n    messageId: string;\n    message: Message;\n};\n\nexport const SetFocusActionType = 'SetFocus';\nexport type SetFocusAction = {\n    type: typeof SetFocusActionType;\n    linkmanId: string;\n};\n\nexport const UpdateGroupPropertyActionType = 'UpdateGroupProperty';\nexport type UpdateGroupPropertyAction = {\n    type: typeof UpdateGroupPropertyActionType;\n    groupId: string;\n    key: keyof Group;\n    value: any;\n};\n\nexport const UpdateFriendPropertyActionType = 'UpdateFriendProperty';\nexport type UpdateFriendPropertyAction = {\n    type: typeof UpdateFriendPropertyActionType;\n    userId: string;\n    key: keyof Group;\n    value: any;\n};\n\nexport const AddLinkmanActionType = 'AddLinkman';\nexport type AddLinkmanAction = {\n    type: typeof AddLinkmanActionType;\n    linkman: Linkman;\n    focus: boolean;\n};\n\nexport const RemoveLinkmanActionType = 'RemoveLinkmanActionType';\nexport type RemoveLinkmanAction = {\n    type: typeof RemoveLinkmanActionType;\n    linkmanId: string;\n};\n\nexport const SetFriendActionType = 'SetFriend';\nexport type SetFriendAction = {\n    type: typeof SetFriendActionType;\n    linkmanId: string;\n    from: Friend['from'];\n    to: Friend['to'];\n};\n\nexport const UpdateUIPropertyActionType = 'UpdateUIPropertyActionType';\nexport type UpdateUIPropertyAction = {\n    type: typeof UpdateUIPropertyActionType;\n    key: keyof State['ui'];\n    value: any;\n};\n\nexport type ActionTypes =\n    | ConnectAction\n    | SetUserAction\n    | SetLinkmanMessagesAction\n    | UpdateGroupPropertyAction\n    | SetGuestAction\n    | SetFocusAction\n    | SetFriendAction\n    | AddLinkmanAction\n    | RemoveLinkmanAction\n    | AddlinkmanMessageAction\n    | AddLinkmanHistoryMessagesAction\n    | DeleteLinkmanMessageAction\n    | UpdateSelfMessageAction\n    | UpdateUserPropertyAction\n    | UpdateUIPropertyAction\n    | UpdateFriendPropertyAction\n    | LogoutAction;\n\nexport type Message = {\n    _id: string;\n    type: string;\n    content: string;\n    createTime: number;\n    percent?: number;\n    loading?: boolean;\n    from: {\n        _id: string;\n        username: string;\n        avatar: string;\n        tag: string;\n        originUsername?: string;\n    };\n    to: string;\n    deleted?: boolean;\n};\n\nexport type Group = {\n    _id: string;\n    type: 'group';\n    name: string;\n    avatar: string;\n    messages: Message[];\n    unread: number;\n    members: {\n        _id: string;\n        user: {\n            _id: string;\n            username: string;\n            avatar: string;\n        };\n        os: string;\n        browser: string;\n        environment: string;\n    }[];\n    creator: string;\n    createTime: number;\n};\n\nexport type Friend = {\n    _id: string;\n    type: 'friend';\n    name: string;\n    avatar: string;\n    from: string;\n    to: {\n        _id: string;\n        avatar: string;\n        username: string;\n    };\n    messages: Message[];\n    unread: number;\n    createTime: number;\n    isOnline?: boolean;\n};\n\nexport type Temporary = {\n    _id: string;\n    type: 'temporary';\n    name: string;\n    avatar: string;\n    messages: Message[];\n    unread: number;\n    createTime: number;\n    isOnline?: boolean;\n};\n\nexport type Linkman = Group | Friend | Temporary;\n\nexport type User = {\n    _id: string;\n    username: string;\n    avatar: string;\n    tag: string;\n    isAdmin: boolean;\n    notificationTokens: string[];\n    createTime: number;\n};\n\nexport type State = {\n    user?: User;\n    linkmans: Linkman[];\n    focus: string;\n    connect: boolean;\n    ui: {\n        loading: string;\n        primaryColor: string;\n        primaryTextColor: string;\n    };\n};\n"
  },
  {
    "path": "packages/app/src/types/socket.ts",
    "content": "export type Socket = {\n    on: (event: string, callback: (...params: any) => void) => void;\n};\n"
  },
  {
    "path": "packages/app/src/utils/constant.ts",
    "content": "export const referer = 'https://fiora.suisuijiang.com/';\n"
  },
  {
    "path": "packages/app/src/utils/convertMessage.ts",
    "content": "// function convertRobot10Message(message) {\n//     if (message.from._id === '5adad39555703565e7903f79') {\n//         try {\n//             const parseMessage = JSON.parse(message.content);\n//             message.from.tag = parseMessage.source;\n//             message.from.avatar = parseMessage.avatar;\n//             message.from.username = parseMessage.username;\n//             message.type = parseMessage.type;\n//             message.content = parseMessage.content;\n//         } catch (err) {\n//             console.warn('解析robot10消息失败', err);\n//         }\n//     }\n// }\n\nimport { Message } from '../types/redux';\n\nconst WuZeiNiangImage = require('../assets/images/wuzeiniang.gif');\n\nfunction convertSystemMessage(message: Message) {\n    if (message.type === 'system') {\n        message.from._id = 'system';\n        message.from.originUsername = message.from.username;\n        message.from.username = '乌贼娘殿下';\n        message.from.avatar = WuZeiNiangImage;\n        message.from.tag = 'system';\n\n        let content = null;\n        try {\n            content = JSON.parse(message.content);\n        } catch {\n            content = {\n                command: 'parse-error',\n            };\n        }\n        switch (content?.command) {\n            case 'roll': {\n                message.content = `掷出了${content.value}点 (上限${content.top}点)`;\n                break;\n            }\n            case 'rps': {\n                message.content = `使出了 ${content.value}`;\n                break;\n            }\n            default: {\n                message.content = '不支持的指令';\n            }\n        }\n    } else if (message.deleted) {\n        message.type = 'system';\n        message.from._id = 'system';\n        message.from.originUsername = message.from.username;\n        message.from.username = '乌贼娘殿下';\n        message.from.avatar = WuZeiNiangImage;\n        message.from.tag = 'system';\n        message.content = `撤回了消息`;\n    }\n\n    return message;\n}\n\n/**\n * 处理文本消息的html转义字符\n * @param {Object} message 消息\n */\nfunction convertMessageHtml(message: Message) {\n    if (message.type === 'text') {\n        message.content = message.content\n            .replace(/&lt;/g, '<')\n            .replace(/&gt;/g, '>');\n    }\n    return message;\n}\n\nexport default function convertMessage(message: Message) {\n    convertSystemMessage(message);\n    convertMessageHtml(message);\n    return message;\n}\n"
  },
  {
    "path": "packages/app/src/utils/expressions.ts",
    "content": "export default {\n    default: [\n        '呵呵',\n        '哈哈',\n        '吐舌',\n        '啊',\n        '酷',\n        '怒',\n        '开心',\n        '汗',\n        '泪',\n        '黑线',\n        '鄙视',\n        '不高兴',\n        '真棒',\n        '钱',\n        '疑问',\n        '阴险',\n        '吐',\n        '咦',\n        '委屈',\n        '花心',\n        '呼',\n        '笑眼',\n        '冷',\n        '太开心',\n        '滑稽',\n        '勉强',\n        '狂汗',\n        '乖',\n        '睡觉',\n        '惊哭',\n        '升起',\n        '惊讶',\n        '喷',\n        '爱心',\n        '心碎',\n        '玫瑰',\n        '礼物',\n        '星星月亮',\n        '太阳',\n        '音乐',\n        '灯泡',\n        '蛋糕',\n        '彩虹',\n        '钱币',\n        '咖啡',\n        'haha',\n        '胜利',\n        '大拇指',\n        '弱',\n        'ok',\n    ],\n};\n"
  },
  {
    "path": "packages/app/src/utils/fetch.ts",
    "content": "import Toast from '../components/Toast';\nimport socket from '../socket';\n\nexport default function fetch<T = any>(\n    event: string,\n    data: any = {},\n    { toast = true } = {},\n): Promise<[string | null, T | null]> {\n    return new Promise((resolve) => {\n        socket.emit(event, data, (res: any) => {\n            if (typeof res === 'string') {\n                if (toast) {\n                    Toast.danger(res);\n                }\n                resolve([res, null]);\n            } else {\n                resolve([null, res]);\n            }\n        });\n    });\n}\n"
  },
  {
    "path": "packages/app/src/utils/getFriendId.ts",
    "content": "export default function getFriendId(userId1: string, userId2: string) {\n    if (userId1 < userId2) {\n        return userId1 + userId2;\n    }\n    return userId2 + userId1;\n}\n"
  },
  {
    "path": "packages/app/src/utils/getRandomColor.ts",
    "content": "import randomColor from 'randomcolor';\n\ntype ColorMode = 'dark' | 'bright' | 'light' | 'random';\n\n/**\n * 获取随机颜色, 刷新页面不变\n * @param seed when passed will cause randomColor to return the same color each time\n */\nexport function getRandomColor(seed: string, luminosity: ColorMode = 'dark') {\n    return randomColor({\n        luminosity,\n        seed,\n    });\n}\n\ntype Cache = {\n    [key: string]: string;\n};\n\nconst cache: Cache = {};\n\n/**\n * 获取随机颜色, 刷新页面后重新随机\n * @param seed 随机种子\n * @param luminosity 亮度\n */\nexport function getPerRandomColor(\n    seed: string,\n    luminosity: ColorMode = 'dark',\n) {\n    if (cache[seed]) {\n        return cache[seed];\n    }\n    cache[seed] = randomColor({ luminosity });\n    return cache[seed];\n}\n"
  },
  {
    "path": "packages/app/src/utils/linkman.ts",
    "content": "import { Friend, Group, Linkman } from '../types/redux';\n\nexport function formatLinkmanName(linkman: Linkman) {\n    if (linkman!.type === 'group' && (linkman as Group).members.length > 0) {\n        return `${(linkman as Group).name} (${\n            (linkman as Group).members.length\n        })`;\n    }\n    if (\n        linkman!.type !== 'group' &&\n        (linkman as Friend).isOnline !== undefined\n    ) {\n        return `${(linkman as Friend).name} (${\n            (linkman as Friend).isOnline ? '在线' : '离线'\n        })`;\n    }\n    return linkman.name;\n}\n"
  },
  {
    "path": "packages/app/src/utils/platform.ts",
    "content": "import { Platform } from 'react-native';\nimport Constants from 'expo-constants';\n// eslint-disable-next-line import/extensions\nimport packageInfo from '../../app.json';\n\nconst os = Platform.OS === 'ios' ? 'iOS' : 'Android';\n\nexport const isiOS = Platform.OS === 'ios';\nexport const isAndroid = Platform.OS === 'android';\n\nexport default {\n    os,\n    browser: 'App',\n    environment: `App ${\n        process.env.NODE_ENV === 'development'\n            ? '开发版'\n            : packageInfo.expo.version\n    } on ${os} ${\n        isiOS ? Constants.platform?.ios?.systemVersion : Constants.systemVersion\n    } ${isiOS ? Constants.platform?.ios?.model : ''}`,\n};\n"
  },
  {
    "path": "packages/app/src/utils/storage.ts",
    "content": "import AsyncStorage from '@react-native-async-storage/async-storage';\n\nexport async function getStorageValue(key: string) {\n    return AsyncStorage.getItem(key);\n}\n\nexport async function setStorageValue(key: string, value: string) {\n    return AsyncStorage.setItem(key, value);\n}\n\nexport async function removeStorageValue(key: string) {\n    return AsyncStorage.removeItem(key);\n}\n"
  },
  {
    "path": "packages/app/src/utils/time.ts",
    "content": "export default {\n    isToday(time1: Date, time2: Date) {\n        return (\n            time1.getFullYear() === time2.getFullYear() &&\n            time1.getMonth() === time2.getMonth() &&\n            time1.getDate() === time2.getDate()\n        );\n    },\n    isYesterday(time1: Date, time2: Date) {\n        const prevDate = new Date(time1);\n        prevDate.setDate(time1.getDate() - 1);\n        return (\n            prevDate.getFullYear() === time2.getFullYear() &&\n            prevDate.getMonth() === time2.getMonth() &&\n            prevDate.getDate() === time2.getDate()\n        );\n    },\n    isSameYear(time1: Date, time2: Date) {\n        return time1.getFullYear() === time2.getFullYear();\n    },\n    getHourMinute(time: Date) {\n        const hours = time.getHours();\n        const minutes = time.getMinutes();\n        return `${hours < 10 ? `0${hours}` : hours}:${\n            minutes < 10 ? `0${minutes}` : minutes\n        }`;\n    },\n    getMonthDate(time: Date) {\n        return `${time.getMonth() + 1}/${time.getDate()}`;\n    },\n    getYearMonthDate(time: Date) {\n        return `${time.getFullYear()}/${time.getMonth() + 1}/${time.getDate()}`;\n    },\n};\n"
  },
  {
    "path": "packages/app/src/utils/uploadFile.ts",
    "content": "import fetch from './fetch';\n\n/**\n * 上传文件\n * @param blob 文件blob数据\n * @param fileName 文件名\n */\nexport default async function uploadFile(\n    blob: Blob | string,\n    fileName: string,\n    isBase64 = false,\n): Promise<string> {\n    const [uploadErr, result] = await fetch('uploadFile', {\n        file: blob,\n        fileName,\n        isBase64,\n    });\n    if (uploadErr) {\n        throw Error(`上传图片失败::${uploadErr}`);\n    }\n    return result.url;\n}\n\nexport function getOSSFileUrl(url: string | number = '', process = '') {\n    if (typeof url === 'number') {\n        return url;\n    }\n    const [rawUrl = '', extraPrams = ''] = url.split('?');\n    if (/^\\/\\/cdn\\.suisuijiang\\.com/.test(rawUrl)) {\n        return `https:${rawUrl}?x-oss-process=${process}${\n            extraPrams ? `&${extraPrams}` : ''\n        }`;\n    }\n    if (url.startsWith('//')) {\n        return `https:${url}`;\n    }\n    if (url.startsWith('/')) {\n        return `https://fiora.suisuijiang.com${url}`;\n    }\n    return `${url}`;\n}\n"
  },
  {
    "path": "packages/app/tests/state/reducer.test.ts",
    "content": "import { mergeLinkmans } from '../../src/state/reducer';\nimport { Linkman } from '../../src/types/redux';\n\ndescribe('mergeLinkmans', () => {\n    it('should return linkmans which is newly and reserve history messages', () => {\n        const linkmans1 = [\n            {\n                _id: 'l1',\n                name: 'l1',\n                messages: [],\n            },\n            {\n                _id: 'l2',\n                name: 'l2',\n                messages: [\n                    {\n                        _id: 'm1',\n                    },\n                    {\n                        _id: 'm2',\n                    },\n                ],\n            },\n        ];\n        const linkmans2 = [\n            {\n                _id: 'l2',\n                name: 'l2',\n                messages: [\n                    {\n                        _id: 'm1',\n                    },\n                    {\n                        _id: 'm2',\n                    },\n                ],\n            },\n            {\n                _id: 'l3',\n                name: 'l3',\n                messages: [],\n            },\n        ];\n\n        const linkmans = mergeLinkmans(\n            linkmans1 as any,\n            linkmans2 as any,\n        ) as Linkman[];\n        expect(linkmans).toHaveLength(2);\n        expect(linkmans[0]._id).toBe('l2');\n        expect(linkmans[1]._id).toBe('l3');\n\n        expect(linkmans[0].messages).toHaveLength(2);\n    });\n});\n"
  },
  {
    "path": "packages/app/tsconfig.json",
    "content": "{\n  \"extends\": \"../../tsconfig\",\n}\n"
  },
  {
    "path": "packages/assets/package.json",
    "content": "{\n  \"name\": \"@fiora/assets\",\n  \"version\": \"1.0.0\",\n  \"license\": \"MIT\",\n  \"private\": true\n}\n"
  },
  {
    "path": "packages/bin/index.ts",
    "content": "import chalk from 'chalk';\nimport path from 'path';\nimport fs from 'fs';\n\nconst script = process.argv[2];\nif (!script) {\n    console.log(chalk.green('没有任何事发生~'));\n    process.exit(0);\n}\n\nconst file = path.resolve(__dirname, `scripts/${script}.ts`);\nif (!fs.existsSync(file)) {\n    console.log(chalk.red(`[${script}] 脚本不存在`));\n}\n\n// @ts-ignore\nimport(file).then((module) => {\n    module.default();\n});\n"
  },
  {
    "path": "packages/bin/package.json",
    "content": "{\n  \"name\": \"@fiora/bin\",\n  \"version\": \"1.0.0\",\n  \"license\": \"MIT\",\n  \"private\": true,\n  \"scripts\": {\n    \"script\": \"ts-node --transpile-only index.ts\"\n  },\n  \"dependencies\": {\n    \"@fiora/config\": \"^1.0.0\",\n    \"@fiora/database\": \"^1.0.0\",\n    \"bcryptjs\": \"^2.4.3\",\n    \"chalk\": \"^4.1.1\",\n    \"detect-port\": \"^1.3.0\",\n    \"inquirer\": \"^8.1.2\"\n  },\n  \"devDependencies\": {\n    \"@types/bcryptjs\": \"^2.4.2\",\n    \"@types/detect-port\": \"^1.3.1\",\n    \"@types/inquirer\": \"^7.3.3\"\n  }\n}\n"
  },
  {
    "path": "packages/bin/scripts/deleteMessages.ts",
    "content": "import path from 'path';\nimport fs from 'fs';\nimport inquirer from 'inquirer';\nimport { promisify } from 'util';\nimport chalk from 'chalk';\nimport initMongoDB from '@fiora/database/mongoose/initMongoDB';\nimport Message from '@fiora/database/mongoose/models/message';\nimport History from '@fiora/database/mongoose/models/history';\n\nexport async function deleteMessages() {\n    const shouldDeleteAllMessages = await inquirer.prompt({\n        type: 'confirm',\n        name: 'result',\n        message: 'Confirm to delete all messages?',\n    });\n    if (!shouldDeleteAllMessages.result) {\n        return;\n    }\n\n    await initMongoDB();\n\n    const deleteResult = await Message.deleteMany({});\n    console.log('Delete result:', deleteResult);\n\n    const deleteHistoryResult = await History.deleteMany({});\n    console.log('Delete history result:', deleteHistoryResult);\n\n    const shouldDeleteAllFiles = await inquirer.prompt({\n        type: 'confirm',\n        name: 'result',\n        message: 'Confirm to delete all message files(Except OSS files)?',\n    });\n    if (!shouldDeleteAllFiles.result) {\n        return;\n    }\n\n    const files = await promisify(fs.readdir)(\n        path.resolve(__dirname, '../../server/public/'),\n    );\n    const iamgesAndFiles = files.filter(\n        (filename) =>\n            filename.startsWith('ImageMessage_') ||\n            filename.startsWith('FileMessage_'),\n    );\n    const unlinkAsync = promisify(fs.unlink);\n    await Promise.all(\n        iamgesAndFiles.map((file) =>\n            unlinkAsync(path.resolve(__dirname, '../../server/public/', file)),\n        ),\n    );\n    console.log('Delete files:', chalk.green(iamgesAndFiles.length.toString()));\n    console.log(chalk.red(iamgesAndFiles.join('\\n')));\n\n    console.log(chalk.green('Successfully deleted all messages'));\n}\n\nasync function run() {\n    await deleteMessages();\n    process.exit(0);\n}\nexport default run;\n"
  },
  {
    "path": "packages/bin/scripts/deleteTodayRegisteredUsers.ts",
    "content": "/**\n * Delete users created today and their related data\n */\nimport chalk from 'chalk';\n\nimport inquirer from 'inquirer';\nimport initMongoDB from '@fiora/database/mongoose/initMongoDB';\nimport User from '@fiora/database/mongoose/models/user';\nimport { deleteUser } from './deleteUser';\n\nexport async function deleteTodayRegisteredUsers() {\n    await initMongoDB();\n\n    const now = new Date();\n    const time = new Date(\n        `${now.getFullYear()}-${now.getMonth() + 1}-${now.getDate()} 00:00:00`,\n    );\n    const users = await User.find({\n        createTime: {\n            $gte: time,\n        },\n    });\n    console.log(\n        `There are ${chalk.green(\n            users.length.toString(),\n        )} newly registered users today`,\n    );\n    if (users.length === 0) {\n        return;\n    }\n\n    const shouldDeleteUsers = await inquirer.prompt({\n        type: 'confirm',\n        name: 'result',\n        message: 'Confirm to delete these users?',\n    });\n    if (!shouldDeleteUsers.result) {\n        return;\n    }\n    await Promise.all(\n        users.map((user) => deleteUser(user._id.toString(), false)),\n    );\n\n    console.log(\n        chalk.green('Successfully deleted today’s newly registered users'),\n    );\n}\n\nasync function run() {\n    await deleteTodayRegisteredUsers();\n    process.exit(0);\n}\nexport default run;\n"
  },
  {
    "path": "packages/bin/scripts/deleteUser.ts",
    "content": "/* eslint-disable no-console */\nimport chalk from 'chalk';\n\nimport inquirer from 'inquirer';\nimport User from '@fiora/database/mongoose/models/user';\nimport Message from '@fiora/database/mongoose/models/message';\nimport Group, { GroupDocument } from '@fiora/database/mongoose/models/group';\nimport Friend from '@fiora/database/mongoose/models/friend';\nimport History from '@fiora/database/mongoose/models/history';\nimport initMongoDB, { mongoose } from '@fiora/database/mongoose/initMongoDB';\n\nexport async function deleteUser(userIdOrName: string, confirm = true) {\n    if (!userIdOrName) {\n        console.log(chalk.red('Wrong command, [userIdOrName] is missing.'));\n        return;\n    }\n\n    await initMongoDB();\n\n    try {\n        const user = await User.findOne(\n            mongoose.isValidObjectId(userIdOrName)\n                ? { _id: userIdOrName }\n                : { username: userIdOrName },\n        );\n        if (user) {\n            console.log(\n                'Found user:',\n                chalk.blue(user._id.toString()),\n                chalk.green(user.username),\n            );\n\n            if (confirm) {\n                const shouldDeleteUser = await inquirer.prompt({\n                    type: 'confirm',\n                    name: 'result',\n                    message: 'Confirm to delete user?',\n                });\n                if (!shouldDeleteUser.result) {\n                    return;\n                }\n            }\n\n            const messages = await Message.find({ from: user._id });\n            const deleteHistoryResult = await History.deleteMany({\n                message: {\n                    $in: messages.map((message) => message.id),\n                },\n            });\n            console.log('Delete history result:', deleteHistoryResult);\n\n            console.log(chalk.yellow('Delete messages created by this user'));\n            const deleteMessageResult = await Message.deleteMany({\n                from: user._id,\n            });\n            console.log('Delete result:', deleteMessageResult);\n\n            console.log(\n                chalk.yellow('Leave the group that the user has joined'),\n            );\n            const groups = await Group.find({\n                members: user._id,\n            });\n            // eslint-disable-next-line no-inner-declarations\n            async function leaveGroup(group: GroupDocument) {\n                if (!user) {\n                    return;\n                }\n                console.log('Leave', group.name);\n                const index = group.members.indexOf(user?._id);\n                group.members.splice(index, 1);\n                if (group.creator?.toString() === user?._id.toString()) {\n                    // @ts-ignore\n                    group.creator = null;\n                }\n                await group.save();\n            }\n            await Promise.all(groups.map(leaveGroup));\n\n            console.log(\n                chalk.yellow(\n                    'Delete the friend relationship related to this user',\n                ),\n            );\n            const deleteFriendResult1 = await Friend.deleteMany({\n                from: user._id,\n            });\n            const deleteFriendResult2 = await Friend.deleteMany({\n                to: user._id,\n            });\n            console.log(\n                'Delete result:',\n                deleteFriendResult1,\n                deleteFriendResult2,\n            );\n\n            console.log(chalk.yellow('Delete this user'));\n            const deleteUserResult = await User.deleteMany({\n                _id: user._id,\n            });\n            console.log('Delete result:', deleteUserResult);\n\n            console.log(chalk.green('Successfully deleted user'));\n        } else {\n            console.log(chalk.red(`User [${userIdOrName}] does not exist`));\n        }\n    } catch (err) {\n        console.log(chalk.red('Failed to delete user!', err.message));\n    }\n}\n\nasync function run() {\n    const userIdOrName = process.argv[3];\n    await deleteUser(userIdOrName);\n    process.exit(0);\n}\nexport default run;\n"
  },
  {
    "path": "packages/bin/scripts/doctor.ts",
    "content": "import chalk from 'chalk';\nimport cp from 'child_process';\nimport fs from 'fs';\nimport path from 'path';\nimport detect from 'detect-port';\nimport server from '@fiora/config/server';\nimport initRedis from '@fiora/database/redis/initRedis';\nimport initMongoDB from '@fiora/database/mongoose/initMongoDB';\n\nexport async function doctor() {\n    console.log(chalk.yellow('===== Run Fiora Doctor ====='));\n\n    const nodeVersion = cp.execSync('node --version').toString();\n    console.log(\n        chalk.green(`node ${nodeVersion.slice(0, nodeVersion.length - 1)}`),\n    );\n\n    await initMongoDB();\n    console.log(chalk.green('MongoDB is OK'));\n\n    await (async () =>\n        new Promise((resolve) => {\n            const redis = initRedis();\n            redis.on('connect', resolve);\n        }))();\n    console.log(chalk.green('Redis is OK'));\n\n    const avaliablePort = await detect(server.port);\n    if (avaliablePort === server.port) {\n        console.log(chalk.green(`Port [${server.port}] is OK`));\n    } else {\n        console.log(chalk.red(`Port [${server.port}] was occupied`));\n    }\n\n    const indexFilePath = path.resolve(\n        __dirname,\n        '../../server/public/index.html',\n    );\n    const indexFile = fs.readFileSync(indexFilePath);\n    if (!indexFile) {\n        console.log(chalk.red('Homepage not exists'));\n    } else if (indexFile.toString().includes('默认首页')) {\n        console.log(\n            chalk.red(\n                'Homepage is default. Please build web client by [yarn build:web]',\n            ),\n        );\n    } else {\n        console.log(chalk.green(`Homepage is OK`));\n    }\n}\n\nasync function run() {\n    await doctor();\n    process.exit(0);\n}\nexport default run;\n"
  },
  {
    "path": "packages/bin/scripts/fixUsersAvatar.ts",
    "content": "import chalk from 'chalk';\nimport inquirer from 'inquirer';\nimport User from '@fiora/database/mongoose/models/user';\nimport initMongoDB from '@fiora/database/mongoose/initMongoDB';\n\nexport async function fixUsersAvatar(\n    searchValue: string,\n    replaceValue: string,\n) {\n    searchValue = searchValue || 'fioraavatar';\n    replaceValue = replaceValue || 'fiora/avatar';\n\n    await initMongoDB();\n\n    const users = await User.find({ avatar: { $regex: 'fioraavatar' } });\n    if (users.length) {\n        console.log(chalk.red('Oh No!'), \"Some user's avatar is wrong\");\n        users.forEach((user) => {\n            console.log(user._id, user.username, user.avatar);\n        });\n\n        const shouldFix = await inquirer.prompt({\n            type: 'confirm',\n            name: 'result',\n            message: 'Confirm to fix?',\n        });\n        if (shouldFix.result) {\n            await Promise.all(\n                users.map((user) => {\n                    user.avatar = user.avatar.replace(\n                        searchValue,\n                        replaceValue,\n                    );\n                    return user.save();\n                }),\n            );\n            console.log(chalk.green('Congratulations! Fixed!'));\n        }\n    } else {\n        console.log(chalk.green('OK!'), \"All user's avatar is corrent\");\n    }\n}\n\nasync function run() {\n    const searchValue = process.argv[3];\n    const replaceValue = process.argv[4];\n    await fixUsersAvatar(searchValue, replaceValue);\n    process.exit(0);\n}\nexport default run;\n"
  },
  {
    "path": "packages/bin/scripts/getUserId.ts",
    "content": "import chalk from 'chalk';\nimport User from '@fiora/database/mongoose/models/user';\nimport initMongoDB from '@fiora/database/mongoose/initMongoDB';\n\nexport async function getUserId(username: string) {\n    if (!username) {\n        console.log(chalk.red('Wrong command, [username] is missing.'));\n        return;\n    }\n\n    await initMongoDB();\n\n    const user = await User.findOne({ username });\n    if (!user) {\n        console.log(chalk.red(`User [${username}] does not exist`));\n    } else {\n        console.log(\n            `The userId of [${username}] is:`,\n            chalk.green(user._id.toString()),\n        );\n    }\n}\n\nasync function run() {\n    const username = process.argv[3];\n    await getUserId(username);\n    process.exit(0);\n}\nexport default run;\n"
  },
  {
    "path": "packages/bin/scripts/register.ts",
    "content": "/**\n * Register\n */\n\nimport bcrypt from 'bcryptjs';\nimport chalk from 'chalk';\n\nimport initMongoDB from '@fiora/database/mongoose/initMongoDB';\nimport User, { UserDocument } from '../../database/mongoose/models/user';\nimport Group from '../../database/mongoose/models/group';\n\nimport { SALT_ROUNDS } from '../../utils/const';\nimport getRandomAvatar from '../../utils/getRandomAvatar';\n\nexport async function register(username: string, password: string) {\n    if (!username) {\n        console.log(chalk.red('Wrong command, [username] is missing.'));\n        return;\n    }\n    if (!password) {\n        console.log(chalk.red('Wrong command, [password] is missing.'));\n        return;\n    }\n\n    await initMongoDB();\n\n    const user = await User.findOne({ username });\n    if (user) {\n        console.log(chalk.red('The username already exists'));\n        return;\n    }\n\n    const defaultGroup = await Group.findOne({ isDefault: true });\n    if (!defaultGroup) {\n        console.log(chalk.red('Default group does not exist'));\n        return;\n    }\n\n    const salt = await bcrypt.genSalt(SALT_ROUNDS);\n    const hash = await bcrypt.hash(password, salt);\n\n    let newUser = null;\n    try {\n        newUser = await User.create({\n            username,\n            salt,\n            password: hash,\n            avatar: getRandomAvatar(),\n        } as UserDocument);\n    } catch (createError) {\n        if (createError.name === 'ValidationError') {\n            console.log(\n                chalk.red(\n                    'Username contains unsupported characters or the length exceeds the limit',\n                ),\n            );\n            return;\n        }\n        console.log(chalk.red('Error:'), createError);\n        return;\n    }\n\n    if (!defaultGroup.creator) {\n        defaultGroup.creator = newUser._id;\n    }\n    if (newUser) {\n        defaultGroup.members.push(newUser._id);\n    }\n    await defaultGroup.save();\n\n    console.log(chalk.green(`Successfully created user [${username}]`));\n}\n\nasync function run() {\n    const username = process.argv[3];\n    const password = process.argv[4];\n    await register(username, password);\n    process.exit(0);\n}\nexport default run;\n"
  },
  {
    "path": "packages/bin/scripts/updateDefaultGroupName.ts",
    "content": "import chalk from 'chalk';\nimport initMongoDB from '@fiora/database/mongoose/initMongoDB';\nimport Group from '@fiora/database/mongoose/models/group';\n\nexport async function updateDefaultGroupName(newName: string) {\n    if (!newName) {\n        console.log(chalk.red('Wrong command, [newName] is missing.'));\n        return;\n    }\n\n    await initMongoDB();\n\n    const group = await Group.findOne({ isDefault: true });\n    if (!group) {\n        console.log(chalk.red('Default group does not exist'));\n    } else {\n        group.name = newName;\n        try {\n            await group.save();\n            console.log(chalk.green('Update default group name success!'));\n        } catch (err) {\n            console.log(\n                chalk.red('Update default group name fail!'),\n                err.message,\n            );\n        }\n    }\n}\n\nasync function run() {\n    const newName = process.argv[3];\n    await updateDefaultGroupName(newName);\n    process.exit(0);\n}\nexport default run;\n"
  },
  {
    "path": "packages/bin/tsconfig.json",
    "content": "{\n  \"extends\": \"../../tsconfig\",\n}"
  },
  {
    "path": "packages/config/client.ts",
    "content": "import { MB } from '../utils/const';\n\nexport default {\n    server:\n        process.env.Server ||\n        (process.env.NODE_ENV === 'development' ? '//localhost:9200' : '/'),\n\n    maxImageSize: process.env.MaxImageSize\n        ? parseInt(process.env.MaxImageSize, 10)\n        : MB * 5,\n    maxBackgroundImageSize: process.env.MaxBackgroundImageSize\n        ? parseInt(process.env.MaxBackgroundImageSize, 10)\n        : MB * 5,\n    maxAvatarSize: process.env.MaxAvatarSize\n        ? parseInt(process.env.MaxAvatarSize, 10)\n        : MB * 1.5,\n    maxFileSize: process.env.MaxFileSize\n        ? parseInt(process.env.MaxFileSize, 10)\n        : MB * 10,\n\n    // client default system setting\n    defaultTheme: process.env.DefaultTheme || 'cool',\n    sound: process.env.Sound || 'default',\n    tagColorMode: process.env.TagColorMode || 'fixedColor',\n\n    /**\n     * 前端监控: https://yueying.effirst.com/index\n     * 值为监控应用id, 为空则不启用监控\n     */\n    frontendMonitorAppId: process.env.FrontendMonitorAppId || '',\n\n    // 禁止用户撤回消息, 不包括管理员, 管理员始终能撤回任何消息\n    // 默认是禁止的\n    disableDeleteMessage: process.env.DisableDeleteMessage\n        ? process.env.DisableDeleteMessage === 'true'\n        : false,\n};\n"
  },
  {
    "path": "packages/config/package.json",
    "content": "{\n  \"name\": \"@fiora/config\",\n  \"version\": \"1.0.0\",\n  \"license\": \"MIT\",\n  \"private\": true,\n  \"dependencies\": {\n    \"ip\": \"^1.1.5\"\n  },\n  \"devDependencies\": {\n    \"@types/ip\": \"^1.1.0\"\n  }\n}\n"
  },
  {
    "path": "packages/config/server.ts",
    "content": "import ip from 'ip';\n\nconst { env } = process;\n\nexport default {\n    /** 服务端host, 默认为本机ip地址(可能会是局域网地址) */\n    host: env.Host || ip.address(),\n\n    // service port\n    port: env.Port ? parseInt(env.Port, 10) : 9200,\n\n    // mongodb address\n    database: env.Database || 'mongodb://localhost:27017/fiora',\n\n    redis: {\n        host: env.RedisHost || 'localhost',\n        port: env.RedisPort ? parseInt(env.RedisPort, 10) : 6379,\n    },\n\n    // jwt encryption secret\n    jwtSecret: env.JwtSecret || 'jwtSecret',\n\n    // Maximize the number of groups\n    maxGroupsCount: env.MaxGroupCount ? parseInt(env.MaxGroupCount, 10) : 3,\n\n    allowOrigin: env.AllowOrigin ? env.AllowOrigin.split(',') : null,\n\n    // token expires time\n    tokenExpiresTime: env.TokenExpiresTime\n        ? parseInt(env.TokenExpiresTime, 10)\n        : 1000 * 60 * 60 * 24 * 30,\n\n    // administrator user id\n    administrator: env.Administrator ? env.Administrator.split(',') : [],\n\n    /** 禁用注册功能 */\n    disableRegister: env.DisableRegister\n        ? env.DisableRegister === 'true'\n        : false,\n\n    /** disable user create new group */\n    disableCreateGroup: env.DisableCreateGroup\n        ? env.DisableCreateGroup === 'true'\n        : false,\n\n    /** Aliyun OSS */\n    aliyunOSS: {\n        enable: env.ALIYUN_OSS ? env.ALIYUN_OSS === 'true' : false,\n        accessKeyId: env.ACCESS_KEY_ID || '',\n        accessKeySecret: env.ACCESS_KEY_SECRET || '',\n        roleArn: env.ROLE_ARN || '',\n        region: env.REGION || '',\n        bucket: env.BUCKET || '',\n        endpoint: env.ENDPOINT || '',\n    },\n};\n"
  },
  {
    "path": "packages/database/mongoose/index.ts",
    "content": "export * from 'mongoose';\n"
  },
  {
    "path": "packages/database/mongoose/initMongoDB.ts",
    "content": "/**\n * 连接 MongoDB\n */\n\nimport mongoose from 'mongoose';\n\nimport config from '@fiora/config/server';\nimport logger from '@fiora/utils/logger';\n\nmongoose.Promise = Promise;\nmongoose.set('useCreateIndex', true);\n\nexport default function initMongoDB() {\n    return new Promise((resolve) => {\n        mongoose.connect(\n            config.database,\n            { useNewUrlParser: true, useUnifiedTopology: true },\n            async (err) => {\n                if (err) {\n                    logger.error('[mongoDB]', err.message);\n                    process.exit(0);\n                } else {\n                    resolve(null);\n                }\n            },\n        );\n    });\n}\n\nexport { mongoose };\n"
  },
  {
    "path": "packages/database/mongoose/models/friend.ts",
    "content": "import { Schema, model, Document } from 'mongoose';\n\nconst FriendSchema = new Schema({\n    createTime: { type: Date, default: Date.now },\n\n    from: {\n        type: Schema.Types.ObjectId,\n        ref: 'User',\n        index: true,\n    },\n    to: {\n        type: Schema.Types.ObjectId,\n        ref: 'User',\n    },\n});\n\nexport interface FriendDocument extends Document {\n    /** 源用户id */\n    from: string;\n    /** 目标用户id */\n    to: string;\n    /** 创建时间 */\n    createTime: Date;\n}\n\n/**\n * Friend Model\n * 好友信息\n * 好友关系是单向的\n */\nconst Friend = model<FriendDocument>('Friend', FriendSchema);\n\nexport default Friend;\n"
  },
  {
    "path": "packages/database/mongoose/models/group.ts",
    "content": "import { Schema, model, Document } from 'mongoose';\nimport { NAME_REGEXP } from '@fiora/utils/const';\n\nconst GroupSchema = new Schema({\n    createTime: { type: Date, default: Date.now },\n\n    name: {\n        type: String,\n        trim: true,\n        unique: true,\n        match: NAME_REGEXP,\n        index: true,\n    },\n    avatar: String,\n    announcement: {\n        type: String,\n        default: '',\n    },\n    creator: {\n        type: Schema.Types.ObjectId,\n        ref: 'User',\n    },\n    isDefault: {\n        type: Boolean,\n        default: false,\n    },\n    members: [\n        {\n            type: Schema.Types.ObjectId,\n            ref: 'User',\n        },\n    ],\n});\n\nexport interface GroupDocument extends Document {\n    /** 群组名 */\n    name: string;\n    /** 头像 */\n    avatar: string;\n    /** 公告 */\n    announcement: string;\n    /** 创建者 */\n    creator: string;\n    /** 是否为默认群组 */\n    isDefault: boolean;\n    /** 成员 */\n    members: string[];\n    /** 创建时间 */\n    createTime: Date;\n}\n\n/**\n * Group Model\n * 群组信息\n */\nconst Group = model<GroupDocument>('Group', GroupSchema);\n\nexport default Group;\n"
  },
  {
    "path": "packages/database/mongoose/models/history.ts",
    "content": "import { Schema, model, Document } from 'mongoose';\n\nconst HistoryScheme = new Schema({\n    user: {\n        type: String,\n        required: true,\n    },\n    linkman: {\n        type: String,\n        required: true,\n    },\n    message: {\n        type: String,\n        required: true,\n    },\n});\n\nexport interface HistoryDocument extends Document {\n    /** user id */\n    user: string;\n\n    /** linkman id */\n    linkman: string;\n\n    /** last readed message id */\n    message: string;\n}\n\nconst History = model<HistoryDocument>('History', HistoryScheme);\n\nexport default History;\n\nexport async function createOrUpdateHistory(\n    userId: string,\n    linkmanId: string,\n    messageId: string,\n) {\n    const history = await History.findOne({ user: userId, linkman: linkmanId });\n    if (history) {\n        history.message = messageId;\n        await history.save();\n    } else {\n        await History.create({\n            user: userId,\n            linkman: linkmanId,\n            message: messageId,\n        });\n    }\n    return {};\n}\n"
  },
  {
    "path": "packages/database/mongoose/models/message.ts",
    "content": "import { Schema, model, Document } from 'mongoose';\nimport Group from './group';\nimport User from './user';\n\nconst MessageSchema = new Schema({\n    createTime: { type: Date, default: Date.now, index: true },\n\n    from: {\n        type: Schema.Types.ObjectId,\n        ref: 'User',\n    },\n    to: {\n        type: String,\n        index: true,\n    },\n    type: {\n        type: String,\n        enum: ['text', 'image', 'file', 'code', 'inviteV2', 'system'],\n        default: 'text',\n    },\n    content: {\n        type: String,\n        default: '',\n    },\n    deleted: {\n        type: Boolean,\n        default: false,\n    },\n});\n\nexport interface MessageDocument extends Document {\n    /** 发送人 */\n    from: string;\n    /** 接受者, 发送给群时为群_id, 发送给个人时为俩人的_id按大小序拼接后值 */\n    to: string;\n    /** 类型, text: 文本消息, image: 图片消息, code: 代码消息, invite: 邀请加群消息, system: 系统消息 */\n    type: string;\n    /** 内容, 某些消息类型会存成JSON */\n    content: string;\n    /** 创建时间 */\n    createTime: Date;\n    /** Has it been deleted */\n    deleted: boolean;\n}\n\n/**\n * Message Model\n * 聊天消息\n */\nconst Message = model<MessageDocument>('Message', MessageSchema);\n\nexport default Message;\n\ninterface SendMessageData {\n    to: string;\n    type: string;\n    content: string;\n}\n\nexport async function handleInviteV2Message(message: SendMessageData) {\n    if (message.type === 'inviteV2') {\n        const inviteInfo = JSON.parse(message.content);\n        if (inviteInfo.inviter && inviteInfo.group) {\n            const [user, group] = await Promise.all([\n                User.findOne({ _id: inviteInfo.inviter }),\n                Group.findOne({ _id: inviteInfo.group }),\n            ]);\n            if (user && group) {\n                message.content = JSON.stringify({\n                    inviter: inviteInfo.inviter,\n                    inviterName: user?.username,\n                    group: inviteInfo.group,\n                    groupName: group.name,\n                });\n            }\n        }\n    }\n}\n\nexport async function handleInviteV2Messages(messages: SendMessageData[]) {\n    return Promise.all(\n        messages.map(async (message) => {\n            if (message.type === 'inviteV2') {\n                await handleInviteV2Message(message);\n            }\n        }),\n    );\n}\n"
  },
  {
    "path": "packages/database/mongoose/models/notification.ts",
    "content": "import { Schema, model, Document } from 'mongoose';\n\nconst NotificationSchema = new Schema({\n    createTime: { type: Date, default: Date.now },\n\n    user: {\n        type: Schema.Types.ObjectId,\n        ref: 'User',\n    },\n    token: {\n        type: String,\n        unique: true,\n    },\n});\n\nexport interface NotificationDocument extends Document {\n    user: any;\n    token: string;\n}\n\nconst Notification = model<NotificationDocument>(\n    'Notification',\n    NotificationSchema,\n);\n\nexport default Notification;\n"
  },
  {
    "path": "packages/database/mongoose/models/socket.ts",
    "content": "import { Schema, model, Document } from 'mongoose';\n\nconst SocketSchema = new Schema({\n    createTime: { type: Date, default: Date.now },\n\n    id: {\n        type: String,\n        unique: true,\n        index: true,\n    },\n    user: {\n        type: Schema.Types.ObjectId,\n        ref: 'User',\n    },\n    ip: String,\n    os: {\n        type: String,\n        default: '',\n    },\n    browser: {\n        type: String,\n        default: '',\n    },\n    environment: {\n        type: String,\n        default: '',\n    },\n});\n\nexport interface SocketDocument extends Document {\n    /** socket连接id */\n    id: string;\n    /** 关联用户id */\n    user: any;\n    /** ip地址 */\n    ip: string;\n    /** 系统 */\n    os: string;\n    /** 浏览器 */\n    browser: string;\n    /** 详细环境信息 */\n    environment: string;\n    /** 创建时间 */\n    createTime: Date;\n}\n\n/**\n * Socket Model\n * 客户端socket连接信息\n */\nconst Socket = model<SocketDocument>('Socket', SocketSchema);\n\nexport default Socket;\n"
  },
  {
    "path": "packages/database/mongoose/models/user.ts",
    "content": "import { Schema, model, Document } from 'mongoose';\nimport { NAME_REGEXP } from '@fiora/utils/const';\n\nconst UserSchema = new Schema({\n    createTime: { type: Date, default: Date.now },\n    lastLoginTime: { type: Date, default: Date.now },\n\n    username: {\n        type: String,\n        trim: true,\n        unique: true,\n        match: NAME_REGEXP,\n        index: true,\n    },\n    salt: String,\n    password: String,\n    avatar: String,\n    tag: {\n        type: String,\n        default: '',\n        trim: true,\n        match: NAME_REGEXP,\n    },\n    expressions: [\n        {\n            type: String,\n        },\n    ],\n    lastLoginIp: String,\n});\n\nexport interface UserDocument extends Document {\n    /** 用户名 */\n    username: string;\n    /** 密码加密盐 */\n    salt: string;\n    /** 加密的密码 */\n    password: string;\n    /** 头像 */\n    avatar: string;\n    /** 用户标签 */\n    tag: string;\n    /** 表情收藏 */\n    expressions: string[];\n    /** 创建时间 */\n    createTime: Date;\n    /** 最后登录时间 */\n    lastLoginTime: Date;\n    /** 最后登录IP */\n    lastLoginIp: string;\n}\n\n/**\n * User Model\n * 用户信息\n */\nconst User = model<UserDocument>('User', UserSchema);\n\nexport default User;\n"
  },
  {
    "path": "packages/database/package.json",
    "content": "{\n  \"name\": \"@fiora/database\",\n  \"version\": \"1.0.0\",\n  \"license\": \"MIT\",\n  \"private\": true,\n  \"dependencies\": {\n    \"@fiora/config\": \"^1.0.0\",\n    \"@fiora/utils\": \"^1.0.0\",\n    \"mongoose\": \"^5.13.3\",\n    \"redis\": \"^3.1.2\"\n  },\n  \"devDependencies\": {\n    \"@types/mongoose\": \"^5.11.97\",\n    \"@types/redis\": \"^2.8.31\"\n  }\n}\n"
  },
  {
    "path": "packages/database/redis/initRedis.ts",
    "content": "import redis from 'redis';\nimport { promisify } from 'util';\nimport config from '@fiora/config/server';\nimport logger from '@fiora/utils/logger';\n\nexport default function initRedis() {\n    const client = redis.createClient({\n        ...config.redis,\n    });\n\n    client.on('error', (err) => {\n        logger.error('[redis]', err.message);\n        process.exit(0);\n    });\n\n    return client;\n}\n\nconst client = initRedis();\n\nexport const get = promisify(client.get).bind(client);\n\nexport const expire = promisify(client.expire).bind(client);\n\nexport async function set(key: string, value: string, expireTime = Infinity) {\n    await promisify(client.set).bind(client)(key, value);\n    if (expireTime !== Infinity) {\n        await expire(key, expireTime);\n    }\n}\n\nexport const keys = promisify(client.keys).bind(client);\n\nexport async function has(key: string) {\n    const v = await get(key);\n    return v !== null;\n}\n\nexport function getNewUserKey(userId: string) {\n    return `NewUser-${userId}`;\n}\n\nexport function getNewRegisteredUserIpKey(ip: string) {\n    // The value of v1 is ip\n    // The value of v2 is count number\n    return `NewRegisteredUserIpV2-${ip}`;\n}\n\nexport function getSealIpKey(ip: string) {\n    return `SealIp-${ip}`;\n}\n\nexport async function getAllSealIp() {\n    const allSealIpKeys = await keys('SealIp-*');\n    return allSealIpKeys.map((key) => key.replace('SealIp-', ''));\n}\n\nexport function getSealUserKey(user: string) {\n    return `SealUser-${user}`;\n}\n\nexport async function getAllSealUser() {\n    const allSealUserKeys = await keys('SealUser-*');\n    return allSealUserKeys.map((key) => key.split('-')[1]);\n}\n\nconst Minute = 60;\nconst Hour = Minute * 60;\nconst Day = Hour * 24;\n\nexport const Redis = {\n    get,\n    set,\n    has,\n    expire,\n    keys,\n    Minute,\n    Hour,\n    Day,\n};\n\nexport const DisableSendMessageKey = 'DisableSendMessage';\nexport const DisableNewUserSendMessageKey = 'DisableNewUserSendMessageKey';\n"
  },
  {
    "path": "packages/database/tsconfig.json",
    "content": "{\n  \"extends\": \"../../tsconfig\",\n}"
  },
  {
    "path": "packages/docs/.gitignore",
    "content": "# Dependencies\n/node_modules\n\n# Production\n/build\n\n# Generated files\n.docusaurus\n.cache-loader\n\n# Misc\n.DS_Store\n.env.local\n.env.development.local\n.env.test.local\n.env.production.local\n\nnpm-debug.log*\nyarn-debug.log*\nyarn-error.log*\n"
  },
  {
    "path": "packages/docs/babel.config.js",
    "content": "module.exports = {\n  presets: [require.resolve('@docusaurus/core/lib/babel/preset')],\n};\n"
  },
  {
    "path": "packages/docs/docs/API.md",
    "content": "---\nid: api\n---\n"
  },
  {
    "path": "packages/docs/docs/App.md",
    "content": "---\nid: app\n---\n"
  },
  {
    "path": "packages/docs/docs/CHANGELOG.md",
    "content": "---\nid: changelog\n---\n"
  },
  {
    "path": "packages/docs/docs/Config.md",
    "content": "---\nid: config\n---\n"
  },
  {
    "path": "packages/docs/docs/FAQ.md",
    "content": "---\nid: faq\n---\n"
  },
  {
    "path": "packages/docs/docs/Getting-Start.md",
    "content": "---\nid: getting-start\n---\n"
  },
  {
    "path": "packages/docs/docs/INSTALL.md",
    "content": "---\nid: install\n---\n"
  },
  {
    "path": "packages/docs/docs/Script.md",
    "content": "---\nid: script\n---\n"
  },
  {
    "path": "packages/docs/docusaurus.config.js",
    "content": "module.exports = {\n    title: 'fiora docs',\n    tagline: 'An interesting open source chat application',\n    url: 'https://fiora.suisuijiang.com',\n    baseUrl: '/fiora/',\n    onBrokenLinks: 'throw',\n    onBrokenMarkdownLinks: 'warn',\n    favicon: 'img/favicon.png',\n    organizationName: 'yinxin630', // Usually your GitHub org/user name.\n    projectName: 'fiora', // Usually your repo name.\n    themeConfig: {\n        navbar: {\n            title: 'fiora',\n            logo: {\n                alt: 'Logo',\n                src: 'img/favicon.png',\n            },\n            items: [\n                {\n                    to: 'docs/getting-start',\n                    activeBasePath: 'docs',\n                    label: 'Docs',\n                    position: 'right',\n                },\n                {\n                    href: 'https://github.com/yinxin630/fiora',\n                    label: 'GitHub',\n                    position: 'right',\n                },\n                {\n                    type: 'localeDropdown',\n                    position: 'right',\n                },\n            ],\n        },\n        footer: {\n            style: 'dark',\n            links: [\n                {\n                    title: 'Docs',\n                    items: [\n                        {\n                            label: 'Overview',\n                            to: '/',\n                        },\n                        {\n                            label: 'Getting Start',\n                            to: 'docs/getting-start',\n                        },\n                        {\n                            label: 'Change Log',\n                            to: 'docs/changelog',\n                        },\n                    ],\n                },\n                {\n                    title: 'Community',\n                    items: [\n                        {\n                            label: 'Feedback',\n                            href:\n                                'https://fiora.suisuijiang.com/invite/group/5adacdcfa109ce59da3e83d3',\n                        },\n                        {\n                            label: 'Issues',\n                            href: 'https://github.com/yinxin630/fiora/issues',\n                        },\n                    ],\n                },\n                {\n                    title: 'More',\n                    items: [\n                        {\n                            label: 'Author',\n                            href: 'https://suisuijiang.com',\n                        },\n                        {\n                            label: 'GitHub',\n                            href: 'https://github.com/yinxin630/fiora',\n                        },\n                    ],\n                },\n            ],\n            copyright: `Copyright © 2015 - ${new Date().getFullYear()} developed by 碎碎酱`,\n        },\n        colorMode: {\n            disableSwitch: true,\n        },\n    },\n    presets: [\n        [\n            '@docusaurus/preset-classic',\n            {\n                docs: {\n                    sidebarPath: require.resolve('./sidebars.js'),\n                    // Please change this to your repo.\n                    editUrl: 'https://github.com/yinxin630/fiora/edit/master/docs/',\n                },\n                theme: {\n                    customCss: require.resolve('./src/css/custom.css'),\n                },\n            },\n        ],\n    ],\n    i18n: {\n        defaultLocale: 'en',\n        locales: ['en', 'zh-Hans'],\n        localeConfigs: {\n            en: {\n                label: 'English',\n            },\n            'zh-Hans': {\n                label: '简体中文',\n            },\n        },\n    },\n};\n"
  },
  {
    "path": "packages/docs/i18n/en/code.json",
    "content": "{\n    \"Title\": {\n        \"message\": \"fiora\"\n    },\n    \"TagLine\": {\n        \"message\": \"An interesting open source chat application\"\n    },\n    \"Keywords\": {\n        \"message\": \"fiora, fiora docs, node.js, chatroom\"\n    },\n    \"Description\": {\n        \"message\": \"This site is for fiora doc. Fiora is an interesting open source chat application\"\n    },\n\n    \"Richness\": {\n        \"message\": \"Fiora contains backend, frontend, Android and iOS apps\"\n    },\n    \"Cross Platform\": {\n        \"message\": \"Fiora is developed with node.js. Supports Windows / Linux / macOS systems\"\n    },\n    \"Open Source\": {\n        \"message\": \"Fiora follows the MIT open source license\"\n    },\n\n    \"Join Chat Title\": {\n        \"message\": \"Join Chat\"\n    },\n    \"Join Chat Content\": {\n        \"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\"\n    },\n    \"Rich Feature Title\": {\n        \"message\": \"Rich Feature\"\n    },\n    \"Rich Feature Content\": {\n        \"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\"\n    },\n    \"Deploy By Yourself Title\": {\n        \"message\": \"Deploy By Yourself\"\n    },\n    \"Deploy By Yourself Content\": {\n        \"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\"\n    },\n\n    \"Interested\": {\n        \"message\": \"Are you very interested?\"\n    },\n    \"Getting Start\": {\n        \"message\": \"Getting Start\"\n    },\n\n    \"Try It Now\": {\n        \"message\": \"Try It Now\"\n    },\n\n    \"View Docs\": {\n        \"message\": \"View Docs\"\n    },\n    \"DocsUrl\": {\n        \"message\": \"/fiora/docs/getting-start/\"\n    }\n}\n"
  },
  {
    "path": "packages/docs/i18n/en/docusaurus-plugin-content-docs/current/API.md",
    "content": "---\nid: api\ntitle: API\nsidebar_label: API\n---\n\n## 如何调用接口\n\nfiora 后端基于 socket.io, 首先需要与后端建立连接\n\n```js\nimport IO from 'socket.io-client';\nconst socket = new IO(serverAddrress, options);\n```\n\n接口调用格式为\n\n```js\nsocket.emit(event, data, callback);\n```\n\n参数说明\n\n-   event {string} 接口名/事件名\n-   data {object} 接口入参\n-   callback {string|object => void} 接口回调, 返回 string 表示接口失败, string 内容为失败原因, 反正 object 表示接口成功, 里面包含返回数据\n\n## 返回数据结构定义\n\n### User\n\n```js\n{\n    _id, // {string} id\n    username, // {string} 用户名\n    avatar, // {string} 头像\n    groups, // {[Group]} 群组列表\n    friends, // {[User]} 好友列表\n    token, // {string} 免密登录token\n    isAdmin, // {boolean} 是否为管理员\n}\n```\n\n### Group\n\n```js\n{\n    _id, // {string} id\n    name, // {string} 群组名\n    avatar, // {string} 头像\n    creator, // {User ID} 群主id\n    isDefault, // {boolean} 是否为默认群\n    members, // {[User]} 成员列表\n    messages, // {[Message]} 消息列表\n}\n```\n\n### Message\n\n```js\n{\n    _id, // {string} id\n    from, // {User} 发送者\n    to, // {string} 群聊: 群id, 私聊: 两人id拼接, 按字符串比较, 小的在前\n    type, // {string} 消息类型 ['text', 'image', 'code', 'invite']\n    content, // {string} 消息内容\n}\n```\n\n## 接口列表\n\n### 用户注册\n\n```js\nsocket.emit(\n    'register',\n    {\n        username, // {string} 用户名\n        password, // {string} 密码\n        os, // {string} 操作系统\n        browser, // {string} 浏览器\n        environment, // {string} 环境信息\n    },\n    (user) => {}, // {User} 用户数据\n);\n```\n\n### 用户登录\n\n```js\nsocket.emit(\n    'login',\n    {\n        username, // {string} 用户名\n        password, // {string} 密码\n        os, // {string} 操作系统\n        browser, // {string} 浏览器\n        environment, // {string} 环境信息\n    },\n    (user) => {}, // {User} 用户数据\n);\n```\n\n### 免密登录 / 断线重连\n\n```js\nsocket.emit(\n    'loginByToken',\n    {\n        token, // {string} 免密登录token\n        os, // {string} 操作系统\n        browser, // {string} 浏览器\n        environment, // {string} 环境信息\n    },\n    (user) => {}, // {User} 用户数据\n);\n```\n\n### 游客登录\n\n游客仅能获取到默认群组\n\n```js\nsocket.emit(\n    'guest',\n    {\n        os, // {string} 操作系统\n        browser, // {string} 浏览器\n        environment, // {string} 环境信息\n    },\n    (defaultGroup) => {}, // {Group} 默认群组数据\n);\n```\n\n### 修改头像\n\n```js\nsocket.emit(\n    'changeAvatar',\n    {\n        avatar, // {string} 新头像url\n    },\n    () => {}, // {Object} 返回空对象\n);\n```\n\n### 添加好友\n\n```js\nsocket.emit(\n    'addFriend',\n    {\n        userId, // {User ID} 目标的id\n    },\n    (friend) => {}, // {User} 好友信息\n);\n```\n\n### 删除好友\n\n```js\nsocket.emit(\n    'deleteFriend',\n    {\n        userId, // {User ID} 目标的id\n    },\n    () => {}, // {Object} 返回空对象\n);\n```\n\n### 修改密码\n\n```js\nsocket.emit(\n    'changePassword',\n    {\n        oldPassword, // {string} 旧密码\n        newPassword, // {string} 新密码\n    },\n    () => {}, // {Object} 返回空对象\n);\n```\n\n### 修改用户名\n\n```js\nsocket.emit(\n    'changeUsername',\n    {\n        username, // {string} 新用户名\n    },\n    () => {}, // {Object} 返回空对象\n);\n```\n\n### 重置指定用户密码\n\n仅管理员可调用\n\n```js\nsocket.emit(\n    'resetUserPassword',\n    {\n        username, // {string} 新用户名\n    },\n    (data) => { // {Object} 返回数据\n        data.newPassword, // {string} 新密码\n    },\n);\n```\n\n### 发送消息\n\n通过 to 字段判断是发送给群, 还是发送给个人\n发送群的话, to 就是群 id\n发送个人的话, to 就是两个人的 id 拼接, 按字符串比较结果, 小的在前大的在后\n\n```js\nsocket.emit(\n    'sendMessage',\n    {\n        to, // {string} 目标群组, 或者俩用户id拼接结果\n        type, // {string} 消息类型\n        content, // {string} 消息内容\n    },\n    (message) => {}, // {Message} 新消息\n);\n```\n\n### 获取联系人最后消息\n\n```js\nsocket.emit(\n    'getLinkmansLastMessages',\n    {\n        linkmans, // {[string]} 联系人id列表, 与to同规则\n    },\n    (messages) => {}, // {object} 所有联系人的最后消息, key: 联系人id, value: [Message] 消息列表\n);\n```\n\n### 获取联系人历史消息\n\n```js\nsocket.emit(\n    'getLinkmanHistoryMessages',\n    {\n        linkmanId, // {string} 联系人id\n        existCount, // {number} 已有消息数量\n    },\n    (messages) => {}, // {[Message]} 消息列表\n);\n```\n\n### 获取默认群组的历史消息\n\n不需要登录态\n\n```js\nsocket.emit(\n    'getDefaultGroupHistoryMessages',\n    {\n        existCount, // {number} 已有消息数量\n    },\n    (messages) => {}, // {[Message]} 消息列表\n);\n```\n\n### 创建群组\n\n```js\nsocket.emit(\n    'createGroup',\n    {\n        name, // {string} 群组名\n    },\n    (group) => {}, // {Group} 新创建的群组\n);\n```\n\n### 加入群组\n\n```js\nsocket.emit(\n    'joinGroup',\n    {\n        groupId, // {Group ID} 目标群id\n    },\n    (group) => {}, // {Group} 新创建的群组\n);\n```\n\n### 退出群组\n\n```js\nsocket.emit(\n    'leaveGroup',\n    {\n        groupId, // {Group ID} 目标群id\n    },\n    () => {}, // {object} 返回空数据\n);\n```\n\n### 获取群组在线用户列表\n\n```js\nsocket.emit(\n    'getGroupOnlineMembers',\n    {\n        groupId, // {Group ID} 目标群id\n    },\n    (users) => {}, // {[User]} 在线用户列表\n);\n```\n\n### 获取默认群组在线用户列表\n\n```js\nsocket.emit(\n    'getDefaultGroupOnlineMembers',\n    {},\n    (users) => {}, // {[User]} 在线用户列表\n);\n```\n\n### 修改群头像\n\n```js\nsocket.emit(\n    'changeGroupAvatar',\n    {\n        groupId, // {Group ID} 目标群id\n        avatar, // {string} 新头像url\n    },\n    () => {}, // {object} 返回空数据\n);\n```\n\n### 获取七牛前端文件上传 token\n\n```js\nsocket.emit(\n    'uploadToken',\n    { },\n    (data) => {\n        // 服务端支持七牛\n        data.token, // 上传token\n        data.urlPrefix, // 文件上传后的路径前缀\n\n        // 服务端不支持七牛\n        data.useUploadFile, // 不支持上传七牛, 需要客户端调用 uploadFile 上传文件到服务端\n    },\n);\n```\n\n### 搜索用户/群组\n\n```js\nsocket.emit(\n    'search',\n    {\n        keywords, // {string} 搜索关键字\n    },\n    (data) => {\n        data.users, // {[User]} 命中的用户\n        data.groups, // {[Group]} 命中的群组\n    },\n);\n```\n\n### 搜索表情包\n\n```js\nsocket.emit(\n    'searchExpression',\n    {\n        keywords, // {string} 搜索关键字\n    },\n    (imageUrls) => {}, // {[string]} 图片列表\n);\n```\n\n### 获取百度语言合成 token\n\n```js\nsocket.emit(\n    'getBaiduToken',\n    { },\n    (data) => {\n        data.token, // {string} token\n    },\n);\n```\n\n### 封禁用户\n\n```js\nsocket.emit(\n    'sealUser',\n    {\n        username, // {string} 要封禁的用户名\n    },\n    () => {}, // {object} 返回空数据\n);\n```\n\n### 获取封禁用户列表\n\n```js\nsocket.emit(\n    'getSealList',\n    {},\n    (users) => {}, // {[string]} 被封禁的用户名列表\n);\n```\n\n### 上传文件到服务端\n\n```js\nsocket.emit(\n    'uploadFile',\n    {\n        fileName, // {string} 文件名\n        file, // {blob} 文件内容, blob格式\n    },\n    (data) => {\n        data.url, // 文件url\n    },\n);\n```\n"
  },
  {
    "path": "packages/docs/i18n/en/docusaurus-plugin-content-docs/current/App.md",
    "content": "---\nid: app\ntitle: Fiora App\nsidebar_label: Fiora App\n---\n\nFiora app is developed with [expo](https://expo.io/) and [react-native](https://reactnative.dev/). Support Android and iOS systems\n\n## Download App\n\n### Android\n\nClick link or scan qrcode to download APK\n\n[https://cdn.suisuijiang.com/fiora.apk](https://cdn.suisuijiang.com/fiora.apk)\n\n![](/img/android-download-qrcode.png)\n\n### iOS\n\nThe 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 <yinxinmac@icloud.com>. Please attach your apple ID\n\n## Hot to run\n\n1. Install expo `yarn global add expo-cli`\n2. Install dependencies `yarn install`\n3. Start compilation `expo start`\n4. According to the console prompt, run the app in the simulator or real device\n\nFor more information, please see [https://docs.expo.io/](https://docs.expo.io/)\n\n## Build Standalone App\n\nPlease refer to <https://docs.expo.io/distribution/building-standalone-apps/>"
  },
  {
    "path": "packages/docs/i18n/en/docusaurus-plugin-content-docs/current/CHANGELOG.md",
    "content": "---\nid: changelog\ntitle: Change Log\nsidebar_label: Change Log\n---\n\n## 2021-6-24\n\n-   Support user names and user tags with Japanese characters\n\n## 2021-5-11\n\n-   Use Aliyun OSS to replace Qiniu CDN\n\n## 2021-3-24\n\n-   Fix the problem that the search function allows regular expression matching\n\n## 2021-3-14\n\n-   Support the server to calculate the number of unread messages\n\n## 2021-3-2\n\n-   When identifying the url in the message, support host as localhost or ip\n\n## 2021-3-1\n\n-   No longer limit the number of groups created by the administrator\n\n## 2021-2-28\n\n-   Multiple users use the same notification token\n\n## 2021-2-27\n\n-   Modify app notification content\n-   Messages sent by yourself no longer push notification to yourself\n-   The progress bar is displayed when the webpack build production environment\n\n## 2021-2-25\n\n-   Support push notification to fiora app\n\n## 2021-2-21\n\n-   **Important** Fix the wrong logic of judging whether it is an administrator on the server side. Treat everyone as an administrator\n\n## 2021-2-17\n\n-   Support sharing groups externally\n\n## 2021-1-26\n\n-   File message size calculation error\n\n## 2021-1-22\n\n-   A single ip can register up to 3 accounts within 24 hours\n\n## 2020-12-17\n\n-   Support search expressions by input content. It is disabled default and you can enable it in setting\n\n-   Only limit send message frequency\n\n## 2020-12-08\n\n-   **Breaking!!!** Refactor to use redis cache instead of memory variable cache. So you should run redis first before start fiora\n\n## 2020-11-15\n\n-   Refactor to use webpack plugin to generate service worker script\n-   Refacotr or add server scripts\n\n## 2020-11-14\n\n-   Adapt to ios full screen devices\n\n## 2020-11-12\n\n-   Support multiple administrators\n-   Add getUserId and deleteUser scripts\n\n## 2020-11-08\n\n-   Support to withdraw self's message\n\n## 2020-11-07\n\n-   Support send file directly\n-   Support display linkman realtime info. About user online status and group online members count\n\n-   Refactor webpack build config\n\n-   Fix the issue of right click on image viewer to copy image will close it\n\n## 2020-11-04\n\n-   **Breaking!!!** Modify the config files. It no longer supports modifying config items through command line params\n-   Remove pm2 ecosystem config and deploy shell script\n\n## 2020-11-03\n\n-   Rename some npm scripts name\n"
  },
  {
    "path": "packages/docs/i18n/en/docusaurus-plugin-content-docs/current/Config.md",
    "content": "---\nid: config\ntitle: Config\nsidebar_label: Config\n---\n\nServer configuration `config/server.ts`\nClient configuration `config/client.ts`\n\nCompared to directly modifying the configuration file, it is recommended to use environment variables to modify the configuration  \nCreate 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`\n\n## Server Config\n\n**Modifying the server configuration requires restarting the application**\n\n| Key                | Type    | Default                                | Description                                                                                              |\n| ------------------ | ------- | -------------------------------------- | -------------------------------------------------------------------------------------------------------- |\n| Host               | string  | your ip                                | backend server host                                                                                      |\n| Port               | number  | 9200                                   | backend server port                                                                                      |\n| Database           | string  | mongodb://localhost:27017/fiora        | mongodbb address                                                                                         |\n| RedisHost          | string  | localhost                              | redis host                                                                                               |\n| RedisPort          | number  | 6379                                   | redis port                                                                                               |\n| JwtSecret          | string  | jwtSecret (Modify it to ensure safety) | jwt token encryption secret                                                                              |\n| MaxGroupCount      | number  | 3                                      | Maximum number of groups created per user                                                                |\n| AllowOrigin        | string  | null                                   | The list of allowed client origins. If null, all origins are allowed. Multiple values separated by comma |\n| tokenExpiresTime   | number  | 2592000000 (30days)                    | login token expires time                                                                                 |\n| Administrator      | string  | ''                                     | Administrator userId list. Multiple values separated by comma                                            |\n| DisableRegister    | boolean | false                                  | Disable register                                                                                         |\n| DisableCreateGroup | boolean | false                                  | Disable create group                                                                                     |\n| ALIYUN_OSS         | boolean | false                                  | enable to use aliyun OSS                                                                                 |\n| ACCESS_KEY_ID      | string  | ''                                     | aliyun OSS access key id. reference: https://help.aliyun.com/document_detail/48699.html                  |\n| ACCESS_KEY_SECRET  | string  | ''                                     | aliyun OSS access key secret. reference like ACCESS_KEY_ID                                               |\n| ROLE_ARN           | string  | ''                                     | aliyun OSS RoleARN. reference: https://help.aliyun.com/document_detail/28649.html                        |\n| REGION             | string  | ''                                     | aliyun OSS region. example: `oss-cn-zhangjiakou`                                                         |\n| BUCKET             | string  | ''                                     | aliyun OSS bucket name                                                                                   |\n| ENDPOINT           | string  | ''                                     | aliyun OSS domain. example: `cdn.suisuijiang.com`                                                        |\n\n## Client Config\n\n**Modifying the client configuration requires rebuilding the client**\n\n| Key                    | Type    | Default         | Description                                                  |\n| ---------------------- | ------- | --------------- | ------------------------------------------------------------ |\n| Server                 | string  | /               | Server address of the client connection                      |\n| MaxImageSize           | number  | 3145728 (3MB)   | The maximum image size that the client can upload            |\n| MaxBackgroundImageSize | number  | 5242880 (5MB)   | The maximum background image size that the client can upload |\n| MaxAvatarSize          | number  | 1572864 (1.5MB) | The maximum avatar image size that the client can upload     |\n| MaxFileSize            | number  | 10485760 (10MB) | The maximum file size that the client can upload             |\n| DefaultTheme           | string  | cool            | default theme                                                |\n| Sound                  | string  | default         | default notification sound                                   |\n| TagColorMode           | string  | fixedColor      | default tag color mode                                       |\n| FrontendMonitorAppId   | string  | fixedColor      | appId of monitor <https://yueying.effirst.com/index>         |\n| DisableDeleteMessage   | boolean | false           | disable user delete messages                                 |\n"
  },
  {
    "path": "packages/docs/i18n/en/docusaurus-plugin-content-docs/current/FAQ.md",
    "content": "---\nid: faq\ntitle: FAQ\nsidebar_label: FAQ\n---\n\n## How to set up an administrator\n\n1. Get user id. reference [getUserId](/docs/script#getuserid)\n2. Set `Administrator` in config to be administrator userId.\n3. Restart the server\n\n## How to modify the default group name\n\nreference [updateDefaultGroupname](/docs/script#updatedefaultgroupname)\n\n## How to custom domain name\n\nRecommend to use nginx reverse proxy\n\nExample config, **Please modify the configuration of the comment item**\n\n```\nserver {\n   listen 80;\n   # Change to your domain name\n   server_name fiora.suisuijiang.com;\n\n   location / {\n      proxy_set_header   X-Real-IP        $remote_addr;\n      proxy_set_header   X-Forwarded-For  $proxy_add_x_forwarded_for;\n      proxy_set_header   Host             $http_host;\n      proxy_set_header   Upgrade          $http_upgrade;\n      proxy_set_header   X-NginX-Proxy    true;\n      proxy_set_header   Connection \"upgrade\";\n      proxy_http_version 1.1;\n      proxy_pass         http://localhost:9200;\n   }\n}\n```\n\nHTTPS + HTTP 2.0 config\n\n```\nserver {\n   listen 80;\n   # Change to your domain name\n   server_name fiora.suisuijiang.com;\n   return 301 https://fiora.suisuijiang.com$request_uri;\n}\nserver {\n   listen 443 ssl http2;\n   # Change to your domain name\n   server_name  fiora.suisuijiang.com;\n\n   ssl on;\n   # Modify to your ssl certificate location\n   ssl_certificate ./ssl/fiora.suisuijiang.com.crt;\n   ssl_certificate_key ./ssl/fiora.suisuijiang.com.key;\n   ssl_session_timeout 5m;\n   ssl_protocols TLSv1 TLSv1.1 TLSv1.2;\n   ssl_ciphers ECDHE-RSA-AES128-GCM-SHA256:HIGH:!aNULL:!MD5:!RC4:!DHE;\n   ssl_prefer_server_ciphers on;\n\n   location / {\n      proxy_set_header   X-Real-IP        $remote_addr;\n      proxy_set_header   X-Forwarded-For  $proxy_add_x_forwarded_for;\n      proxy_set_header   Host             $http_host;\n      proxy_set_header   Upgrade          $http_upgrade;\n      proxy_set_header   X-NginX-Proxy    true;\n      proxy_set_header   Connection \"upgrade\";\n      proxy_http_version 1.1;\n      proxy_pass         http://localhost:9200;\n   }\n}\n```\n\n## How to Disable register, manual account assignment\n\nSet `DisableRegister` in config to be true. Restart the server to take effect\n\nUse scripts to manually register new users. Reference [register](/docs/script#register)\n\n## How to delete user\n\nReference [deleteUser](/docs/script#deleteuser)\n\n## The client throw an error \"调用失败,处于萌新阶段\"\n\nIn 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.\n\n## An error is throwed when executing the command. \"Couldn't find a package.json file in xxx\"\n\nFirst cd to the fiora root directory, and then execute the corresponding command\n\n## Why the modified configuration does not take effect\n\n1. First confirm whether the configuration modification is correct\n    -If you modify the configuration file directly, please make sure that the modified part of the syntax and format is correct\n    -If you modify the configuration through the .env file, please make sure the format is correct\n2. After modifying the configuration\n    -If you modify the server configuration, you need to restart the server\n    -If you modify the client configuration, you need to rebuild the client\n\n## How to rebuild the web client\n\n`yarn build:web`"
  },
  {
    "path": "packages/docs/i18n/en/docusaurus-plugin-content-docs/current/Getting-Start.md",
    "content": "---\nid: getting-start\ntitle: Getting Start\nsidebar_label: Getting Start\n---\n\nimport useBaseUrl from '@docusaurus/useBaseUrl';\n\nfiora is an interesting chat application. It is developed based on node.js, mongodb, react and socket.io technologies\n\nThe project started at [2015-11-04](https://github.com/yinxin630/chatroom-with-sails/commit/0a032372727550b8b4087f24ac299de03b677b9f)\n\nOnline address: [https://fiora.suisuijiang.com/](https://fiora.suisuijiang.com/)  \nAndroid / iOS app: [https://github.com/yinxin630/fiora-app](https://github.com/yinxin630/fiora-app)\n\n## Functions\n\n1. Register an account and log in, it can save your data for a long time\n2. Join an existing group or create your own group to communicate with everyone\n3. Chat privately with anyone and add them as friends\n4. Multiple message types, including text / emoticons / pictures / codes / files / commands, you can also search for emoticons\n5. Push notification when you receive a new message, you can customize the notification ringtone, and it can also read the message out\n6. Choose the theme you like, and you can set it as any wallpaper and theme color you like\n7. Set up an administrator to manage users\n\n## Screenshot\n\n<img alt=\"PC screenshot\" src={useBaseUrl('img/screenshots/screenshot-pc.png')} style={{'max-width':'800px'}} />\n<img alt=\"Mobile screenshot\" src={useBaseUrl('img/screenshots/screenshot-phone.png')} style={{'max-height':'667px'}} />\n\n## Directory\n\n    |-- [.githubb]                // github actions\n    |-- [.vscode]                 // vscode workspace config\n    |-- [bin]                     // server scripts\n    |-- [build]                   // webpack config\n    |-- [client]                  // web client\n    |-- [config]                  // application configs\n    |-- [dist]                    // client buid output directory\n    |-- [docs]                    // document\n    |-- [public]                  // server static resources\n    |-- [server]                  // server\n    |-- [test]                    // unit test\n    |-- [types]                   // typescript types\n    |-- [utils]                   // util functions\n    |-- .babelrc                  // babel config\n    |-- .eslintignore             // eslint ignore list\n    |-- .eslintrc                 // eslint config\n    |-- .gitignore                // git ignore\n    |-- .nodemonrc                // nodemon config\n    |-- .prettierrc               // prettier config\n    |-- Dockerfile                // docker file\n    |-- LICENSE                   // fiora license\n    |-- docker-compose.yaml       // docker compose config\n    |-- jest.*.sj                 // jest config\n    |-- package.json              // npm\n    |-- tsconfig.json             // typescript config\n    |-- yarn.lock                 // yarn\n    ...\n\n## Contribution\n\nIf you want to add functionality or fix bugs, please follow the process below:\n\n1. Fork this repository and clone the fork post to the local\n2. Installation dependencies `yarn install`\n3. Modify the code and confirm it is bug free\n4. Submit code, if eslint has reported error, please repair it and submit it again.\n5. Create a pull request\n"
  },
  {
    "path": "packages/docs/i18n/en/docusaurus-plugin-content-docs/current/INSTALL.md",
    "content": "---\nid: install\ntitle: Install\nsidebar_label: Install\n---\n\n## Environmental Preparation\n\nTo run Fiora, you need Node.js(recommend v14 LTS version), MongoDB and redis\n\n-   Install Node.js\n    -   Official website <https://nodejs.org/en/download/>\n    -   It is recommended to use nvm to install Node.js\n        -   Install nvm <https://github.com/nvm-sh/nvm#install--update-script>\n        -   Install Node.js via nvm <https://github.com/nvm-sh/nvm#usage>\n-   Install MongoDB\n    -   Official website <https://docs.mongodb.com/manual/installation/#install-mongodb>\n-   Install redis\n    -   Official website <https://docs.mongodb.com/manual/installation/#install-mongodb>\n\nRecommended to running on Linux or MacOS systems\n\n## How to run\n\n1. Clone the project `git clone https://github.com/yinxin630/fiora.git -b master`\n2. Ensure you have install [yarn](https://www.npmjs.com/package/yarn) before, if not please run `npm install -g yarn`\n3. Install project dependencies `yarn install`\n4. Build client `yarn build:web`\n5. Config JwtSecret `echo \"JwtSecret=<string>\" > .env2`. Change `<string>` to a secret text\n6. Start the server `yarn start`\n7. Open `http://[ip]:[port]`(such as `http://127.0.0.1:9200`) in browser\n\n### Run in the background\n\nUsing `yarn start` to run the server will stop running after disconnecting the ssh connection, it is recommended to use pm2 to run\n\n```bash\n# install pm2\nnpm install -g pm2\n\n# use pm2 to run fiora\npm2 start yarn --name fiora -- start\n\n# view pm2 apps status\npm2 ls\n\n# view pm2 fiora logging\npm2 logs fiora\n```\n\n### Run With Develop Mode\n\n1. Start the server `yarn dev:server`\n2. Start the client `yarn dev:web`\n3. Open `http://localhost:8080` in browser\n\n### Running on the docker\n\nFirst install docker <https://docs.docker.com/install/>\n\n#### Run directly from the DockerHub image\n\n```bash\n# Pull mongo\ndocker pull mongo\n\n# Pull redis\ndocker pull redis\n\n# Pull fiora\ndocker pull suisuijiang/fiora\n\n# Create a virtual network\ndocker network create fiora-network\n\n# Run mongodB\ndocker run --name fioradb -p 27017:27017 --network fiora-network mongo\n\n# Run redis\ndocker run --name fioraredis -p 6379:6379 --network fiora-network redis\n\n# Run fiora\ndocker run --name fiora -p 9200:9200 --network fiora-network -e Database=mongodb://fioradb:27017/fiora -e RedisHost=fioraredis suisuijiang/fiora\n```\n\n#### Local build image and run\n\n1. Clone the project to the local `git clone https://github.com/yinxin630/fiora.git -b master`\n2. Build the image `docker-compose build --no-cache --force-rm`\n3. Run it `docker-compose up`\n"
  },
  {
    "path": "packages/docs/i18n/en/docusaurus-plugin-content-docs/current/Script.md",
    "content": "---\nid: script\ntitle: Script\nsidebar_label: Script\n---\n\nFiora has a built-in command line tool to manage the server. Execute `fiora` to view the tool\n\n**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\n\n## deleteMessages\n\n`fiora deleteMessages`\n\nDelete all historical message records, if the message pictures and files are stored on the server, they can also be deleted together\n\n## deleteTodayRegisteredUsers\n\n`fiora deleteTodayRegisteredUsers`\n\nDelete all newly registered users on the day (based on server time)\n\n## deleteUser\n\n`fiora deleteUser [userId]`\n\nDelete the specified user, delete its historical messages, exit the group that it has joined, and delete all its friends\n\n## doctor\n\n`fiora doctor`\n\nCheck the server configuration and status, which can be used to locate the cause of the server startup failure\n\n## fixUsersAvatar\n\n`fiora fixUsersAvatar`\n\nFix user error avatar path, please modify the script judgment logic according to your actual situation\n\n## getUserId\n\n`fiora getUserId [username]`\n\nGet the userId of the specified user name\n\n## register\n\n`fiora register [username] [password]`\n\nRegister new users, when registration is prohibited, the administrator can register new users through it\n\n## updateDefaultGroupName\n\n`fiora updateDefaultGroupName [newName]`\n\nUpdate default group name"
  },
  {
    "path": "packages/docs/i18n/en/docusaurus-theme-classic/footer.json",
    "content": "{\n    \"link.title.Docs\": {\n        \"message\": \"Docs\"\n    },\n    \"link.item.label.Overview\": {\n        \"message\": \"Overview\"\n    },\n\n    \"link.title.Community\": {\n        \"message\": \"Community\"\n    },\n    \"link.item.label.Feedback\": {\n        \"message\": \"Join Chat\"\n    },\n    \"link.item.label.Issues\": {\n        \"message\": \"Submit Issue\"\n    },\n\n    \"link.title.More\": {\n        \"message\": \"More\"\n    },\n    \"link.item.label.Author\": {\n        \"message\": \"About Author\"\n    },\n    \"link.item.label.GitHub\": {\n        \"message\": \"GitHub\"\n    }\n}\n"
  },
  {
    "path": "packages/docs/i18n/en/docusaurus-theme-classic/navbar.json",
    "content": "{\n    \"item.label.Docs\": {\n        \"message\": \"Docs\"\n    }\n}\n"
  },
  {
    "path": "packages/docs/i18n/zh-Hans/code.json",
    "content": "{\n    \"Title\": {\n        \"message\": \"fiora\"\n    },\n    \"TagLine\": {\n        \"message\": \"一个有趣的开源聊天应用\"\n    },\n    \"Keywords\": {\n        \"message\": \"fiora, fiora 文档, node.js, 聊天室\"\n    },\n    \"Description\": {\n        \"message\": \"这是 fiora 文档网站, fiora 是一个有趣的开源聊天室应用\"\n    },\n\n    \"Richness\": {\n        \"message\": \"fiora 包括后端、前端、安卓和 iOS App\"\n    },\n    \"Cross Platform\": {\n        \"message\": \"fiora 基于 node.js 开发, 支持 Windows / Linux / macOS 等操作系统\"\n    },\n    \"Open Source\": {\n        \"message\": \"fiora 遵循 MIT 开源许可\"\n    },\n\n    \"Join Chat Title\": {\n        \"message\": \"加入聊天\"\n    },\n    \"Join Chat Content\": {\n        \"message\": \"注册一个账号加入聊天, 加入或者新的群组, 和有趣的陌生人私聊并加为好友, 你的账号和消息会永久保留\"\n    },\n    \"Rich Feature Title\": {\n        \"message\": \"丰富的功能\"\n    },\n    \"Rich Feature Content\": {\n        \"message\": \"你可以发送文本、表情、图片、代码和文件给其他人, 你还可以撤回已发送的消息, 另外你还可以修改用户名和头像, 最令人兴奋的是你可以选择或者自定义不同的主题\"\n    },\n    \"Deploy By Yourself Title\": {\n        \"message\": \"自己部署\"\n    },\n    \"Deploy By Yourself Content\": {\n        \"message\": \"fiora 是一个开源项目, 你可以克隆源码并部署到自己的服务器, 支持 windows / Linux and macOS 操作系统, 但是推荐您部署到 Linux 服务器上\"\n    },\n\n    \"Interested\": {\n        \"message\": \"你是否非常感兴趣?\"\n    },\n    \"Getting Start\": {\n        \"message\": \"查看文档\"\n    },\n\n    \"Try It Now\": {\n        \"message\": \"去体验看看\"\n    },\n\n    \"View Docs\": {\n        \"message\": \"查看文档\"\n    },\n    \"DocsUrl\": {\n        \"message\": \"/fiora/zh-Hans/docs/getting-start/\"\n    }\n}\n"
  },
  {
    "path": "packages/docs/i18n/zh-Hans/docusaurus-plugin-content-docs/current/API.md",
    "content": "---\nid: api\ntitle: 接口\nsidebar_label: 接口\n---\n\n## 如何调用接口\n\nfiora 后端基于 socket.io, 首先需要与后端建立连接\n\n```js\nimport IO from 'socket.io-client';\nconst socket = new IO(serverAddrress, options);\n```\n\n接口调用格式为\n\n```js\nsocket.emit(event, data, callback);\n```\n\n参数说明\n\n-   event {string} 接口名/事件名\n-   data {object} 接口入参\n-   callback {string|object => void} 接口回调, 返回 string 表示接口失败, string 内容为失败原因, 反正 object 表示接口成功, 里面包含返回数据\n\n## 返回数据结构定义\n\n### User\n\n```js\n{\n    _id, // {string} id\n    username, // {string} 用户名\n    avatar, // {string} 头像\n    groups, // {[Group]} 群组列表\n    friends, // {[User]} 好友列表\n    token, // {string} 免密登录token\n    isAdmin, // {boolean} 是否为管理员\n}\n```\n\n### Group\n\n```js\n{\n    _id, // {string} id\n    name, // {string} 群组名\n    avatar, // {string} 头像\n    creator, // {User ID} 群主id\n    isDefault, // {boolean} 是否为默认群\n    members, // {[User]} 成员列表\n    messages, // {[Message]} 消息列表\n}\n```\n\n### Message\n\n```js\n{\n    _id, // {string} id\n    from, // {User} 发送者\n    to, // {string} 群聊: 群id, 私聊: 两人id拼接, 按字符串比较, 小的在前\n    type, // {string} 消息类型 ['text', 'image', 'code', 'invite']\n    content, // {string} 消息内容\n}\n```\n\n## 接口列表\n\n### 用户注册\n\n```js\nsocket.emit(\n    'register',\n    {\n        username, // {string} 用户名\n        password, // {string} 密码\n        os, // {string} 操作系统\n        browser, // {string} 浏览器\n        environment, // {string} 环境信息\n    },\n    (user) => {}, // {User} 用户数据\n);\n```\n\n### 用户登录\n\n```js\nsocket.emit(\n    'login',\n    {\n        username, // {string} 用户名\n        password, // {string} 密码\n        os, // {string} 操作系统\n        browser, // {string} 浏览器\n        environment, // {string} 环境信息\n    },\n    (user) => {}, // {User} 用户数据\n);\n```\n\n### 免密登录 / 断线重连\n\n```js\nsocket.emit(\n    'loginByToken',\n    {\n        token, // {string} 免密登录token\n        os, // {string} 操作系统\n        browser, // {string} 浏览器\n        environment, // {string} 环境信息\n    },\n    (user) => {}, // {User} 用户数据\n);\n```\n\n### 游客登录\n\n游客仅能获取到默认群组\n\n```js\nsocket.emit(\n    'guest',\n    {\n        os, // {string} 操作系统\n        browser, // {string} 浏览器\n        environment, // {string} 环境信息\n    },\n    (defaultGroup) => {}, // {Group} 默认群组数据\n);\n```\n\n### 修改头像\n\n```js\nsocket.emit(\n    'changeAvatar',\n    {\n        avatar, // {string} 新头像url\n    },\n    () => {}, // {Object} 返回空对象\n);\n```\n\n### 添加好友\n\n```js\nsocket.emit(\n    'addFriend',\n    {\n        userId, // {User ID} 目标的id\n    },\n    (friend) => {}, // {User} 好友信息\n);\n```\n\n### 删除好友\n\n```js\nsocket.emit(\n    'deleteFriend',\n    {\n        userId, // {User ID} 目标的id\n    },\n    () => {}, // {Object} 返回空对象\n);\n```\n\n### 修改密码\n\n```js\nsocket.emit(\n    'changePassword',\n    {\n        oldPassword, // {string} 旧密码\n        newPassword, // {string} 新密码\n    },\n    () => {}, // {Object} 返回空对象\n);\n```\n\n### 修改用户名\n\n```js\nsocket.emit(\n    'changeUsername',\n    {\n        username, // {string} 新用户名\n    },\n    () => {}, // {Object} 返回空对象\n);\n```\n\n### 重置指定用户密码\n\n仅管理员可调用\n\n```js\nsocket.emit(\n    'resetUserPassword',\n    {\n        username, // {string} 新用户名\n    },\n    (data) => { // {Object} 返回数据\n        data.newPassword, // {string} 新密码\n    },\n);\n```\n\n### 发送消息\n\n通过 to 字段判断是发送给群, 还是发送给个人\n发送群的话, to 就是群 id\n发送个人的话, to 就是两个人的 id 拼接, 按字符串比较结果, 小的在前大的在后\n\n```js\nsocket.emit(\n    'sendMessage',\n    {\n        to, // {string} 目标群组, 或者俩用户id拼接结果\n        type, // {string} 消息类型\n        content, // {string} 消息内容\n    },\n    (message) => {}, // {Message} 新消息\n);\n```\n\n### 获取联系人最后消息\n\n```js\nsocket.emit(\n    'getLinkmansLastMessages',\n    {\n        linkmans, // {[string]} 联系人id列表, 与to同规则\n    },\n    (messages) => {}, // {object} 所有联系人的最后消息, key: 联系人id, value: [Message] 消息列表\n);\n```\n\n### 获取联系人历史消息\n\n```js\nsocket.emit(\n    'getLinkmanHistoryMessages',\n    {\n        linkmanId, // {string} 联系人id\n        existCount, // {number} 已有消息数量\n    },\n    (messages) => {}, // {[Message]} 消息列表\n);\n```\n\n### 获取默认群组的历史消息\n\n不需要登录态\n\n```js\nsocket.emit(\n    'getDefaultGroupHistoryMessages',\n    {\n        existCount, // {number} 已有消息数量\n    },\n    (messages) => {}, // {[Message]} 消息列表\n);\n```\n\n### 创建群组\n\n```js\nsocket.emit(\n    'createGroup',\n    {\n        name, // {string} 群组名\n    },\n    (group) => {}, // {Group} 新创建的群组\n);\n```\n\n### 加入群组\n\n```js\nsocket.emit(\n    'joinGroup',\n    {\n        groupId, // {Group ID} 目标群id\n    },\n    (group) => {}, // {Group} 新创建的群组\n);\n```\n\n### 退出群组\n\n```js\nsocket.emit(\n    'leaveGroup',\n    {\n        groupId, // {Group ID} 目标群id\n    },\n    () => {}, // {object} 返回空数据\n);\n```\n\n### 获取群组在线用户列表\n\n```js\nsocket.emit(\n    'getGroupOnlineMembers',\n    {\n        groupId, // {Group ID} 目标群id\n    },\n    (users) => {}, // {[User]} 在线用户列表\n);\n```\n\n### 获取默认群组在线用户列表\n\n```js\nsocket.emit(\n    'getDefaultGroupOnlineMembers',\n    {},\n    (users) => {}, // {[User]} 在线用户列表\n);\n```\n\n### 修改群头像\n\n```js\nsocket.emit(\n    'changeGroupAvatar',\n    {\n        groupId, // {Group ID} 目标群id\n        avatar, // {string} 新头像url\n    },\n    () => {}, // {object} 返回空数据\n);\n```\n\n### 获取七牛前端文件上传 token\n\n```js\nsocket.emit(\n    'uploadToken',\n    { },\n    (data) => {\n        // 服务端支持七牛\n        data.token, // 上传token\n        data.urlPrefix, // 文件上传后的路径前缀\n\n        // 服务端不支持七牛\n        data.useUploadFile, // 不支持上传七牛, 需要客户端调用 uploadFile 上传文件到服务端\n    },\n);\n```\n\n### 搜索用户/群组\n\n```js\nsocket.emit(\n    'search',\n    {\n        keywords, // {string} 搜索关键字\n    },\n    (data) => {\n        data.users, // {[User]} 命中的用户\n        data.groups, // {[Group]} 命中的群组\n    },\n);\n```\n\n### 搜索表情包\n\n```js\nsocket.emit(\n    'searchExpression',\n    {\n        keywords, // {string} 搜索关键字\n    },\n    (imageUrls) => {}, // {[string]} 图片列表\n);\n```\n\n### 获取百度语言合成 token\n\n```js\nsocket.emit(\n    'getBaiduToken',\n    { },\n    (data) => {\n        data.token, // {string} token\n    },\n);\n```\n\n### 封禁用户\n\n```js\nsocket.emit(\n    'sealUser',\n    {\n        username, // {string} 要封禁的用户名\n    },\n    () => {}, // {object} 返回空数据\n);\n```\n\n### 获取封禁用户列表\n\n```js\nsocket.emit(\n    'getSealList',\n    {},\n    (users) => {}, // {[string]} 被封禁的用户名列表\n);\n```\n\n### 上传文件到服务端\n\n```js\nsocket.emit(\n    'uploadFile',\n    {\n        fileName, // {string} 文件名\n        file, // {blob} 文件内容, blob格式\n    },\n    (data) => {\n        data.url, // 文件url\n    },\n);\n```\n"
  },
  {
    "path": "packages/docs/i18n/zh-Hans/docusaurus-plugin-content-docs/current/App.md",
    "content": "---\nid: app\ntitle: Fiora App\nsidebar_label: Fiora App\n---\n\nfiora app 是基于 [expo](https://expo.io/) he  [react-native](https://reactnative.dev/) 开发的, 支持 Android 和 iOS 系统\n\n## 下载 App\n\n### Android\n\n点击链接或者扫描二维码下载 APK\n\n[https://cdn.suisuijiang.com/fiora.apk](https://cdn.suisuijiang.com/fiora.apk)\n\n![](/img/android-download-qrcode.png)\n\n### iOS\n\niOS app 已经提交给 App Store 审核了, 现在可以通过 testflight 来安装. 请联系碎碎酱或者发送邮件给 <yinxinmac@icloud.com>, 附上你的 Apple ID\n\n## 如何运行\n\n1. 安装 expo `yarn global add expo-cli`\n2. 安装依赖 `yarn install`\n3. 启动编译 `expo start`\n4. 根据控制台输出的提示, 在模拟器或者真实设备上运行 app\n\n想要了解更多信息, 请查看 [https://docs.expo.io/](https://docs.expo.io/)\n\n## 构建 App\n\n请参考 <https://docs.expo.io/distribution/building-standalone-apps/>"
  },
  {
    "path": "packages/docs/i18n/zh-Hans/docusaurus-plugin-content-docs/current/CHANGELOG.md",
    "content": "---\nid: changelog\ntitle: 更新日志\nsidebar_label: 更新日志\n---\n\n## 2021-6-24\n\n-   支持带有日文字符的用户名和用户标签\n\n## 2021-5-11\n\n-   使用阿里云 OSS 替代七牛 CDN\n\n## 2021-3-24\n\n-   修复搜索功能允许使用正则表达式匹配的问题\n\n## 2021-3-14\n\n-   支持服务端计算未读消息数量\n\n## 2021-3-2\n\n-   识别消息中的 URL 时, 支持 host 为 localhost 或者 ip\n\n## 2021-3-1\n\n-   管理员创建群组时, 不再限制数量\n\n## 2021-2-28\n\n-   多个用户可以使用相同的 notification token\n\n## 2021-2-27\n\n-   修改 app 通知内容\n-   自己发送的消息不再推送通知给自己\n-   webpack 构建生成环境版本时显示进度条\n\n## 2021-2-25\n\n-   支持推送通知给 fiora app\n\n## 2021-2-21\n\n-   **重要** 修复错误的服务端判断管理员的逻辑, 会将所有人当做管理员. 但是前端并不会展示管理员面板\n\n## 2021-2-17\n\n-   支持向 fiora 外部分享群组\n\n## 2021-1-26\n\n-   文件消息的文件大小计算错误\n\n## 2021-1-22\n\n-   一个 ip 在 24 小时内只允许创建三个账号\n\n## 2020-12-17\n\n-   支持根据输入框内容自动搜索表情, 该功能默认是关闭的, 可以在用户设置中打开\n\n-   只限制发消息的频率\n\n## 2020-12-08\n\n-   **兼容性!!!** 使用 redis 缓存来替代内存缓存, 所以你需要在运行 fiora 之前配置并启动 redis\n\n## 2020-11-15\n\n-   使用 webpack 插件来生成 service worker script\n-   重构并新增服务端脚本\n\n## 2020-11-14\n\n-   适配 iOS 全面屏设备\n\n## 2020-11-12\n\n-   支持设置多个管理员\n\n## 2020-11-08\n\n-   支持撤回自己发的消息\n\n## 2020-11-07\n\n-   支持发送文件\n-   支持展示实时(数据有 60s 缓存时间)的群组在线人数和用户在线状态\n\n-   重构 webpack 构建配置\n\n-   修复在图片查看大图时右键会关闭的问题\n\n## 2020-11-04\n\n-   **兼容性!!!** 修改 config 文件配置方法, 不再支持通过命令行参数来设置\n\n## 2020-11-03\n\n-   重命名一部分 npm script 名\n"
  },
  {
    "path": "packages/docs/i18n/zh-Hans/docusaurus-plugin-content-docs/current/Config.md",
    "content": "---\nid: config\ntitle: 配置\nsidebar_label: 配置\n---\n\n服务器配置 `config/server.ts`\n客户端配置 `config/client.ts`\n\n相比于直接修改配置文件, 推荐用环境变量来修改配置  \n在 fiora 根目录创建 `.env` 文件, 在里面填写 `key=value` 键值对(每行一个), 即可修改相应配置. 比如修改端口号 `Port=8888`\n\n## 服务端配置\n\n**修改服务端配置需要重启应用**\n\n| Key                | 类型    | 默认值                             | 描述                                                                               |\n| ------------------ | ------- | ---------------------------------- | ---------------------------------------------------------------------------------- |\n| Host               | string  | your ip                            | 服务端 host                                                                        |\n| Port               | number  | 9200                               | 服务端端口号                                                                       |\n| Database           | string  | mongodb://localhost:27017/fiora    | mongoDB 数据库地址                                                                 |\n| RedisHost          | string  | localhost                          | redis 地址主机名                                                                   |\n| RedisPort          | number  | 6379                               | redis 端口                                                                         |\n| JwtSecret          | string  | jwtSecret (推荐修改它来保证安全性) | jwt token 加密 secret                                                              |\n| MaxGroupCount      | number  | 3                                  | 用户最大可以创建的群组个数                                                         |\n| AllowOrigin        | string  | null                               | 允许的客户端 origin 列表, null 时允许所有 origin 连接, 多个值逗号分割              |\n| tokenExpiresTime   | number  | 2592000000 (30 天)                 | 登陆 token 过期时间                                                                |\n| Administrator      | string  | ''                                 | 管理员用户 id 列表, 多个值逗号分割                                                 |\n| DisableRegister    | boolean | false                              | 禁止注册账号                                                                       |\n| DisableCreateGroup | boolean | false                              | 禁止创建群组                                                                       |\n| ALIYUN_OSS         | boolean | false                              | 启用阿里云 OSS                                                                     |\n| ACCESS_KEY_ID      | string  | ''                                 | 阿里云 OSS access key id. 参考: https://help.aliyun.com/document_detail/48699.html |\n| ACCESS_KEY_SECRET  | string  | ''                                 | 阿里云 OSS access key secret. 参考和 ACCESS_KEY_ID 相同                            |\n| ROLE_ARN           | string  | ''                                 | 阿里云 OSS RoleARN. 参考: https://help.aliyun.com/document_detail/28649.html       |\n| REGION             | string  | ''                                 | 阿里云 OSS 地域. 例如: `oss-cn-zhangjiakou`                                        |\n| BUCKET             | string  | ''                                 | 阿里云 OSS bucket 名称                                                             |\n| ENDPOINT           | string  | ''                                 | 阿里云 OSS 域名. 例如: `cdn.suisuijiang.com`                                       |\n\n## 客户端配置\n\n**修改客户端配置需要重新构建客户端**\n\n| Key                    | 类型    | 默认值          | 描述                                               |\n| ---------------------- | ------- | --------------- | -------------------------------------------------- |\n| Server                 | string  | /               | 客户端要连接的服务端地址                           |\n| MaxImageSize           | number  | 3145728 (3MB)   | 客户端可以上传的最大图片大小                       |\n| MaxBackgroundImageSize | number  | 5242880 (5MB)   | 客户端可以上传的最大背景图大小                     |\n| MaxAvatarSize          | number  | 1572864 (1.5MB) | 客户端可以上传的最大头像图片大小                   |\n| MaxFileSize            | number  | 10485760 (10MB) | 客户端可以上传的最大文件大小                       |\n| DefaultTheme           | string  | cool            | 默认主题                                           |\n| Sound                  | string  | default         | 默认通知音                                         |\n| TagColorMode           | string  | fixedColor      | 默认标签颜色模式                                   |\n| FrontendMonitorAppId   | string  | fixedColor      | 岳鹰监控 appId <https://yueying.effirst.com/index> |\n| DisableDeleteMessage   | boolean | false           | 禁止用户撤回消息                                   |\n"
  },
  {
    "path": "packages/docs/i18n/zh-Hans/docusaurus-plugin-content-docs/current/FAQ.md",
    "content": "---\nid: faq\ntitle: 问题解答\nsidebar_label: 问题解答\n---\n\n## 如何设置管理员\n\n1. 获取用户 id, 参考 [getUserId](/docs/script#getuserid)\n2. 修改 `Administrator` 配置项, 改为上一步获取的 id\n3. 重启服务端\n\n## 如何修改默认群组名称\n\n参考 [updateDefaultGroupName](/docs/script#updatedefaultgroupname)\n\n##  如何自定义域名\n\n推荐使用 nginx 反向代理\n\n示例配置, **请修改注释项的配置**\n\n```\nserver {\n   listen 80;\n   # 修改为你的域名\n   server_name fiora.suisuijiang.com;\n\n   location / {\n      proxy_set_header   X-Real-IP        $remote_addr;\n      proxy_set_header   X-Forwarded-For  $proxy_add_x_forwarded_for;\n      proxy_set_header   Host             $http_host;\n      proxy_set_header   Upgrade          $http_upgrade;\n      proxy_set_header   X-NginX-Proxy    true;\n      proxy_set_header   Connection \"upgrade\";\n      proxy_http_version 1.1;\n      proxy_pass         http://localhost:9200;\n   }\n}\n```\n\n配置 HTTPS + HTTP 2.0\n\n```\nserver {\n   listen 80;\n   # 修改为你的域名\n   server_name fiora.suisuijiang.com;\n   return 301 https://fiora.suisuijiang.com$request_uri;\n}\nserver {\n   listen 443 ssl http2;\n   # 修改为你的域名\n   server_name  fiora.suisuijiang.com;\n\n   ssl on;\n   # 修改为你的ssl证书位置\n   ssl_certificate ./ssl/fiora.suisuijiang.com.crt;\n   ssl_certificate_key ./ssl/fiora.suisuijiang.com.key;\n   ssl_session_timeout 5m;\n   ssl_protocols TLSv1 TLSv1.1 TLSv1.2;\n   ssl_ciphers ECDHE-RSA-AES128-GCM-SHA256:HIGH:!aNULL:!MD5:!RC4:!DHE;\n   ssl_prefer_server_ciphers on;\n\n   location / {\n      proxy_set_header   X-Real-IP        $remote_addr;\n      proxy_set_header   X-Forwarded-For  $proxy_add_x_forwarded_for;\n      proxy_set_header   Host             $http_host;\n      proxy_set_header   Upgrade          $http_upgrade;\n      proxy_set_header   X-NginX-Proxy    true;\n      proxy_set_header   Connection \"upgrade\";\n      proxy_http_version 1.1;\n      proxy_pass         http://localhost:9200;\n   }\n}\n```\n\n## 如何禁止注册, 手动分配账号\n\n将 `DisableRegister` 配置项设置为 true, 重启服务器生效\n\n使用脚本手动注册新用户. 参考 [register](/docs/script#register)\n\n##  如何删除用户\n\n参考 [deleteUser](/docs/script#deleteuser)\n\n## 客户端报错 \"调用失败,处于萌新阶段\"\n\n为了避免新注册的用户乱发消息刷屏, 注册时间未满 24 小时的用户每分钟限制只能发 5 条消息\n\n## 执行命令时报错 \"Couldn't find a package.json file in xxx\"\n\n先 cd 到 fiora 根目录下, 再执行相应命令\n\n## 为什么修改配置不生效\n\n1. 先确认配置修改是否正确\n   - 如果是直接修改配置文件, 请确认修改的部分语法和格式正确\n   - 如果是通过 .env 文件修改配置, 请确认格式正确\n2. 修改配置后\n   - 如果修改的是服务端配置, 需要重启服务端\n   - 如果修改的是客户端配置, 需要重新构建客户端\n\n## 怎么重新构建客户端\n\n`yarn build:web`"
  },
  {
    "path": "packages/docs/i18n/zh-Hans/docusaurus-plugin-content-docs/current/Getting-Start.md",
    "content": "---\nid: getting-start\ntitle: 入门指南\nsidebar_label: 入门指南\n---\n\nimport useBaseUrl from '@docusaurus/useBaseUrl';\n\nfiora 是一款有趣的聊天应用. 基于 node.js, mongodb, react 和 socket.io 等技术开发\n\n该项目起始于 [2015-11-04](https://github.com/yinxin630/chatroom-with-sails/commit/0a032372727550b8b4087f24ac299de03b677b9f)\n\n在线地址: [https://fiora.suisuijiang.com/](https://fiora.suisuijiang.com/)  \n安卓/iOS app: [https://github.com/yinxin630/fiora-app](https://github.com/yinxin630/fiora-app)\n\n## 功能\n\n1. 注册账号并登录, 可以长久保存你的数据\n2. 加入现有群组或者创建自己的群组, 来和大家交流\n3. 和任意人私聊, 并添加其为好友\n4. 多种消息类型, 包括文本 / 表情 / 图片 / 代码 / 文件 / 命令, 还可以搜索表情包\n5. 当收到新消息时推送通知, 可以自定义通知铃声, 还可以把消息读出来\n6. 选择你喜欢的主题, 并且可以设置为任何你喜欢的壁纸以及主题颜色\n7. 设置管理员来管理用户\n\n## 运行截图\n\n<img src={useBaseUrl('img/screenshots/screenshot-pc.png')} alt=\"PC\" style={{'max-width':'800px'}} />\n<img src={useBaseUrl('img/screenshots/screenshot-phone.png')} alt=\"Phone\" height=\"667\" style={{'max-width':'667px'}} />\n\n## 目录结构\n\n    |-- [.githubb]                // github actions\n    |-- [.vscode]                 // vscode 工作区配置\n    |-- [bin]                     // 服务端脚本\n    |-- [build]                   // webpack 配置\n    |-- [client]                  // web 客户端\n    |-- [config]                  // 应用配置\n    |-- [dist]                    // 构建客户端输出目录\n    |-- [docs]                    // 文档\n    |-- [public]                  // 服务端静态资源\n    |-- [server]                  // 服务端\n    |-- [test]                    // 单元测试\n    |-- [types]                   // typescript 类型\n    |-- [utils]                   // 工具方法\n    |-- .babelrc                  // babel 配置\n    |-- .eslintignore             // eslint 忽略\n    |-- .eslintrc                 // eslint 配置\n    |-- .gitignore                // git 忽略\n    |-- .nodemonrc                // nodemon 配置\n    |-- .prettierrc               // prettier 配置\n    |-- Dockerfile                // docker 文件\n    |-- LICENSE                   // fiora 许可\n    |-- docker-compose.yaml       // docker compose 配置\n    |-- jest.*.sj                 // jest 配置\n    |-- package.json              // npm\n    |-- tsconfig.json             // typescript 配置\n    |-- yarn.lock                 // yarn\n    ...\n\n## 贡献代码\n\n如果你想要添加功能或者修复 BUG. 请遵守下列流程.\n\n1. fork 本仓库并克隆 fork 后的仓库到本地\n2. 安装依赖 `yarn install`\n3. 修改代码并确认无 bug\n4. 提交代码, 如果 eslint 有报错, 请修复后再次提交\n5. 创建一个 pull request\n"
  },
  {
    "path": "packages/docs/i18n/zh-Hans/docusaurus-plugin-content-docs/current/INSTALL.md",
    "content": "---\nid: install\ntitle: 安装\nsidebar_label: 安装\n---\n\n## 环境准备\n\n要运行 Fiora, 你需要 Node.js(推荐 v14 LTS 版本), MongoDB 和 redis\n\n-   安装 Node.js\n    -   官网 <http://nodejs.cn/download/>\n    -   更推荐使用 nvm 安装 Node.js\n        -   安装 nvm <https://github.com/nvm-sh/nvm#install--update-script>\n        -   通过 nvm 安装 Node.js <https://github.com/nvm-sh/nvm#usage>\n-   安装 MongoDB\n    -   官网 <https://docs.mongodb.com/manual/installation/#install-mongodb>\n-   安装 redis\n    -   官网 <https://redis.io/topics/quickstart>\n\n推荐在 Linux 或者 MacOS 系统上运行\n\n## 如何运行\n\n1. 克隆项目到本地 `git clone https://github.com/yinxin630/fiora.git -b master`\n2. 确保安装了 [yarn](https://www.npmjs.com/package/yarn), 如果没有安装请执行 `npm install -g yarn`\n3. 安装项目依赖 `yarn install`\n4. 构建客户端代码 `yarn build:web`\n5. 配置 JwtSecret `echo \"JwtSecret=<string>\" > .env2`. 要将 `<string>` 替换为一个秘密文本\n6. 启动服务端 `yarn start`\n7. 使用浏览器打开 `http://[ip地址]:[端口]`(比如 `http://127.0.0.1:9200`)\n\n### 在后台运行\n\n使用 `yarn start` 运行服务端会在断开 ssh 连接后停止运行, 推荐使用 pm2 来运行\n\n```bash\n# 安装 pm2\nnpm install -g pm2\n\n# 使用 pm2 运行 fiora\npm2 start yarn --name fiora -- start\n\n# 查看 pm2 应用状态\npm2 ls\n\n# 查看 pm2 fiora 日志\npm2 logs fiora\n```\n\n### 运行开发模式\n\n1. 启动服务端 `yarn dev:server`\n2. 启动客户端 `yarn dev:web`\n3. 使用浏览器打开 `http://localhost:8080`\n\n### docker 运行\n\n首先安装 docker <https://docs.docker.com/install/>\n\n#### 直接从 DockerHub 镜像运行\n\n```bash\n# 拉取 mongo\ndocker pull mongo\n\n# 拉取 redis\ndocker pull redis\n\n# 拉取 fiora\ndocker pull suisuijiang/fiora\n\n# 创建虚拟网络\ndocker network create fiora-network\n\n# 启动 mongodB\ndocker run --name fioradb -p 27017:27017 --network fiora-network mongo\n\n# 启动 redis\ndocker run --name fioraredis -p 6379:6379 --network fiora-network redis\n\n# 启动 fiora\ndocker run --name fiora -p 9200:9200 --network fiora-network -e Database=mongodb://fioradb:27017/fiora -e RedisHost=fioraredis suisuijiang/fiora\n```\n\n#### 本地构建镜像运行\n\n1. 克隆项目到本地 `git clone https://github.com/yinxin630/fiora.git -b master`\n2. 构建镜像 `docker-compose build --no-cache --force-rm`\n3. 运行 `docker-compose up`\n"
  },
  {
    "path": "packages/docs/i18n/zh-Hans/docusaurus-plugin-content-docs/current/Script.md",
    "content": "---\nid: script\ntitle: 脚本\nsidebar_label: 脚本\n---\n\nfiora 内置了一个命令行工具, 用来管理服务器. 执行 `fiora` 查看工具\n\n> **注意!**  这些脚本大多会直接修改数据库, 推荐(但非必需)提前备份数据库并停止服务端后再执行\n\n## deleteMessages\n\n`fiora deleteMessages`\n\n删除所有历史消息记录, 如果消息图片和文件是存储在服务器上, 也可以一并删除\n\n## deleteTodayRegisteredUsers\n\n`fiora deleteTodayRegisteredUsers`\n\n删除当天(以服务器时间为准)新注册的所有用户\n\n## deleteUser\n\n`fiora deleteUser [userId]`\n\n删除指定用户, 同时删除其历史消息, 退出其已加入的群组并删除其所有好友关系\n\n## doctor\n\n`fiora doctor`\n\n检查服务端配置和状态, 可以用来定位服务端启动失败的原因\n\n## fixUsersAvatar\n\n`fiora fixUsersAvatar`\n\n修复用户错误头像路径, 请根据你的实际情况修改脚本判断逻辑\n\n## getUserId\n\n`fiora getUserId [username]`\n\n获取指定用户名的 userId\n\n## register\n\n`fiora register [username] [password]`\n\n注册新用户, 当禁止注册时可以由管理员通过其注册新用户\n\n## updateDefaultGroupName\n\n`fiora updateDefaultGroupName [newName]`\n\n更新默认群组名"
  },
  {
    "path": "packages/docs/i18n/zh-Hans/docusaurus-theme-classic/footer.json",
    "content": "{\n    \"link.title.Docs\": {\n        \"message\": \"文档\"\n    },\n    \"link.item.label.Overview\": {\n        \"message\": \"首页\"\n    },\n    \"link.item.label.Getting Start\": {\n        \"message\": \"入门指南\"\n    },\n    \"link.item.label.Change Log\": {\n        \"message\": \"更新日志\"\n    },\n\n    \"link.title.Community\": {\n        \"message\": \"社区\"\n    },\n    \"link.item.label.Feedback\": {\n        \"message\": \"加入聊天群\"\n    },\n    \"link.item.label.Issues\": {\n        \"message\": \"Bug 反馈\"\n    },\n\n    \"link.title.More\": {\n        \"message\": \"更多\"\n    },\n    \"link.item.label.Author\": {\n        \"message\": \"关于作者\"\n    },\n    \"link.item.label.GitHub\": {\n        \"message\": \"GitHub\"\n    }\n}\n"
  },
  {
    "path": "packages/docs/i18n/zh-Hans/docusaurus-theme-classic/navbar.json",
    "content": "{\n    \"item.label.Docs\": {\n        \"message\": \"文档\"\n    }\n}\n"
  },
  {
    "path": "packages/docs/package.json",
    "content": "{\n  \"name\": \"@fiora/docs\",\n  \"version\": \"0.0.0\",\n  \"private\": true,\n  \"scripts\": {\n    \"docusaurus\": \"docusaurus\",\n    \"dev:docs\": \"docusaurus start\",\n    \"build:docs\": \"docusaurus build\",\n    \"swizzle\": \"docusaurus swizzle\",\n    \"deploy:docs\": \"docusaurus deploy\",\n    \"serve\": \"docusaurus serve\",\n    \"clear\": \"docusaurus clear\",\n    \"write-translations\": \"docusaurus write-translations\"\n  },\n  \"dependencies\": {\n    \"@docusaurus/core\": \"^2.0.0-alpha.71\",\n    \"@docusaurus/preset-classic\": \"^2.0.0-alpha.71\",\n    \"@mdx-js/react\": \"^1.6.21\",\n    \"clsx\": \"^1.1.1\",\n    \"react\": \"^16.8.4\",\n    \"react-dom\": \"^16.8.4\"\n  },\n  \"browserslist\": {\n    \"production\": [\n      \">0.5%\",\n      \"not dead\",\n      \"not op_mini all\"\n    ],\n    \"development\": [\n      \"last 1 chrome version\",\n      \"last 1 firefox version\",\n      \"last 1 safari version\"\n    ]\n  }\n}\n"
  },
  {
    "path": "packages/docs/sidebars.js",
    "content": "module.exports = {\n    docs: {\n        fiora: ['getting-start', 'install', 'config', 'script', 'faq', 'changelog'],\n        'fiora-app': ['app'],\n    },\n};\n"
  },
  {
    "path": "packages/docs/src/css/custom.css",
    "content": "/* stylelint-disable docusaurus/copyright-header */\n/**\n * Any CSS included here will be global. The classic template\n * bundles Infima by default. Infima is a CSS framework designed to\n * work well for content-centric websites.\n */\n\n/* You can override the default Infima variables here. */\n:root {\n  --ifm-color-primary: #25c2a0;\n  --ifm-color-primary-dark: rgb(33, 175, 144);\n  --ifm-color-primary-darker: rgb(31, 165, 136);\n  --ifm-color-primary-darkest: rgb(26, 136, 112);\n  --ifm-color-primary-light: rgb(70, 203, 174);\n  --ifm-color-primary-lighter: rgb(102, 212, 189);\n  --ifm-color-primary-lightest: rgb(146, 224, 208);\n  --ifm-code-font-size: 95%;\n}\n\n.docusaurus-highlight-code-line {\n  background-color: rgb(72, 77, 91);\n  display: block;\n  margin: 0 calc(-1 * var(--ifm-pre-padding));\n  padding: 0 var(--ifm-pre-padding);\n}\n"
  },
  {
    "path": "packages/docs/src/pages/index.js",
    "content": "import React from 'react';\nimport clsx from 'clsx';\nimport Layout from '@theme/Layout';\nimport Link from '@docusaurus/Link';\nimport useDocusaurusContext from '@docusaurus/useDocusaurusContext';\nimport useBaseUrl from '@docusaurus/useBaseUrl';\nimport Translate, { translate } from '@docusaurus/Translate';\nimport styles from './styles.module.css';\n\nconst features = [\n    {\n        title: 'Richness',\n        imageUrl: 'img/website-app.png',\n        description: translate({\n            message: 'Richness',\n        }),\n    },\n    {\n        title: 'Cross Platform',\n        imageUrl: 'img/cross-platform.png',\n        description: translate({\n            message: 'Cross Platform',\n        }),\n    },\n    {\n        title: 'Open Source',\n        imageUrl: 'img/open-source.png',\n        description: translate({\n            message: 'Open Source',\n        }),\n    },\n];\n\nfunction Feature({ imageUrl, title, description }) {\n    const imgUrl = useBaseUrl(imageUrl);\n    return (\n        <div className={clsx('col col--4', styles.feature)}>\n            {imgUrl && (\n                <div className=\"text--center\">\n                    <img className={styles.featureImage} src={imgUrl} alt={title} />\n                </div>\n            )}\n            <h3 className=\"text--center\">{title}</h3>\n            <p className={clsx('text--center', styles.featureDescription)}>{description}</p>\n        </div>\n    );\n}\n\nconst descriptions = [\n    {\n        title: translate({ message: 'Join Chat Title' }),\n        content: translate({ message: 'Join Chat Content' }),\n        image: `img/undraw_youtube_tutorial.svg`,\n        imageAlign: 'right',\n    },\n    {\n        title: translate({ message: 'Rich Feature Title' }),\n        content: translate({ message: 'Rich Feature Content' }),\n        image: `img/undraw_note_list.svg`,\n        imageAlign: 'left',\n    },\n    {\n        title: translate({ message: 'Deploy By Yourself Title' }),\n        content: translate({ message: 'Deploy By Yourself Content' }),\n        image: `img/undraw_code_review.svg`,\n        imageAlign: 'right',\n    },\n];\n\nfunction Description({ title, content, image, index }) {\n    return (\n        <div className={clsx(styles.description, index % 2 === 0 && styles.lightBackground)}>\n            <div className={clsx(styles.descriptionContent, index % 2 === 1 && styles.rightImage)}>\n                <div className={clsx('col col--6', 'text--center')}>\n                    <img className={styles.descriptionImage} src={image} alt={title} />\n                </div>\n                <div className={clsx('col col--6')}>\n                    <h3 className=\"text--center\">{title}</h3>\n                    <p className=\"text--center\">{content}</p>\n                </div>\n            </div>\n        </div>\n    );\n}\n\nfunction DeployByYourself({ url }) {\n    return (\n        <div className={styles.deployByYourself}>\n            <h2 className={styles.deployTitle}>{translate({ message: 'Interested' })}</h2>\n            <div>\n                <Link\n                    className={clsx(\n                        'button button--outline button--secondary button--lg',\n                        styles.getStarted,\n                    )}\n                    to={url}\n                >\n                    {translate({ message: 'Getting Start' })}\n                </Link>\n            </div>\n        </div>\n    );\n}\n\nfunction Home() {\n    const context = useDocusaurusContext();\n    const { siteConfig = {} } = context;\n\n    const title = translate({ message: 'Title' });\n    const tagLine = translate({ message: 'TagLine' });\n    const keywords = translate({ message: 'Keywords' });\n    const description = translate({ message: 'Description' });\n    const docsUrl = translate({ message: 'DocsUrl' });\n\n    return (\n        <Layout title={tagLine} keywords={keywords} description={description}>\n            <header className={clsx('hero hero--primary', styles.heroBanner)}>\n                <div className=\"container\">\n                    <h1 className=\"hero__title\">{title}</h1>\n                    <p className=\"hero__subtitle\">{tagLine}</p>\n                    <div>\n                        <iframe\n                            className={styles.starIframe}\n                            src=\"https://ghbtns.com/github-btn.html?user=yinxin630&repo=fiora&type=star&count=true&size=large\"\n                            width={160}\n                            height={30}\n                            title=\"GitHub Stars\"\n                        />\n                    </div>\n                    <div className={styles.buttons}>\n                        <Link\n                            className={clsx(\n                                'button button--outline button--secondary button--lg',\n                                styles.heroButton,\n                            )}\n                            to={siteConfig.url}\n                        >\n                            {translate({ message: 'Try It Now' })}\n                        </Link>\n                        <Link\n                            className={clsx(\n                                'button button--outline button--secondary button--lg',\n                                styles.heroButton,\n                            )}\n                            to={docsUrl}\n                        >\n                            {translate({ message: 'View Docs' })}\n                        </Link>\n                    </div>\n                </div>\n            </header>\n            <main>\n                {features && features.length > 0 && (\n                    <section className={styles.features}>\n                        <div className=\"container\">\n                            <div className=\"row\">\n                                {features.map((props, idx) => (\n                                    <Feature key={idx} {...props} />\n                                ))}\n                            </div>\n                        </div>\n                    </section>\n                )}\n                {descriptions && descriptions.length > 0 && (\n                    <section className={styles.descriptions}>\n                        <div className={clsx('row', styles.descriptionRow)}>\n                            {descriptions.map((props, idx) => (\n                                <Description key={idx} {...props} index={idx} />\n                            ))}\n                        </div>\n                    </section>\n                )}\n                <section className={styles.descriptions}>\n                    <div className={clsx('row', styles.descriptionRow)}>\n                        <DeployByYourself url={`${siteConfig.baseUrl}docs/getting-start`} />\n                    </div>\n                </section>\n            </main>\n        </Layout>\n    );\n}\n\nexport default Home;\n"
  },
  {
    "path": "packages/docs/src/pages/styles.module.css",
    "content": "/* stylelint-disable docusaurus/copyright-header */\n\n/**\n * CSS files with the .module.css suffix will be treated as CSS modules\n * and scoped locally.\n */\n\n.heroBanner {\n    padding: 4rem 0;\n    text-align: center;\n    position: relative;\n    overflow: hidden;\n}\n\n@media screen and (max-width: 966px) {\n    .heroBanner {\n        padding: 2rem;\n    }\n}\n\n.buttons {\n    display: flex;\n    align-items: center;\n    justify-content: center;\n}\n\n.features {\n    display: flex;\n    align-items: center;\n    padding: 2rem 0;\n    width: 100%;\n}\n\n.featureImage {\n    height: 120px;\n    width: 200px;\n    background-color: white;\n}\n\n.featureDescription {\n    max-width: 300px;\n    margin: 0 auto;\n}\n\n.starIframe {\n    border: none;\n    margin-bottom: 30px;\n}\n\n.descriptions {\n}\n\n.descriptionRow {\n    width: 100%;\n    margin: 0;\n}\n\n.description {\n    width: 100%;\n    min-height: 300px;\n}\n\n.descriptionContent {\n    display: flex;\n    align-items: center;\n    max-width: var(--ifm-container-width);\n    margin: 0 auto;\n    padding: 0 var(--ifm-spacing-horizontal);\n    width: 100%;\n    height: 100%;\n}\n\n.descriptionImage {\n    height: auto;\n    width: 280px;\n}\n\n.lightBackground {\n    background-color: #f6f6f6;\n}\n\n.rightImage {\n    flex-direction: row-reverse;\n}\n\n.deployByYourself {\n    width: 100%;\n    min-height: 200px;\n    display: flex;\n    flex-direction: column;\n    align-items: center;\n    justify-content: center;\n}\n\n.deployTitle {\n    color: var(--ifm-color-primary);\n}\n\n.heroButton {\n    margin: 0 10px;\n}\n"
  },
  {
    "path": "packages/docs/static/.nojekyll",
    "content": ""
  },
  {
    "path": "packages/i18n/en-US/bin.ts",
    "content": "export const getUserIdDescription = 'Get user id by username';\n\nexport const registerDescription = 'Register a new user';\n\nexport const deleteUserDescription = 'Delete a user';\n\nexport const fixUsersAvatarDescription = \"Fix user's wrong avatar\";\n\nexport const deleteTodayRegisteredUsersDescription =\n    'Delete all newly created users today';\n\nexport const deleteMessagesDescription = 'Delete all messages';\n\nexport const updateDefaultGroupNameDescription =\n    'Modify the name of the default group';\n\nexport const doctorDescription =\n    'Run doctor to diagnose environment and configuration issues';\n"
  },
  {
    "path": "packages/i18n/en-US/index.ts",
    "content": "export * from './bin';\n"
  },
  {
    "path": "packages/i18n/node.index.ts",
    "content": "import osLocale from 'os-locale';\n\nimport * as zhCN from './zh-CN';\nimport * as enUS from './en-US';\n\nconst languages = {\n    'zh-CN': zhCN,\n    'en-US': enUS,\n};\n\nconst locale = osLocale.sync() || 'en-US';\n\nexport default function i18n(key: keyof typeof enUS | keyof typeof zhCN) {\n    // @ts-ignore\n    return languages[locale][key] || enUS[key] || key;\n}\n"
  },
  {
    "path": "packages/i18n/package.json",
    "content": "{\n  \"name\": \"@fiora/i18n\",\n  \"version\": \"1.0.0\",\n  \"license\": \"MIT\",\n  \"private\": true,\n  \"dependencies\": {\n    \"os-locale\": \"^5.0.0\"\n  }\n}\n"
  },
  {
    "path": "packages/i18n/zh-CN/bin.ts",
    "content": "export const getUserIdDescription = '通过用户名获取 user id';\n\nexport const registerDescription = '注册新用户';\n\nexport const deleteUserDescription = '删除用户';\n\nexport const fixUsersAvatarDescription = '修复用户错误的头像';\n\nexport const deleteTodayRegisteredUsersDescription = '删除所有今天创建的新用户';\n\nexport const deleteMessagesDescription = '删除所有消息';\n\nexport const updateDefaultGroupNameDescription = '修改默认群组名称';\n\nexport const doctorDescription = '运行诊断工具检查环境和配置问题';\n"
  },
  {
    "path": "packages/i18n/zh-CN/index.ts",
    "content": "export * from './bin';\n"
  },
  {
    "path": "packages/server/.nodemonrc",
    "content": "{\n    \"verbose\": true,\n    \"ignore\": [\n        \"test/**/*\",\n        \"public/**/*\"\n    ]\n}"
  },
  {
    "path": "packages/server/package.json",
    "content": "{\n  \"name\": \"@fiora/server\",\n  \"version\": \"1.0.0\",\n  \"license\": \"MIT\",\n  \"private\": true,\n  \"scripts\": {\n    \"start\": \"cross-env NODE_ENV=production DOTENV_CONFIG_PATH=../../.env ts-node -r dotenv/config --transpile-only src/main.ts\",\n    \"dev:server\": \"cross-env NODE_ENV=development DOTENV_CONFIG_PATH=../../.env nodemon src/main.ts --exec \\\"ts-node --files -r dotenv/config\\\" --config .nodemonrc --watch ../\"\n  },\n  \"dependencies\": {\n    \"@fiora/bin\": \"^1.0.0\",\n    \"@fiora/config\": \"^1.0.0\",\n    \"@fiora/database\": \"^1.0.0\",\n    \"@fiora/utils\": \"^1.0.0\",\n    \"ali-oss\": \"^6.16.0\",\n    \"axios\": \"^0.21.1\",\n    \"bcryptjs\": \"^2.4.3\",\n    \"expo-server-sdk\": \"^3.6.0\",\n    \"jwt-simple\": \"^0.5.6\",\n    \"koa\": \"^2.13.1\",\n    \"koa-router\": \"^10.0.0\",\n    \"koa-send\": \"^5.0.1\",\n    \"koa-static\": \"^5.0.0\",\n    \"regex-escape\": \"^3.4.10\",\n    \"socket.io\": \"^4.1.3\",\n    \"string-hash\": \"^1.1.3\",\n    \"ts-jest\": \"^27.0.3\"\n  },\n  \"devDependencies\": {\n    \"@types/ali-oss\": \"^6.0.10\",\n    \"@types/bcryptjs\": \"^2.4.2\",\n    \"@types/koa\": \"^2.13.4\",\n    \"@types/koa-router\": \"^7.4.4\",\n    \"@types/koa-send\": \"^4.1.3\",\n    \"@types/koa-static\": \"^4.0.2\",\n    \"@types/string-hash\": \"^1.1.1\",\n    \"cross-env\": \"^7.0.3\",\n    \"dotenv\": \"^10.0.0\",\n    \"nodemon\": \"^2.0.12\"\n  }\n}\n"
  },
  {
    "path": "packages/server/public/PrivacyPolicy.html",
    "content": "<!DOCTYPE html>\n<html>\n    <head>\n        <link\n            href=\"https://fonts.googleapis.com/css?family=Roboto:100,300,400,500,700,900\"\n            rel=\"stylesheet\"\n        />\n        <link\n            href=\"https://cdn.jsdelivr.net/npm/@mdi/font@4.x/css/materialdesignicons.min.css\"\n            rel=\"stylesheet\"\n        />\n        <link\n            href=\"https://cdn.jsdelivr.net/npm/vuetify@2.x/dist/vuetify.min.css\"\n            rel=\"stylesheet\"\n        />\n        <meta\n            name=\"viewport\"\n            content=\"width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, minimal-ui\"\n        />\n        <meta charset=\"utf-8\" />\n        <title>fiora 隐私政策</title>\n    </head>\n    <body>\n        <div id=\"app\">\n            <v-app>\n                <v-content>\n                    <v-container\n                        ><div data-v-ad817de0=\"\" class=\"v-card__title text-h4 my-4 mx-2\">\n                            fiora 隐私政策\n                        </div>\n                        <div\n                            data-v-ad817de0=\"\"\n                            class=\"ml-6 mr-2 text-body-1 mb-4\"\n                            style=\"text-indent: 2em\"\n                        >\n                            欢迎您访问我们的产品。\n                            <strong data-v-ad817de0=\"\"> fiora </strong\n                            >（包括App等产品提供的服务，以下简称“产品和服务”）是由\n                            <strong data-v-ad817de0=\"\"> 碎碎酱:尹鑫 </strong\n                            >（以下简称“我们”）开发并运营的。\n                            确保用户的数据安全和隐私保护是我们的首要任务，\n                            本隐私政策载明了您访问和使用我们的产品和服务时所收集的数据及其处理方式。\n                        </div>\n                        <div\n                            data-v-ad817de0=\"\"\n                            class=\"ml-6 mr-2 text-body-1 mb-4\"\n                            style=\"text-indent: 2em\"\n                        >\n                            请您在继续使用我们的产品前务必认真仔细阅读并确认充分理解本隐私政策全部规则和要点，\n                            一旦您选择使用，即视为您同意本隐私政策的全部内容，同意我们按其收集和使用您的相关信息。\n                            如您在在阅读过程中，对本政策有任何疑问，可联系我们的客服咨询， 请通过\n                            <strong data-v-ad817de0=\"\"> yinxin630@gmail.com </strong\n                            >或产品中的反馈方式与我们取得联系。\n                            如您不同意相关协议或其中的任何条款的，您应停止使用我们的产品和服务。\n                        </div>\n                        <div\n                            data-v-ad817de0=\"\"\n                            class=\"ml-6 mr-2 text-body-1 mb-4\"\n                            style=\"text-indent: 2em\"\n                        >\n                            本隐私政策帮助您了解以下内容：\n                            <div data-v-ad817de0=\"\" class=\"mt-2\">\n                                一、我们如何收集和使用您的个人信息；\n                            </div>\n                            <div data-v-ad817de0=\"\">二、我们如何存储和保护您的个人信息；</div>\n                            <div data-v-ad817de0=\"\">\n                                三、我们如何共享、转让、公开披露您的个人信息；\n                            </div>\n                            <div data-v-ad817de0=\"\">四、我们如何使用 Cookie 和其他追踪技术；</div>\n                            <!---->\n                        </div>\n                        <div data-v-ad817de0=\"\" class=\"ml-6 mr-2 text-h6 mb-4\">\n                            一、我们如何收集和使用您的个人信息\n                        </div>\n                        <div\n                            data-v-ad817de0=\"\"\n                            class=\"ml-6 mr-2 text-body-1 mb-4\"\n                            style=\"text-indent: 2em\"\n                        >\n                            个人信息是指以电子或者其他方式记录的能够单独或者与其他信息，\n                            结合识别特定自然人身份或者反映特定自然人活动情况的各种信息。\n                            我们根据《中华人民共和国网络安全法》和《信息安全技术个人信息安全规范》（GB/T\n                            35273-2017）\n                            以及其它相关法律法规的要求，并严格遵循正当、合法、必要的原则，\n                            出于您使用我们提供的服务和/或产品等过程中而收集和使用您的个人信息。\n                        </div>\n                        <div\n                            data-v-ad817de0=\"\"\n                            class=\"ml-6 mr-2 text-body-1 mb-4\"\n                            style=\"text-indent: 2em\"\n                        >\n                            为接受我们全面的产品服务，您应首先注册一个用户账号，我们将通过它记录相关的数据。\n                            您所提供的所有信息均来自于您本人在注册时提供的数据。\n                            您准备使用的账户名、密码、您本人的联系方式，\n                            我们可能通过发短信或者邮件的方式来验证您的身份是否有效。\n                        </div>\n                        <!---->\n                        <div data-v-ad817de0=\"\" class=\"ml-6 mr-2 text-h6 mb-4\">\n                            二、我们如何存储和保护您的个人信息\n                        </div>\n                        <div\n                            data-v-ad817de0=\"\"\n                            class=\"ml-6 mr-2 text-body-1 mb-4\"\n                            style=\"text-indent: 2em\"\n                        >\n                            作为一般规则，我们仅在实现信息收集目的所需的时间内保留您的个人信息。\n                            我们会在对于管理与您之间的关系严格必要的时间内保留您的个人信息\n                            （例如，当您开立帐户，从我们的产品获取服务时）。\n                            出于遵守法律义务或为证明某项权利或合同满足适用的诉讼时效要求的目的，\n                            我们可能需要在上述期限到期后保留您存档的个人信息，并且无法按您的要求删除。\n                            <span data-v-ad817de0=\"\">\n                                当您的个人信息对于我们的法定义务或法定时效对应的目的或档案不再必要时，\n                                我们确保将其完全删除或匿名化。 </span\n                            ><!---->\n                        </div>\n                        <div\n                            data-v-ad817de0=\"\"\n                            class=\"ml-6 mr-2 text-body-1 mb-4\"\n                            style=\"text-indent: 2em\"\n                        >\n                            我们使用符合业界标准的安全防护措施保护您提供的个人信息，并加密其中的关键数据，\n                            防止其遭到未经授权访问、公开披露、使用、修改、损坏或丢失。我们会采取一切合理可行的措施，保护您的个人信息。\n                            我们会使用加密技术确保数据的保密性；我们会使用受信赖的保护机制防止数据遭到恶意攻击。\n                        </div>\n                        <!---->\n                        <div data-v-ad817de0=\"\" class=\"ml-6 mr-2 text-h6 mb-4\">\n                            三、我们如何共享、转让、公开披露您的个人信息\n                        </div>\n                        <div\n                            data-v-ad817de0=\"\"\n                            class=\"ml-6 mr-2 text-body-1 mb-4\"\n                            style=\"text-indent: 2em\"\n                        >\n                            在管理我们的日常业务活动所需要时，为追求合法利益以更好地服务客户，\n                            我们将合规且恰当的使用您的个人信息。出于对业务和各个方面的综合考虑，\n                            我们仅自身使用这些数据，不与任何第三方分享。\n                        </div>\n                        <div\n                            data-v-ad817de0=\"\"\n                            class=\"ml-6 mr-2 text-body-1 mb-4\"\n                            style=\"text-indent: 2em\"\n                        >\n                            我们可能会根据法律法规规定，或按政府主管部门的强制性要求，对外共享您的个人信息。\n                            在符合法律法规的前提下，当我们收到上述披露信息的请求时，我们会要求必须出具与之相应的法律文件，如传票或调查函。\n                            我们坚信，对于要求我们提供的信息，应该在法律允许的范围内尽可能保持透明。\n                        </div>\n                        <div\n                            data-v-ad817de0=\"\"\n                            class=\"ml-6 mr-2 text-body-1 mb-4\"\n                            style=\"text-indent: 2em\"\n                        >\n                            在以下情形中，共享、转让、公开披露您的个人信息无需事先征得您的授权同意：\n                            <div data-v-ad817de0=\"\" class=\"mt-2\">\n                                1、与国家安全、国防安全直接相关的；\n                            </div>\n                            <div data-v-ad817de0=\"\">\n                                2、与犯罪侦查、起诉、审判和判决执行等直接相关的；\n                            </div>\n                            <div data-v-ad817de0=\"\">\n                                3、出于维护您或其他个人的生命、财产等重大合法权益但又很难得到本人同意的；\n                            </div>\n                            <div data-v-ad817de0=\"\">4、您自行向社会公众公开的个人信息；</div>\n                            <div data-v-ad817de0=\"\">\n                                5、从合法公开披露的信息中收集个人信息的，如合法的新闻报道、政府信息公开等渠道。\n                            </div>\n                            <div data-v-ad817de0=\"\">\n                                6、根据个人信息主体要求签订和履行合同所必需的；\n                            </div>\n                            <div data-v-ad817de0=\"\">\n                                7、用于维护所提供的产品或服务的安全稳定运行所必需的，例如发现、处置产品或服务的故障；\n                            </div>\n                            <div data-v-ad817de0=\"\">8、法律法规规定的其他情形。</div>\n                        </div>\n                        <div data-v-ad817de0=\"\" class=\"ml-6 mr-2 text-h6 mb-4\">\n                            四、我们如何使用 Cookie 和其他追踪技术\n                        </div>\n                        <div\n                            data-v-ad817de0=\"\"\n                            class=\"ml-6 mr-2 text-body-1 mb-4\"\n                            style=\"text-indent: 2em\"\n                        >\n                            为确保产品正常运转，我们会在您的计算机或移动设备上存储名为 Cookie\n                            的小数据文件。 Cookie 通常包含标识符、产品名称以及一些号码和字符。\n                            借助于\n                            Cookie，我们能够存储您的偏好或商品等数据，并用以判断注册用户是否已经登录，\n                            提升服务和产品质量及优化用户体验。\n                        </div>\n                        <div\n                            data-v-ad817de0=\"\"\n                            class=\"ml-6 mr-2 text-body-1 mb-4\"\n                            style=\"text-indent: 2em\"\n                        >\n                            我们出于不同的目的使用各种Cookie，包括：严格必要型Cookie、性能Cookie、营销Cookie和功能Cookie。\n                            某些Cookie可能由外部第三方提供，以向我们的产品提供其它功能。 我们不会将\n                            Cookie 用于本政策所述目的之外的任何用途。您可根据自己的偏好管理或删除\n                            Cookie。 您可以清除计算机上或手机中保存的所有\n                            Cookie，大部分网络浏览器都设有阻止或禁用 Cookie 的功能，\n                            您可对浏览器进行配置。阻止或禁用 Cookie\n                            功能后，可能影响您使用或不能充分使用我们的产品和服务。\n                        </div>\n                        <!----><!----><!---->\n                    </v-container>\n                </v-content>\n            </v-app>\n        </div>\n        <script src=\"https://cdn.jsdelivr.net/npm/vue@2.x/dist/vue.js\"></script>\n        <script src=\"https://cdn.jsdelivr.net/npm/vuetify@2.x/dist/vuetify.js\"></script>\n        <script>\n            new Vue({\n                el: '#app',\n                vuetify: new Vuetify(),\n            });\n        </script>\n    </body>\n</html>\n"
  },
  {
    "path": "packages/server/public/index.html",
    "content": "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n    <meta charset=\"UTF-8\">\n    <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n    <meta http-equiv=\"X-UA-Compatible\" content=\"ie=edge\">\n    <title>默认首页</title>\n</head>\n<body>\n    <h1>请执行 \"yarn build:web\" 构建前端页面</h1>\n</body>\n</html>\n"
  },
  {
    "path": "packages/server/public/manifest.json",
    "content": "{\n    \"name\": \"fiora\",\n    \"short_name\": \"fiora\",\n    \"start_url\": \"/\",\n    \"display\": \"standalone\",\n    \"background_color\": \"#FAFAFA\",\n    \"description\": \"一个神奇的聊天室\",\n    \"orientation\": \"portrait-primary\",\n    \"theme_color\": \"#4a90e2\",\n    \"icons\": [\n        {\n            \"src\": \"/favicon-96.png\",\n            \"sizes\": \"96x96\",\n            \"type\": \"image/png\"\n        },\n        {\n            \"src\": \"/favicon-192.png\",\n            \"sizes\": \"192x192\",\n            \"type\": \"image/png\"\n        },\n        {\n            \"src\": \"/favicon-512.png\",\n            \"sizes\": \"512x512\",\n            \"type\": \"image/png\"\n        }\n    ]\n}\n"
  },
  {
    "path": "packages/server/src/app.ts",
    "content": "import Koa from 'koa';\nimport koaSend from 'koa-send';\nimport koaStatic from 'koa-static';\nimport path from 'path';\nimport http from 'http';\nimport { Server } from 'socket.io';\n\nimport logger from '@fiora/utils/logger';\nimport config from '@fiora/config/server';\nimport { getSocketIp } from '@fiora/utils/socket';\nimport SocketModel, {\n    SocketDocument,\n} from '@fiora/database/mongoose/models/socket';\n\nimport seal from './middlewares/seal';\nimport frequency from './middlewares/frequency';\nimport isLogin from './middlewares/isLogin';\nimport isAdmin from './middlewares/isAdmin';\n\nimport * as userRoutes from './routes/user';\nimport * as groupRoutes from './routes/group';\nimport * as messageRoutes from './routes/message';\nimport * as systemRoutes from './routes/system';\nimport * as notificationRoutes from './routes/notification';\nimport * as historyRoutes from './routes/history';\nimport registerRoutes from './middlewares/registerRoutes';\n\nconst app = new Koa();\napp.proxy = true;\n\nconst httpServer = http.createServer(app.callback());\nconst io = new Server(httpServer, {\n    cors: {\n        origin: config.allowOrigin || '*',\n        credentials: true,\n    },\n    pingTimeout: 10000,\n    pingInterval: 5000,\n});\n\n// serve index.html\napp.use(async (ctx, next) => {\n    if (\n        /\\/invite\\/group\\/[\\w\\d]+/.test(ctx.request.url) ||\n        !/(\\.)|(\\/invite\\/group\\/[\\w\\d]+)/.test(ctx.request.url)\n    ) {\n        await koaSend(ctx, 'index.html', {\n            root: path.join(__dirname, '../public'),\n            maxage: 1000 * 60 * 60 * 24 * 7,\n            gzip: true,\n        });\n    } else {\n        await next();\n    }\n});\n\n// serve public static files\napp.use(\n    koaStatic(path.join(__dirname, '../public'), {\n        maxAge: 1000 * 60 * 60 * 24 * 7,\n        gzip: true,\n    }),\n);\n\nconst routes: Routes = {\n    ...userRoutes,\n    ...groupRoutes,\n    ...messageRoutes,\n    ...systemRoutes,\n    ...notificationRoutes,\n    ...historyRoutes,\n};\nObject.keys(routes).forEach((key) => {\n    if (key.startsWith('_')) {\n        routes[key] = null;\n    }\n});\n\nio.on('connection', async (socket) => {\n    const ip = getSocketIp(socket);\n    logger.trace(`connection ${socket.id} ${ip}`);\n    await SocketModel.create({\n        id: socket.id,\n        ip,\n    } as SocketDocument);\n\n    socket.on('disconnect', async () => {\n        logger.trace(`disconnect ${socket.id}`);\n        await SocketModel.deleteOne({\n            id: socket.id,\n        });\n    });\n\n    socket.use(seal(socket));\n    socket.use(isLogin(socket));\n    socket.use(isAdmin(socket));\n    socket.use(frequency(socket));\n    socket.use(registerRoutes(socket, routes));\n});\n\nexport default httpServer;\n"
  },
  {
    "path": "packages/server/src/main.ts",
    "content": "import config from '@fiora/config/server';\nimport getRandomAvatar from '@fiora/utils/getRandomAvatar';\nimport { doctor } from '@fiora/bin/scripts/doctor';\nimport logger from '@fiora/utils/logger';\nimport initMongoDB from '@fiora/database/mongoose/initMongoDB';\nimport Socket from '@fiora/database/mongoose/models/socket';\nimport Group, { GroupDocument } from '@fiora/database/mongoose/models/group';\nimport app from './app';\n\n(async () => {\n    if (process.argv.find((argv) => argv === '--doctor')) {\n        await doctor();\n    }\n\n    await initMongoDB();\n\n    // 判断默认群是否存在, 不存在就创建一个\n    const group = await Group.findOne({ isDefault: true });\n    if (!group) {\n        const defaultGroup = await Group.create({\n            name: 'fiora',\n            avatar: getRandomAvatar(),\n            isDefault: true,\n        } as GroupDocument);\n\n        if (!defaultGroup) {\n            logger.error('[defaultGroup]', 'create default group fail');\n            return process.exit(1);\n        }\n    }\n\n    app.listen(config.port, async () => {\n        await Socket.deleteMany({}); // 删除Socket表所有历史数据\n        logger.info(`>>> server listen on http://localhost:${config.port}`);\n    });\n\n    return null;\n})();\n"
  },
  {
    "path": "packages/server/src/middlewares/frequency.ts",
    "content": "import { Socket } from 'socket.io';\nimport {\n    getNewUserKey,\n    getSealUserKey,\n    Redis,\n} from '@fiora/database/redis/initRedis';\n\nexport const CALL_SERVICE_FREQUENTLY = '发消息过于频繁, 请冷静一会再试';\nexport const NEW_USER_CALL_SERVICE_FREQUENTLY =\n    '发消息过于频繁, 你还处于萌新期, 不要恶意刷屏, 先冷静一会再试';\n\nconst MaxCallPerMinutes = 20;\nconst NewUserMaxCallPerMinutes = 5;\nconst ClearDataInterval = 60000;\n\nconst AutoSealDuration = 5; // minutes\n\ntype Options = {\n    maxCallPerMinutes?: number;\n    newUserMaxCallPerMinutes?: number;\n    clearDataInterval?: number;\n};\n\n/**\n * 限制接口调用频率\n * 新用户限制每分钟5次, 老用户限制每分钟20次\n */\nexport default function frequency(\n    socket: Socket,\n    {\n        maxCallPerMinutes = MaxCallPerMinutes,\n        newUserMaxCallPerMinutes = NewUserMaxCallPerMinutes,\n        clearDataInterval = ClearDataInterval,\n    }: Options = {},\n) {\n    let callTimes: Record<string, number> = {};\n\n    // 每60s清空一次次数统计\n    setInterval(() => {\n        callTimes = {};\n    }, clearDataInterval);\n\n    return async ([event, , cb]: MiddlewareArgs, next: MiddlewareNext) => {\n        if (event !== 'sendMessage') {\n            next();\n        } else {\n            const socketId = socket.id;\n            const count = callTimes[socketId] || 0;\n\n            const isNewUser =\n                socket.data.user &&\n                (await Redis.has(getNewUserKey(socket.data.user)));\n            if (isNewUser && count >= newUserMaxCallPerMinutes) {\n                // new user limit\n                cb(NEW_USER_CALL_SERVICE_FREQUENTLY);\n                await Redis.set(\n                    getSealUserKey(socket.data.user),\n                    socket.data.user,\n                    Redis.Minute * AutoSealDuration,\n                );\n            } else if (count >= maxCallPerMinutes) {\n                // normal user limit\n                cb(CALL_SERVICE_FREQUENTLY);\n                await Redis.set(\n                    getSealUserKey(socket.data.user),\n                    socket.data.user,\n                    Redis.Minute * AutoSealDuration,\n                );\n            } else {\n                callTimes[socketId] = count + 1;\n                next();\n            }\n        }\n    };\n}\n"
  },
  {
    "path": "packages/server/src/middlewares/isAdmin.ts",
    "content": "import config from '@fiora/config/server';\nimport { Socket } from 'socket.io';\n\nexport const YOU_ARE_NOT_ADMINISTRATOR = '你不是管理员';\n\n/**\n * 拦截非管理员用户请求需要管理员权限的接口\n */\nexport default function isAdmin(socket: Socket) {\n    const requireAdminEvent = new Set([\n        'sealUser',\n        'getSealList',\n        'resetUserPassword',\n        'setUserTag',\n        'getUserIps',\n        'sealIp',\n        'getSealIpList',\n        'toggleSendMessage',\n        'toggleNewUserSendMessage',\n        'getSystemConfig',\n    ]);\n    return async ([event, , cb]: MiddlewareArgs, next: MiddlewareNext) => {\n        socket.data.isAdmin =\n            !!socket.data.user &&\n            config.administrator.includes(socket.data.user);\n        const isAdminEvent = requireAdminEvent.has(event);\n        if (!socket.data.isAdmin && isAdminEvent) {\n            cb(YOU_ARE_NOT_ADMINISTRATOR);\n        } else {\n            next();\n        }\n    };\n}\n"
  },
  {
    "path": "packages/server/src/middlewares/isLogin.ts",
    "content": "import { Socket } from 'socket.io';\n\nexport const PLEASE_LOGIN = '请登录后再试';\n\n/**\n * 拦截未登录用户请求需要登录态的接口\n */\nexport default function isLogin(socket: Socket) {\n    const noRequireLoginEvent = new Set([\n        'register',\n        'login',\n        'loginByToken',\n        'guest',\n        'getDefaultGroupHistoryMessages',\n        'getDefaultGroupOnlineMembers',\n        'getBaiduToken',\n        'getGroupBasicInfo',\n        'getSTS',\n    ]);\n    return async ([event, , cb]: MiddlewareArgs, next: MiddlewareNext) => {\n        if (!noRequireLoginEvent.has(event) && !socket.data.user) {\n            cb(PLEASE_LOGIN);\n        } else {\n            next();\n        }\n    };\n}\n"
  },
  {
    "path": "packages/server/src/middlewares/registerRoutes.ts",
    "content": "import assert from 'assert';\nimport logger from '@fiora/utils/logger';\nimport { getSocketIp } from '@fiora/utils/socket';\nimport { Socket } from 'socket.io';\n\nfunction defaultCallback() {\n    logger.error('Server Error: emit event with callback');\n}\n\nexport default function registerRoutes(socket: Socket, routes: Routes) {\n    return async ([event, data, cb = defaultCallback]: MiddlewareArgs) => {\n        const route = routes[event];\n        if (route) {\n            try {\n                const ctx: Context<any> = {\n                    data,\n                    socket: {\n                        id: socket.id,\n                        ip: getSocketIp(socket),\n                        get user() {\n                            return socket.data.user;\n                        },\n                        set user(newUserId: string) {\n                            socket.data.user = newUserId;\n                        },\n                        get isAdmin() {\n                            return socket.data.isAdmin;\n                        },\n                        join: socket.join.bind(socket),\n                        leave: socket.leave.bind(socket),\n                        emit: (target, _event, _data) => {\n                            socket.to(target).emit(_event, _data);\n                        },\n                    },\n                };\n                const before = Date.now();\n                const res = await route(ctx);\n                const after = Date.now();\n                logger.info(\n                    `[${event}]`,\n                    after - before,\n                    ctx.socket.id,\n                    ctx.socket.user || 'null',\n                    typeof res === 'string' ? res : 'null',\n                );\n                cb(res);\n            } catch (err) {\n                if (err instanceof assert.AssertionError) {\n                    cb(err.message);\n                } else {\n                    logger.error(`[${event}]`, err.message);\n                    cb(`Server Error: ${err.message}`);\n                }\n            }\n        } else {\n            cb(`Server Error: event [${event}] not exists`);\n        }\n    };\n}\n"
  },
  {
    "path": "packages/server/src/middlewares/seal.ts",
    "content": "import { SEAL_TEXT } from '@fiora/utils/const';\nimport { getSocketIp } from '@fiora/utils/socket';\nimport { Socket } from 'socket.io';\nimport {\n    getSealIpKey,\n    getSealUserKey,\n    Redis,\n} from '@fiora/database/redis/initRedis';\n\n/**\n * 拦截被封禁用户的请求\n */\nexport default function seal(socket: Socket) {\n    return async ([, , cb]: MiddlewareArgs, next: MiddlewareNext) => {\n        const ip = getSocketIp(socket);\n        const isSealIp = await Redis.has(getSealIpKey(ip));\n        const isSealUser =\n            socket.data.user &&\n            (await Redis.has(getSealUserKey(socket.data.user)));\n\n        if (isSealUser || isSealIp) {\n            cb(SEAL_TEXT);\n        } else {\n            next();\n        }\n    };\n}\n"
  },
  {
    "path": "packages/server/src/routes/group.ts",
    "content": "import assert, { AssertionError } from 'assert';\nimport { Types } from '@fiora/database/mongoose';\nimport stringHash from 'string-hash';\n\nimport config from '@fiora/config/server';\nimport getRandomAvatar from '@fiora/utils/getRandomAvatar';\nimport Group, { GroupDocument } from '@fiora/database/mongoose/models/group';\nimport Socket from '@fiora/database/mongoose/models/socket';\nimport Message from '@fiora/database/mongoose/models/message';\n\nconst { isValid } = Types.ObjectId;\n\n/**\n * 获取指定群组的在线用户辅助方法\n * @param group 群组\n */\nasync function getGroupOnlineMembersHelper(group: GroupDocument) {\n    const sockets = await Socket.find(\n        {\n            user: {\n                $in: group.members.map((member) => member.toString()),\n            },\n        },\n        {\n            os: 1,\n            browser: 1,\n            environment: 1,\n            user: 1,\n        },\n    ).populate('user', { username: 1, avatar: 1 });\n    const filterSockets = sockets.reduce((result, socket) => {\n        result.set(socket.user._id.toString(), socket);\n        return result;\n    }, new Map());\n    return Array.from(filterSockets.values());\n}\n\n/**\n * 创建群组\n * @param ctx Context\n */\nexport async function createGroup(ctx: Context<{ name: string }>) {\n    assert(!config.disableCreateGroup, '管理员已关闭创建群组功能');\n\n    const ownGroupCount = await Group.count({ creator: ctx.socket.user });\n    assert(\n        ctx.socket.isAdmin || ownGroupCount < config.maxGroupsCount,\n        `创建群组失败, 你已经创建了${config.maxGroupsCount}个群组`,\n    );\n\n    const { name } = ctx.data;\n    assert(name, '群组名不能为空');\n\n    const group = await Group.findOne({ name });\n    assert(!group, '该群组已存在');\n\n    let newGroup = null;\n    try {\n        newGroup = await Group.create({\n            name,\n            avatar: getRandomAvatar(),\n            creator: ctx.socket.user,\n            members: [ctx.socket.user],\n        } as GroupDocument);\n    } catch (err) {\n        if (err.name === 'ValidationError') {\n            return '群组名包含不支持的字符或者长度超过限制';\n        }\n        throw err;\n    }\n\n    ctx.socket.join(newGroup._id.toString());\n    return {\n        _id: newGroup._id,\n        name: newGroup.name,\n        avatar: newGroup.avatar,\n        createTime: newGroup.createTime,\n        creator: newGroup.creator,\n    };\n}\n\n/**\n * 加入群组\n * @param ctx Context\n */\nexport async function joinGroup(ctx: Context<{ groupId: string }>) {\n    const { groupId } = ctx.data;\n    assert(isValid(groupId), '无效的群组ID');\n\n    const group = await Group.findOne({ _id: groupId });\n    if (!group) {\n        throw new AssertionError({ message: '加入群组失败, 群组不存在' });\n    }\n    assert(group.members.indexOf(ctx.socket.user) === -1, '你已经在群组中');\n\n    group.members.push(ctx.socket.user);\n    await group.save();\n\n    const messages = await Message.find(\n        { toGroup: groupId },\n        {\n            type: 1,\n            content: 1,\n            from: 1,\n            createTime: 1,\n        },\n        { sort: { createTime: -1 }, limit: 3 },\n    ).populate('from', { username: 1, avatar: 1 });\n    messages.reverse();\n\n    ctx.socket.join(group._id.toString());\n\n    return {\n        _id: group._id,\n        name: group.name,\n        avatar: group.avatar,\n        createTime: group.createTime,\n        creator: group.creator,\n        messages,\n    };\n}\n\n/**\n * 退出群组\n * @param ctx Context\n */\nexport async function leaveGroup(ctx: Context<{ groupId: string }>) {\n    const { groupId } = ctx.data;\n    assert(isValid(groupId), '无效的群组ID');\n\n    const group = await Group.findOne({ _id: groupId });\n    if (!group) {\n        throw new AssertionError({ message: '群组不存在' });\n    }\n\n    // 默认群组没有creator\n    if (group.creator) {\n        assert(\n            group.creator.toString() !== ctx.socket.user.toString(),\n            '群主不可以退出自己创建的群',\n        );\n    }\n\n    const index = group.members.indexOf(ctx.socket.user);\n    assert(index !== -1, '你不在群组中');\n\n    group.members.splice(index, 1);\n    await group.save();\n\n    ctx.socket.leave(group._id.toString());\n\n    return {};\n}\n\nconst GroupOnlineMembersCacheExpireTime = 1000 * 60;\n\n/**\n * 获取群组在线成员\n */\nfunction getGroupOnlineMembersWrapperV2() {\n    const cache: Record<\n        string,\n        {\n            key?: string;\n            value: any;\n            expireTime: number;\n        }\n    > = {};\n    return async function getGroupOnlineMembersV2(\n        ctx: Context<{ groupId: string; cache?: string }>,\n    ) {\n        const { groupId, cache: cacheKey } = ctx.data;\n        assert(isValid(groupId), '无效的群组ID');\n\n        if (\n            cache[groupId] &&\n            cache[groupId].key === cacheKey &&\n            cache[groupId].expireTime > Date.now()\n        ) {\n            return { cache: cacheKey };\n        }\n\n        const group = await Group.findOne({ _id: groupId });\n        if (!group) {\n            throw new AssertionError({ message: '群组不存在' });\n        }\n        const result = await getGroupOnlineMembersHelper(group);\n        const resultCacheKey = stringHash(\n            result.map((item) => item.user._id).join(','),\n        ).toString(36);\n        if (cache[groupId] && cache[groupId].key === resultCacheKey) {\n            cache[groupId].expireTime =\n                Date.now() + GroupOnlineMembersCacheExpireTime;\n            if (resultCacheKey === cacheKey) {\n                return { cache: cacheKey };\n            }\n        }\n\n        cache[groupId] = {\n            key: resultCacheKey,\n            value: result,\n            expireTime: Date.now() + GroupOnlineMembersCacheExpireTime,\n        };\n        return {\n            cache: resultCacheKey,\n            members: result,\n        };\n    };\n}\nexport const getGroupOnlineMembersV2 = getGroupOnlineMembersWrapperV2();\n\nexport async function getGroupOnlineMembers(\n    ctx: Context<{ groupId: string; cache?: string }>,\n) {\n    const result = await getGroupOnlineMembersV2(ctx);\n    return result.members;\n}\n\n/**\n * 获取默认群组的在线成员\n * 无需登录态\n */\nfunction getDefaultGroupOnlineMembersWrapper() {\n    let cache: any = null;\n    let expireTime = 0;\n    return async function getDefaultGroupOnlineMembers() {\n        if (cache && expireTime > Date.now()) {\n            return cache;\n        }\n\n        const group = await Group.findOne({ isDefault: true });\n        if (!group) {\n            throw new AssertionError({ message: '群组不存在' });\n        }\n        cache = await getGroupOnlineMembersHelper(group);\n        expireTime = Date.now() + GroupOnlineMembersCacheExpireTime;\n        return cache;\n    };\n}\nexport const getDefaultGroupOnlineMembers = getDefaultGroupOnlineMembersWrapper();\n\n/**\n * 修改群头像, 只有群创建者有权限\n * @param ctx Context\n */\nexport async function changeGroupAvatar(\n    ctx: Context<{ groupId: string; avatar: string }>,\n) {\n    const { groupId, avatar } = ctx.data;\n    assert(isValid(groupId), '无效的群组ID');\n    assert(avatar, '头像地址不能为空');\n\n    const group = await Group.findOne({ _id: groupId });\n    if (!group) {\n        throw new AssertionError({ message: '群组不存在' });\n    }\n    assert(\n        group.creator.toString() === ctx.socket.user.toString(),\n        '只有群主才能修改头像',\n    );\n\n    await Group.updateOne({ _id: groupId }, { avatar });\n    return {};\n}\n\n/**\n * 修改群组头像, 只有群创建者有权限\n * @param ctx Context\n */\nexport async function changeGroupName(\n    ctx: Context<{ groupId: string; name: string }>,\n) {\n    const { groupId, name } = ctx.data;\n    assert(isValid(groupId), '无效的群组ID');\n    assert(name, '群组名称不能为空');\n\n    const group = await Group.findOne({ _id: groupId });\n    if (!group) {\n        throw new AssertionError({ message: '群组不存在' });\n    }\n    assert(group.name !== name, '新群组名不能和之前一致');\n    assert(\n        group.creator.toString() === ctx.socket.user.toString(),\n        '只有群主才能修改头像',\n    );\n\n    const targetGroup = await Group.findOne({ name });\n    assert(!targetGroup, '该群组名已存在');\n\n    await Group.updateOne({ _id: groupId }, { name });\n\n    ctx.socket.emit(groupId, 'changeGroupName', { groupId, name });\n\n    return {};\n}\n\n/**\n * 删除群组, 只有群创建者有权限\n * @param ctx Context\n */\nexport async function deleteGroup(ctx: Context<{ groupId: string }>) {\n    const { groupId } = ctx.data;\n    assert(isValid(groupId), '无效的群组ID');\n\n    const group = await Group.findOne({ _id: groupId });\n    if (!group) {\n        throw new AssertionError({ message: '群组不存在' });\n    }\n    assert(\n        group.creator.toString() === ctx.socket.user.toString(),\n        '只有群主才能解散群组',\n    );\n    assert(group.isDefault !== true, '默认群组不允许解散');\n\n    await Group.deleteOne({ _id: group });\n\n    ctx.socket.emit(groupId, 'deleteGroup', { groupId });\n\n    return {};\n}\n\nexport async function getGroupBasicInfo(ctx: Context<{ groupId: string }>) {\n    const { groupId } = ctx.data;\n    assert(isValid(groupId), '无效的群组ID');\n\n    const group = await Group.findOne({ _id: groupId });\n    if (!group) {\n        throw new AssertionError({ message: '群组不存在' });\n    }\n\n    return {\n        _id: group._id,\n        name: group.name,\n        avatar: group.avatar,\n        members: group.members.length,\n    };\n}\n"
  },
  {
    "path": "packages/server/src/routes/history.ts",
    "content": "import { isValidObjectId, Types } from '@fiora/database/mongoose';\nimport assert from 'assert';\nimport User from '@fiora/database/mongoose/models/user';\nimport Group from '@fiora/database/mongoose/models/group';\nimport Message from '@fiora/database/mongoose/models/message';\nimport { createOrUpdateHistory } from '@fiora/database/mongoose/models/history';\n\nexport async function updateHistory(\n    ctx: Context<{ userId: string; linkmanId: string; messageId: string }>,\n) {\n    const { linkmanId, messageId } = ctx.data;\n    const self = ctx.socket.user.toString();\n    if (!Types.ObjectId.isValid(messageId)) {\n        return {\n            msg: `not update with invalid messageId:${messageId}`,\n        };\n    }\n\n    // @ts-ignore\n    const [user, linkman, message] = await Promise.all([\n        User.findOne({ _id: self }),\n        isValidObjectId(linkmanId)\n            ? Group.findOne({ _id: linkmanId })\n            : User.findOne({ _id: linkmanId.replace(self, '') }),\n        Message.findOne({ _id: messageId }),\n    ]);\n    assert(user, '用户不存在');\n    assert(linkman, '联系人不存在');\n    assert(message, '消息不存在');\n\n    await createOrUpdateHistory(self, linkmanId, messageId);\n\n    return {\n        msg: 'ok',\n    };\n}\n"
  },
  {
    "path": "packages/server/src/routes/message.ts",
    "content": "/* eslint-disable no-await-in-loop */\n/* eslint-disable no-restricted-syntax */\nimport assert, { AssertionError } from 'assert';\nimport { Types } from '@fiora/database/mongoose';\nimport { Expo, ExpoPushErrorTicket } from 'expo-server-sdk';\n\nimport xss from '@fiora/utils/xss';\nimport logger from '@fiora/utils/logger';\nimport User, { UserDocument } from '@fiora/database/mongoose/models/user';\nimport Group, { GroupDocument } from '@fiora/database/mongoose/models/group';\nimport Message, {\n    handleInviteV2Message,\n    handleInviteV2Messages,\n    MessageDocument,\n} from '@fiora/database/mongoose/models/message';\nimport Notification from '@fiora/database/mongoose/models/notification';\nimport History, {\n    createOrUpdateHistory,\n} from '@fiora/database/mongoose/models/history';\nimport Socket from '@fiora/database/mongoose/models/socket';\n\nimport {\n    DisableSendMessageKey,\n    DisableNewUserSendMessageKey,\n    Redis,\n} from '@fiora/database/redis/initRedis';\nimport client from '../../../config/client';\n\nconst { isValid } = Types.ObjectId;\n\n/** 初次获取历史消息数 */\nconst FirstTimeMessagesCount = 15;\n/** 每次调用接口获取的历史消息数 */\nconst EachFetchMessagesCount = 30;\n\nconst OneYear = 365 * 24 * 3600 * 1000;\n\n/** 石头剪刀布, 用于随机生成结果 */\nconst RPS = ['石头', '剪刀', '布'];\n\nasync function pushNotification(\n    notificationTokens: string[],\n    message: MessageDocument,\n    groupName?: string,\n) {\n    const expo = new Expo({});\n\n    const content =\n        message.type === 'text' ? message.content : `[${message.type}]`;\n    const pushMessages = notificationTokens.map((notificationToken) => ({\n        to: notificationToken,\n        sound: 'default',\n        title: groupName || (message.from as any).username,\n        body: groupName\n            ? `${(message.from as any).username}: ${content}`\n            : content,\n        data: { focus: message.to },\n    }));\n\n    const chunks = expo.chunkPushNotifications(pushMessages as any);\n    for (const chunk of chunks) {\n        try {\n            const results = await expo.sendPushNotificationsAsync(chunk);\n            results.forEach((result) => {\n                const { status, message: errMessage } =\n                    result as ExpoPushErrorTicket;\n                if (status === 'error') {\n                    logger.warn('[Notification]', errMessage);\n                }\n            });\n        } catch (error) {\n            logger.error('[Notification]', (error as Error).message);\n        }\n    }\n}\n\n/**\n * 发送消息\n * 如果是发送给群组, to是群组id\n * 如果是发送给个人, to是俩人id按大小序拼接后的值\n * @param ctx Context\n */\nexport async function sendMessage(ctx: Context<SendMessageData>) {\n    const disableSendMessage = await Redis.get(DisableSendMessageKey);\n    assert(disableSendMessage !== 'true' || ctx.socket.isAdmin, '全员禁言中');\n\n    const disableNewUserSendMessage = await Redis.get(\n        DisableNewUserSendMessageKey,\n    );\n    if (disableNewUserSendMessage === 'true') {\n        const user = await User.findById(ctx.socket.user);\n        const isNewUser =\n            user && user.createTime.getTime() > Date.now() - OneYear;\n        assert(\n            ctx.socket.isAdmin || !isNewUser,\n            '新用户禁言中! 主群禁止闲聊, 多交流fiora和开发技术, 自发维护交流环境',\n        );\n    }\n\n    const { to, content } = ctx.data;\n    let { type } = ctx.data;\n    assert(to, 'to不能为空');\n\n    let toGroup: GroupDocument | null = null;\n    let toUser: UserDocument | null = null;\n    if (isValid(to)) {\n        toGroup = await Group.findOne({ _id: to });\n        assert(toGroup, '群组不存在');\n    } else {\n        const userId = to.replace(ctx.socket.user.toString(), '');\n        assert(isValid(userId), '无效的用户ID');\n        toUser = await User.findOne({ _id: userId });\n        assert(toUser, '用户不存在');\n    }\n\n    let messageContent = content;\n    if (type === 'text') {\n        assert(messageContent.length <= 2048, '消息长度过长');\n\n        const rollRegex = /^-roll( ([0-9]*))?$/;\n        if (rollRegex.test(messageContent)) {\n            const regexResult = rollRegex.exec(messageContent);\n            if (regexResult) {\n                let numberStr = regexResult[1] || '100';\n                if (numberStr.length > 5) {\n                    numberStr = '99999';\n                }\n                const number = parseInt(numberStr, 10);\n                type = 'system';\n                messageContent = JSON.stringify({\n                    command: 'roll',\n                    value: Math.floor(Math.random() * (number + 1)),\n                    top: number,\n                });\n            }\n        } else if (/^-rps$/.test(messageContent)) {\n            type = 'system';\n            messageContent = JSON.stringify({\n                command: 'rps',\n                value: RPS[Math.floor(Math.random() * RPS.length)],\n            });\n        }\n        messageContent = xss(messageContent);\n    } else if (type === 'file') {\n        const file: { size: number } = JSON.parse(content);\n        assert(file.size < client.maxFileSize, '要发送的文件过大');\n        messageContent = content;\n    } else if (type === 'inviteV2') {\n        const shareTargetGroup = await Group.findOne({ _id: content });\n        if (!shareTargetGroup) {\n            throw new AssertionError({ message: '目标群组不存在' });\n        }\n        const user = await User.findOne({ _id: ctx.socket.user });\n        if (!user) {\n            throw new AssertionError({ message: '用户不存在' });\n        }\n        messageContent = JSON.stringify({\n            inviter: user._id,\n            group: shareTargetGroup._id,\n        });\n    }\n\n    const user = await User.findOne(\n        { _id: ctx.socket.user },\n        { username: 1, avatar: 1, tag: 1 },\n    );\n    if (!user) {\n        throw new AssertionError({ message: '用户不存在' });\n    }\n\n    const message = await Message.create({\n        from: ctx.socket.user,\n        to,\n        type,\n        content: messageContent,\n    } as MessageDocument);\n\n    const messageData = {\n        _id: message._id,\n        createTime: message.createTime,\n        from: user.toObject(),\n        to,\n        type,\n        content: message.content,\n    };\n    if (type === 'inviteV2') {\n        await handleInviteV2Message(messageData);\n    }\n\n    if (toGroup) {\n        ctx.socket.emit(toGroup._id.toString(), 'message', messageData);\n\n        const notifications = await Notification.find({\n            user: {\n                $in: toGroup.members,\n            },\n        });\n        const notificationTokens: string[] = [];\n        notifications.forEach((notification) => {\n            // Messages sent by yourself don’t push notification to yourself\n            if (\n                notification.user._id.toString() === ctx.socket.user.toString()\n            ) {\n                return;\n            }\n            notificationTokens.push(notification.token);\n        });\n        if (notificationTokens.length) {\n            pushNotification(\n                notificationTokens,\n                messageData as unknown as MessageDocument,\n                toGroup.name,\n            );\n        }\n    } else {\n        const targetSockets = await Socket.find({ user: toUser?._id });\n        const targetSocketIdList =\n            targetSockets?.map((socket) => socket.id) || [];\n        if (targetSocketIdList.length) {\n            ctx.socket.emit(targetSocketIdList, 'message', messageData);\n        }\n\n        const selfSockets = await Socket.find({ user: ctx.socket.user });\n        const selfSocketIdList = selfSockets?.map((socket) => socket.id) || [];\n        if (selfSocketIdList.length) {\n            ctx.socket.emit(selfSocketIdList, 'message', messageData);\n        }\n\n        const notificationTokens = await Notification.find({ user: toUser });\n        if (notificationTokens.length) {\n            pushNotification(\n                notificationTokens.map(({ token }) => token),\n                messageData as unknown as MessageDocument,\n            );\n        }\n    }\n\n    createOrUpdateHistory(ctx.socket.user.toString(), to, message._id);\n\n    return messageData;\n}\n\n/**\n * 获取一组联系人的最后历史消息\n * @param ctx Context\n */\nexport async function getLinkmansLastMessages(\n    ctx: Context<{ linkmans: string[] }>,\n) {\n    const { linkmans } = ctx.data;\n    assert(Array.isArray(linkmans), '参数linkmans应该是Array');\n\n    const promises = linkmans.map(async (linkmanId) => {\n        const messages = await Message.find(\n            { to: linkmanId },\n            {\n                type: 1,\n                content: 1,\n                from: 1,\n                createTime: 1,\n                deleted: 1,\n            },\n            { sort: { createTime: -1 }, limit: FirstTimeMessagesCount },\n        ).populate('from', { username: 1, avatar: 1, tag: 1 });\n        await handleInviteV2Messages(messages);\n        return messages;\n    });\n    const results = await Promise.all(promises);\n    type Messages = {\n        [linkmanId: string]: MessageDocument[];\n    };\n    const messages = linkmans.reduce((result: Messages, linkmanId, index) => {\n        result[linkmanId] = (results[index] || []).reverse();\n        return result;\n    }, {});\n\n    return messages;\n}\n\nexport async function getLinkmansLastMessagesV2(\n    ctx: Context<{ linkmans: string[] }>,\n) {\n    const { linkmans } = ctx.data;\n\n    const histories = await History.find({\n        user: ctx.socket.user.toString(),\n        linkman: {\n            $in: linkmans,\n        },\n    });\n    const historyMap = histories\n        .filter(Boolean)\n        .reduce((result: { [linkman: string]: string }, history) => {\n            result[history.linkman] = history.message;\n            return result;\n        }, {});\n\n    const linkmansMessages = await Promise.all(\n        linkmans.map(async (linkmanId) => {\n            const messages = await Message.find(\n                { to: linkmanId },\n                {\n                    type: 1,\n                    content: 1,\n                    from: 1,\n                    createTime: 1,\n                    deleted: 1,\n                },\n                {\n                    sort: { createTime: -1 },\n                    limit: historyMap[linkmanId] ? 100 : FirstTimeMessagesCount,\n                },\n            ).populate('from', { username: 1, avatar: 1, tag: 1 });\n            await handleInviteV2Messages(messages);\n            return messages;\n        }),\n    );\n\n    type ResponseData = {\n        [linkmanId: string]: {\n            messages: MessageDocument[];\n            unread: number;\n        };\n    };\n    const responseData = linkmans.reduce(\n        (result: ResponseData, linkmanId, index) => {\n            const messages = linkmansMessages[index];\n            if (historyMap[linkmanId]) {\n                const messageIndex = messages.findIndex(\n                    ({ _id }) => _id.toString() === historyMap[linkmanId],\n                );\n                result[linkmanId] = {\n                    messages: messages.slice(0, 15).reverse(),\n                    unread: messageIndex === -1 ? 100 : messageIndex,\n                };\n            } else {\n                result[linkmanId] = {\n                    messages: messages.reverse(),\n                    unread: 0,\n                };\n            }\n            return result;\n        },\n        {},\n    );\n\n    return responseData;\n}\n\n/**\n * 获取联系人的历史消息\n * @param ctx Context\n */\nexport async function getLinkmanHistoryMessages(\n    ctx: Context<{ linkmanId: string; existCount: number }>,\n) {\n    const { linkmanId, existCount } = ctx.data;\n\n    const messages = await Message.find(\n        { to: linkmanId },\n        {\n            type: 1,\n            content: 1,\n            from: 1,\n            createTime: 1,\n            deleted: 1,\n        },\n        {\n            sort: { createTime: -1 },\n            limit: EachFetchMessagesCount + existCount,\n        },\n    ).populate('from', { username: 1, avatar: 1, tag: 1 });\n    await handleInviteV2Messages(messages);\n    const result = messages.slice(existCount).reverse();\n    return result;\n}\n\n/**\n * 获取默认群组的历史消息\n * @param ctx Context\n */\nexport async function getDefaultGroupHistoryMessages(\n    ctx: Context<{ existCount: number }>,\n) {\n    const { existCount } = ctx.data;\n\n    const group = await Group.findOne({ isDefault: true });\n    if (!group) {\n        throw new AssertionError({ message: '默认群组不存在' });\n    }\n    const messages = await Message.find(\n        { to: group._id },\n        {\n            type: 1,\n            content: 1,\n            from: 1,\n            createTime: 1,\n            deleted: 1,\n        },\n        {\n            sort: { createTime: -1 },\n            limit: EachFetchMessagesCount + existCount,\n        },\n    ).populate('from', { username: 1, avatar: 1, tag: 1 });\n    await handleInviteV2Messages(messages);\n    const result = messages.slice(existCount).reverse();\n    return result;\n}\n\n/**\n * 删除消息, 需要管理员权限\n */\nexport async function deleteMessage(ctx: Context<{ messageId: string }>) {\n    assert(\n        !client.disableDeleteMessage || ctx.socket.isAdmin,\n        '已禁止撤回消息',\n    );\n\n    const { messageId } = ctx.data;\n    assert(messageId, 'messageId不能为空');\n\n    const message = await Message.findOne({ _id: messageId });\n    if (!message) {\n        throw new AssertionError({ message: '消息不存在' });\n    }\n    assert(\n        ctx.socket.isAdmin ||\n            message.from.toString() === ctx.socket.user.toString(),\n        '只能撤回本人的消息',\n    );\n\n    if (ctx.socket.isAdmin) {\n        await Message.deleteOne({ _id: messageId });\n    } else {\n        message.deleted = true;\n        await message.save();\n    }\n\n    /**\n     * 广播删除消息通知, 区分群消息和私聊消息\n     */\n    const messageName = 'deleteMessage';\n    const messageData = {\n        linkmanId: message.to.toString(),\n        messageId,\n        isAdmin: ctx.socket.isAdmin,\n    };\n    if (isValid(message.to)) {\n        // 群消息\n        ctx.socket.emit(message.to.toString(), messageName, messageData);\n    } else {\n        // 私聊消息\n        const targetUserId = message.to.replace(ctx.socket.user.toString(), '');\n        const targetSockets = await Socket.find({ user: targetUserId });\n        const targetSocketIdList =\n            targetSockets?.map((socket) => socket.id) || [];\n        if (targetSocketIdList) {\n            ctx.socket.emit(targetSocketIdList, messageName, messageData);\n        }\n\n        const selfSockets = await Socket.find({ user: ctx.socket.user });\n        const selfSocketIdList = selfSockets?.map((socket) => socket.id) || [];\n        if (selfSocketIdList) {\n            ctx.socket.emit(\n                selfSocketIdList.filter(\n                    (socketId) => socketId !== ctx.socket.id,\n                ),\n                messageName,\n                messageData,\n            );\n        }\n    }\n\n    return {\n        msg: 'ok',\n    };\n}\n"
  },
  {
    "path": "packages/server/src/routes/notification.ts",
    "content": "import { AssertionError } from 'assert';\nimport User from '@fiora/database/mongoose/models/user';\nimport Notification from '@fiora/database/mongoose/models/notification';\n\nexport async function setNotificationToken(ctx: Context<{ token: string }>) {\n    const { token } = ctx.data;\n\n    const user = await User.findOne({ _id: ctx.socket.user });\n    if (!user) {\n        throw new AssertionError({ message: '用户不存在' });\n    }\n\n    const notification = await Notification.findOne({ token: ctx.data.token });\n    if (notification) {\n        notification.user = user;\n        await notification.save();\n    } else {\n        await Notification.create({\n            user,\n            token,\n        });\n\n        const existNotifications = await Notification.find({ user });\n        if (existNotifications.length > 3) {\n            await Notification.deleteOne({ _id: existNotifications[0]._id });\n        }\n    }\n\n    return {\n        isOK: true,\n    };\n}\n"
  },
  {
    "path": "packages/server/src/routes/system.ts",
    "content": "import fs from 'fs';\nimport path from 'path';\nimport axios from 'axios';\nimport assert, { AssertionError } from 'assert';\nimport { promisify } from 'util';\nimport RegexEscape from 'regex-escape';\nimport OSS, { STS } from 'ali-oss';\n\nimport config from '@fiora/config/server';\nimport logger from '@fiora/utils/logger';\nimport User from '@fiora/database/mongoose/models/user';\nimport Group from '@fiora/database/mongoose/models/group';\n\nimport Socket from '@fiora/database/mongoose/models/socket';\nimport {\n    getAllSealIp,\n    getAllSealUser,\n    getSealIpKey,\n    getSealUserKey,\n    DisableSendMessageKey,\n    DisableNewUserSendMessageKey,\n    Redis,\n} from '@fiora/database/redis/initRedis';\n\n/** 百度语言合成token */\nlet baiduToken = '';\n/** 最后一次获取token的时间 */\nlet lastBaiduTokenTime = Date.now();\n\n/**\n * 搜索用户和群组\n * @param ctx Context\n */\nexport async function search(ctx: Context<{ keywords: string }>) {\n    const keywords = ctx.data.keywords?.trim() || '';\n    if (keywords === '') {\n        return {\n            users: [],\n            groups: [],\n        };\n    }\n\n    const escapedKeywords = RegexEscape(keywords);\n    const users = await User.find(\n        { username: { $regex: escapedKeywords } },\n        { avatar: 1, username: 1 },\n    );\n    const groups = await Group.find(\n        { name: { $regex: escapedKeywords } },\n        { avatar: 1, name: 1, members: 1 },\n    );\n\n    return {\n        users,\n        groups: groups.map((group) => ({\n            _id: group._id,\n            avatar: group.avatar,\n            name: group.name,\n            members: group.members.length,\n        })),\n    };\n}\n\n/**\n * 搜索表情包, 爬其它站资源\n * @param ctx Context\n */\nexport async function searchExpression(\n    ctx: Context<{ keywords: string; limit?: number }>,\n) {\n    const { keywords, limit = Infinity } = ctx.data;\n    if (keywords === '') {\n        return [];\n    }\n\n    const res = await axios({\n        method: 'get',\n        url: `https://pic.sogou.com/pics/json.jsp?query=${encodeURIComponent(\n            `${keywords} 表情`,\n        )}&st=5&start=0&xml_len=60&callback=callback&reqFrom=wap_result&`,\n        headers: {\n            accept: '*/*',\n            'accept-language': 'zh-CN,zh;q=0.9,en;q=0.8,zh-TW;q=0.7',\n            'cache-control': 'no-cache',\n            pragma: 'no-cache',\n            'sec-fetch-mode': 'navigate',\n            'sec-fetch-site': 'same-origin',\n            referrer: `https://pic.sogou.com/pic/emo/searchList.jsp?statref=search_form&uID=hTHHybkSPt37C46z&spver=0&rcer=&keyword=${encodeURIComponent(\n                keywords,\n            )}`,\n            referrerPolicy: 'no-referrer-when-downgrade',\n            'user-agent':\n                'Mozilla/5.0 (iPhone; CPU iPhone OS 11_0 like Mac OS X) AppleWebKit/604.1.38 (KHTML, like Gecko) Version/11.0 Mobile/15A372 Safari/604.1',\n        },\n    });\n    assert(res.status === 200, '搜索表情包失败, 请重试');\n\n    try {\n        const parseDataResult = res.data.match(/callback\\((.+)\\)/);\n        const data = JSON.parse(`${parseDataResult[1]}`);\n\n        type Image = {\n            locImageLink: string;\n            width: number;\n            height: number;\n        };\n        const images = data.items as Image[];\n        return images\n            .map(({ locImageLink, width, height }) => ({\n                image: locImageLink,\n                width,\n                height,\n            }))\n            .filter((image, index) =>\n                limit === Infinity ? true : index < limit,\n            );\n    } catch (err) {\n        assert(false, '搜索表情包失败, 数据解析异常');\n    }\n\n    return [];\n}\n\n/**\n * 获取百度语言合成token\n */\nexport async function getBaiduToken() {\n    if (baiduToken && Date.now() < lastBaiduTokenTime) {\n        return { token: baiduToken };\n    }\n\n    const res = await axios.get(\n        'https://openapi.baidu.com/oauth/2.0/token?grant_type=client_credentials&client_id=pw152BzvaSZVwrUf3Z2OHXM6&client_secret=fa273cc704b080e85ad61719abbf7794',\n    );\n    assert(res.status === 200, '请求百度token失败');\n\n    baiduToken = res.data.access_token;\n    lastBaiduTokenTime =\n        Date.now() + (res.data.expires_in - 60 * 60 * 24) * 1000;\n    return { token: baiduToken };\n}\n\n/**\n * 封禁用户, 需要管理员权限\n * @param ctx Context\n */\nexport async function sealUser(ctx: Context<{ username: string }>) {\n    const { username } = ctx.data;\n    assert(username !== '', 'username不能为空');\n\n    const user = await User.findOne({ username });\n    if (!user) {\n        throw new AssertionError({ message: '用户不存在' });\n    }\n\n    const userId = user._id.toString();\n    const isSealUser = await Redis.has(getSealUserKey(userId));\n    assert(!isSealUser, '用户已在封禁名单');\n\n    await Redis.set(getSealUserKey(userId), userId, Redis.Minute * 10);\n\n    return {\n        msg: 'ok',\n    };\n}\n\n/**\n * 获取封禁列表, 包含用户封禁和ip封禁, 需要管理员权限\n */\nexport async function getSealList() {\n    const sealUserList = await getAllSealUser();\n    const sealIpList = await getAllSealIp();\n    const users = await User.find({ _id: { $in: sealUserList } });\n\n    const result = {\n        users: users.map((user) => user.username),\n        ips: sealIpList,\n    };\n    return result;\n}\n\nconst CantSealLocalIp = '不能封禁内网ip';\nconst CantSealSelf = '闲的没事封自己干啥';\nconst IpInSealList = 'ip已在封禁名单';\n\n/**\n * 封禁 ip 地址, 需要管理员权限\n */\nexport async function sealIp(ctx: Context<{ ip: string }>) {\n    const { ip } = ctx.data;\n    assert(ip !== '::1' && ip !== '127.0.0.1', CantSealLocalIp);\n    assert(ip !== ctx.socket.ip, CantSealSelf);\n\n    const isSealIp = await Redis.has(getSealIpKey(ip));\n    assert(!isSealIp, IpInSealList);\n\n    await Redis.set(getSealIpKey(ip), ip, Redis.Hour * 6);\n\n    return {\n        msg: 'ok',\n    };\n}\n\n/**\n * 封禁指定用户的所有在线 ip 地址, 需要管理员权限\n */\nexport async function sealUserOnlineIp(ctx: Context<{ userId: string }>) {\n    const { userId } = ctx.data;\n\n    const user = await User.findOne({ _id: userId });\n    assert(user, '用户不存在');\n    const sockets = await Socket.find({ user: userId });\n    const ipList = [\n        ...sockets.map((socket) => socket.ip),\n        user.lastLoginIp,\n    ].filter(\n        (ip) =>\n            ip !== '' &&\n            ip !== '::1' &&\n            ip !== '127.0.0.1' &&\n            ip !== ctx.socket.ip,\n    );\n\n    // 如果全部 ip 都已经封禁过了, 则直接提示\n    const isSealIpList = await Promise.all(\n        ipList.map((ip) => Redis.has(getSealIpKey(ip))),\n    );\n    assert(!isSealIpList.every((isSealIp) => isSealIp), IpInSealList);\n\n    await Promise.all(\n        ipList.map(async (ip) => {\n            await Redis.set(getSealIpKey(ip), ip, Redis.Hour * 6);\n        }),\n    );\n\n    return {\n        msg: 'ok',\n    };\n}\n\ntype STSResult = {\n    enable: boolean;\n    AccessKeyId: string;\n    AccessKeySecret: string;\n    bucket: string;\n    region: string;\n    SecurityToken: string;\n    endpoint: string;\n};\n\n// eslint-disable-next-line consistent-return\nexport async function getSTS(): Promise<STSResult> {\n    if (!config.aliyunOSS.enable) {\n        // @ts-ignore\n        return {\n            enable: false,\n        };\n    }\n\n    const sts = new STS({\n        accessKeyId: config.aliyunOSS.accessKeyId,\n        accessKeySecret: config.aliyunOSS.accessKeySecret,\n    });\n    try {\n        const result = await sts.assumeRole(\n            config.aliyunOSS.roleArn,\n            undefined,\n            undefined,\n            'fiora-uploader',\n        );\n        // @ts-ignore\n        return {\n            enable: true,\n            region: config.aliyunOSS.region,\n            bucket: config.aliyunOSS.bucket,\n            endpoint: config.aliyunOSS.endpoint,\n            ...result.credentials,\n        };\n    } catch (err) {\n        const typedErr = err as Error;\n        assert.fail(`获取 STS 失败 - ${typedErr.message}`);\n    }\n}\n\nexport async function uploadFile(\n    ctx: Context<{ fileName: string; file: any; isBase64?: boolean }>,\n) {\n    try {\n        if (config.aliyunOSS.enable) {\n            const sts = await getSTS();\n            const client = new OSS({\n                accessKeyId: sts.AccessKeyId,\n                accessKeySecret: sts.AccessKeySecret,\n                bucket: sts.bucket,\n                region: sts.region,\n                stsToken: sts.SecurityToken,\n            });\n            const result = await client.put(\n                ctx.data.fileName,\n                ctx.data.isBase64\n                    ? Buffer.from(ctx.data.file, 'base64')\n                    : ctx.data.file,\n            );\n            if (result.res.status === 200) {\n                return {\n                    url: `//${config.aliyunOSS.endpoint}/${result.name}`,\n                };\n            }\n            throw Error('上传阿里云OSS失败');\n        }\n\n        const [directory, fileName] = ctx.data.fileName.split('/');\n        const filePath = path.resolve('__dirname', '../public', directory);\n        const isExists = await promisify(fs.exists)(filePath);\n        if (!isExists) {\n            await promisify(fs.mkdir)(filePath);\n        }\n        await promisify(fs.writeFile)(\n            path.resolve(filePath, fileName),\n            ctx.data.file,\n        );\n        return {\n            url: `/${ctx.data.fileName}`,\n        };\n    } catch (err) {\n        const typedErr = err as Error;\n        logger.error('[uploadFile]', typedErr.message);\n        return `上传文件失败:${typedErr.message}`;\n    }\n}\n\nexport async function toggleSendMessage(ctx: Context<{ enable: boolean }>) {\n    const { enable } = ctx.data;\n    await Redis.set(DisableSendMessageKey, (!enable).toString());\n    return {\n        msg: 'ok',\n    };\n}\n\nexport async function toggleNewUserSendMessage(\n    ctx: Context<{ enable: boolean }>,\n) {\n    const { enable } = ctx.data;\n    await Redis.set(DisableNewUserSendMessageKey, (!enable).toString());\n    return {\n        msg: 'ok',\n    };\n}\n\nexport async function getSystemConfig() {\n    return {\n        disableSendMessage: (await Redis.get(DisableSendMessageKey)) === 'true',\n        disableNewUserSendMessage:\n            (await Redis.get(DisableNewUserSendMessageKey)) === 'true',\n    };\n}\n"
  },
  {
    "path": "packages/server/src/routes/user.ts",
    "content": "import bcrypt from 'bcryptjs';\nimport assert, { AssertionError } from 'assert';\nimport jwt from 'jwt-simple';\nimport { Types } from '@fiora/database/mongoose';\n\nimport config from '@fiora/config/server';\nimport getRandomAvatar from '@fiora/utils/getRandomAvatar';\nimport { SALT_ROUNDS } from '@fiora/utils/const';\nimport User, { UserDocument } from '@fiora/database/mongoose/models/user';\nimport Group, { GroupDocument } from '@fiora/database/mongoose/models/group';\nimport Friend, { FriendDocument } from '@fiora/database/mongoose/models/friend';\nimport Socket from '@fiora/database/mongoose/models/socket';\nimport Message, {\n    handleInviteV2Messages,\n} from '@fiora/database/mongoose/models/message';\nimport Notification from '@fiora/database/mongoose/models/notification';\nimport {\n    getNewRegisteredUserIpKey,\n    getNewUserKey,\n    Redis,\n} from '@fiora/database/redis/initRedis';\n\nconst { isValid } = Types.ObjectId;\n\n/** 一天时间 */\nconst OneDay = 1000 * 60 * 60 * 24;\n\ninterface Environment {\n    /** 客户端系统 */\n    os: string;\n    /** 客户端浏览器 */\n    browser: string;\n    /** 客户端环境信息 */\n    environment: string;\n}\n\n/**\n * 生成jwt token\n * @param user 用户\n * @param environment 客户端环境信息\n */\nfunction generateToken(user: string, environment: string) {\n    return jwt.encode(\n        {\n            user,\n            environment,\n            expires: Date.now() + config.tokenExpiresTime,\n        },\n        config.jwtSecret,\n    );\n}\n\n/**\n * 处理注册时间不满24小时的用户\n * @param user 用户\n */\nasync function handleNewUser(user: UserDocument, ip = '') {\n    // 将用户添加到新用户列表, 24小时后删除\n    if (Date.now() - user.createTime.getTime() < OneDay) {\n        const userId = user._id.toString();\n        await Redis.set(getNewUserKey(userId), userId, Redis.Day);\n\n        if (ip) {\n            const registeredCount = await Redis.get(\n                getNewRegisteredUserIpKey(ip),\n            );\n            await Redis.set(\n                getNewRegisteredUserIpKey(ip),\n                (parseInt(registeredCount || '0', 10) + 1).toString(),\n                Redis.Day,\n            );\n        }\n    }\n}\n\nasync function getUserNotificationTokens(user: UserDocument) {\n    const notifications = (await Notification.find({ user })) || [];\n    return notifications.map(({ token }) => token);\n}\n\n/**\n * 注册新用户\n * @param ctx Context\n */\nexport async function register(\n    ctx: Context<{ username: string; password: string } & Environment>,\n) {\n    assert(!config.disableRegister, '注册功能已被禁用, 请联系管理员开通账号');\n\n    const { username, password, os, browser, environment } = ctx.data;\n    assert(username, '用户名不能为空');\n    assert(password, '密码不能为空');\n\n    const user = await User.findOne({ username });\n    assert(!user, '该用户名已存在');\n\n    const registeredCountWithin24Hours = await Redis.get(\n        getNewRegisteredUserIpKey(ctx.socket.ip),\n    );\n    assert(parseInt(registeredCountWithin24Hours || '0', 10) < 3, '系统错误');\n\n    const defaultGroup = await Group.findOne({ isDefault: true });\n    if (!defaultGroup) {\n        // TODO: refactor when node types support \"Assertion Functions\" https://www.typescriptlang.org/docs/handbook/release-notes/typescript-3-7.html#assertion-functions\n        throw new AssertionError({ message: '默认群组不存在' });\n    }\n\n    const salt = await bcrypt.genSalt(SALT_ROUNDS);\n    const hash = await bcrypt.hash(password, salt);\n\n    let newUser = null;\n    try {\n        newUser = await User.create({\n            username,\n            salt,\n            password: hash,\n            avatar: getRandomAvatar(),\n            lastLoginIp: ctx.socket.ip,\n        } as UserDocument);\n    } catch (err) {\n        if ((err as Error).name === 'ValidationError') {\n            return '用户名包含不支持的字符或者长度超过限制';\n        }\n        throw err;\n    }\n\n    await handleNewUser(newUser, ctx.socket.ip);\n\n    if (!defaultGroup.creator) {\n        defaultGroup.creator = newUser._id;\n    }\n    defaultGroup.members.push(newUser._id);\n    await defaultGroup.save();\n\n    const token = generateToken(newUser._id.toString(), environment);\n\n    ctx.socket.user = newUser._id.toString();\n    await Socket.updateOne(\n        { id: ctx.socket.id },\n        {\n            user: newUser._id,\n            os,\n            browser,\n            environment,\n        },\n    );\n\n    return {\n        _id: newUser._id,\n        avatar: newUser.avatar,\n        username: newUser.username,\n        groups: [\n            {\n                _id: defaultGroup._id,\n                name: defaultGroup.name,\n                avatar: defaultGroup.avatar,\n                creator: defaultGroup.creator,\n                createTime: defaultGroup.createTime,\n                messages: [],\n            },\n        ],\n        friends: [],\n        token,\n        isAdmin: false,\n        notificationTokens: [],\n    };\n}\n\n/**\n * 账密登录\n * @param ctx Context\n */\nexport async function login(\n    ctx: Context<{ username: string; password: string } & Environment>,\n) {\n    const { username, password, os, browser, environment } = ctx.data;\n    assert(username, '用户名不能为空');\n    assert(password, '密码不能为空');\n\n    const user = await User.findOne({ username });\n    if (!user) {\n        throw new AssertionError({ message: '该用户不存在' });\n    }\n\n    const isPasswordCorrect = bcrypt.compareSync(password, user.password);\n    assert(isPasswordCorrect, '密码错误');\n\n    await handleNewUser(user);\n\n    user.lastLoginTime = new Date();\n    user.lastLoginIp = ctx.socket.ip;\n    await user.save();\n\n    const groups = await Group.find(\n        { members: user._id },\n        {\n            _id: 1,\n            name: 1,\n            avatar: 1,\n            creator: 1,\n            createTime: 1,\n        },\n    );\n    groups.forEach((group) => {\n        ctx.socket.join(group._id.toString());\n    });\n\n    const friends = await Friend.find({ from: user._id }).populate('to', {\n        avatar: 1,\n        username: 1,\n    });\n\n    const token = generateToken(user._id.toString(), environment);\n\n    ctx.socket.user = user._id.toString();\n    await Socket.updateOne(\n        { id: ctx.socket.id },\n        {\n            user: user._id,\n            os,\n            browser,\n            environment,\n        },\n    );\n\n    const notificationTokens = await getUserNotificationTokens(user);\n\n    return {\n        _id: user._id,\n        avatar: user.avatar,\n        username: user.username,\n        tag: user.tag,\n        groups,\n        friends,\n        token,\n        isAdmin: config.administrator.includes(user._id.toString()),\n        notificationTokens,\n    };\n}\n\n/**\n * token登录\n * @param ctx Context\n */\nexport async function loginByToken(\n    ctx: Context<{ token: string } & Environment>,\n) {\n    const { token, os, browser, environment } = ctx.data;\n    assert(token, 'token不能为空');\n\n    let payload = null;\n    try {\n        payload = jwt.decode(token, config.jwtSecret);\n    } catch (err) {\n        return '非法token';\n    }\n\n    assert(Date.now() < payload.expires, 'token已过期');\n    assert.equal(environment, payload.environment, '非法登录');\n\n    const user = await User.findOne(\n        { _id: payload.user },\n        {\n            _id: 1,\n            avatar: 1,\n            username: 1,\n            tag: 1,\n            createTime: 1,\n        },\n    );\n    if (!user) {\n        throw new AssertionError({ message: '用户不存在' });\n    }\n\n    await handleNewUser(user);\n\n    user.lastLoginTime = new Date();\n    user.lastLoginIp = ctx.socket.ip;\n    await user.save();\n\n    const groups = await Group.find(\n        { members: user._id },\n        {\n            _id: 1,\n            name: 1,\n            avatar: 1,\n            creator: 1,\n            createTime: 1,\n        },\n    );\n    groups.forEach((group: GroupDocument) => {\n        ctx.socket.join(group._id.toString());\n    });\n\n    const friends = await Friend.find({ from: user._id }).populate('to', {\n        avatar: 1,\n        username: 1,\n    });\n\n    ctx.socket.user = user._id.toString();\n    await Socket.updateOne(\n        { id: ctx.socket.id },\n        {\n            user: user._id,\n            os,\n            browser,\n            environment,\n        },\n    );\n\n    const notificationTokens = await getUserNotificationTokens(user);\n\n    return {\n        _id: user._id,\n        avatar: user.avatar,\n        username: user.username,\n        tag: user.tag,\n        groups,\n        friends,\n        isAdmin: config.administrator.includes(user._id.toString()),\n        notificationTokens,\n    };\n}\n\n/**\n * 游客登录, 只能获取默认群组信息\n * @param ctx Context\n */\nexport async function guest(ctx: Context<Environment>) {\n    const { os, browser, environment } = ctx.data;\n\n    await Socket.updateOne(\n        { id: ctx.socket.id },\n        {\n            os,\n            browser,\n            environment,\n        },\n    );\n\n    const group = await Group.findOne(\n        { isDefault: true },\n        {\n            _id: 1,\n            name: 1,\n            avatar: 1,\n            createTime: 1,\n            creator: 1,\n        },\n    );\n    if (!group) {\n        throw new AssertionError({ message: '默认群组不存在' });\n    }\n    ctx.socket.join(group._id.toString());\n\n    const messages = await Message.find(\n        { to: group._id },\n        {\n            type: 1,\n            content: 1,\n            from: 1,\n            createTime: 1,\n            deleted: 1,\n        },\n        { sort: { createTime: -1 }, limit: 15 },\n    ).populate('from', { username: 1, avatar: 1 });\n    await handleInviteV2Messages(messages);\n    messages.reverse();\n\n    return { messages, ...group.toObject() };\n}\n\n/**\n * 修改用户头像\n * @param ctx Context\n */\nexport async function changeAvatar(ctx: Context<{ avatar: string }>) {\n    const { avatar } = ctx.data;\n    assert(avatar, '新头像链接不能为空');\n\n    await User.updateOne(\n        { _id: ctx.socket.user },\n        {\n            avatar,\n        },\n    );\n\n    return {};\n}\n\n/**\n * 添加好友, 单向添加\n * @param ctx Context\n */\nexport async function addFriend(ctx: Context<{ userId: string }>) {\n    const { userId } = ctx.data;\n    assert(isValid(userId), '无效的用户ID');\n    assert(ctx.socket.user !== userId, '不能添加自己为好友');\n\n    const user = await User.findOne({ _id: userId });\n    if (!user) {\n        throw new AssertionError({ message: '添加好友失败, 用户不存在' });\n    }\n\n    const friend = await Friend.find({ from: ctx.socket.user, to: user._id });\n    assert(friend.length === 0, '你们已经是好友了');\n\n    const newFriend = await Friend.create({\n        from: ctx.socket.user as string,\n        to: user._id,\n    } as FriendDocument);\n\n    return {\n        _id: user._id,\n        username: user.username,\n        avatar: user.avatar,\n        from: newFriend.from,\n        to: newFriend.to,\n    };\n}\n\n/**\n * 删除好友, 单向删除\n * @param ctx Context\n */\nexport async function deleteFriend(ctx: Context<{ userId: string }>) {\n    const { userId } = ctx.data;\n    assert(isValid(userId), '无效的用户ID');\n\n    const user = await User.findOne({ _id: userId });\n    if (!user) {\n        throw new AssertionError({ message: '用户不存在' });\n    }\n\n    await Friend.deleteOne({ from: ctx.socket.user, to: user._id });\n    return {};\n}\n\n/**\n * 修改用户密码\n * @param ctx Context\n */\nexport async function changePassword(\n    ctx: Context<{ oldPassword: string; newPassword: string }>,\n) {\n    const { oldPassword, newPassword } = ctx.data;\n    assert(newPassword, '新密码不能为空');\n    assert(oldPassword !== newPassword, '新密码不能与旧密码相同');\n\n    const user = await User.findOne({ _id: ctx.socket.user });\n    if (!user) {\n        throw new AssertionError({ message: '用户不存在' });\n    }\n    const isPasswordCorrect = bcrypt.compareSync(oldPassword, user.password);\n    assert(isPasswordCorrect, '旧密码不正确');\n\n    const salt = await bcrypt.genSalt(SALT_ROUNDS);\n    const hash = await bcrypt.hash(newPassword, salt);\n\n    user.password = hash;\n    await user.save();\n\n    return {\n        msg: 'ok',\n    };\n}\n\n/**\n * 修改用户名\n * @param ctx Context\n */\nexport async function changeUsername(ctx: Context<{ username: string }>) {\n    const { username } = ctx.data;\n    assert(username, '新用户名不能为空');\n\n    const user = await User.findOne({ username });\n    assert(!user, '该用户名已存在, 换一个试试吧');\n\n    const self = await User.findOne({ _id: ctx.socket.user });\n    if (!self) {\n        throw new AssertionError({ message: '用户不存在' });\n    }\n\n    self.username = username;\n    await self.save();\n\n    return {\n        msg: 'ok',\n    };\n}\n\n/**\n * 重置用户密码, 需要管理员权限\n * @param ctx Context\n */\nexport async function resetUserPassword(ctx: Context<{ username: string }>) {\n    const { username } = ctx.data;\n    assert(username !== '', 'username不能为空');\n\n    const user = await User.findOne({ username });\n    if (!user) {\n        throw new AssertionError({ message: '用户不存在' });\n    }\n\n    const newPassword = 'helloworld';\n    const salt = await bcrypt.genSalt(SALT_ROUNDS);\n    const hash = await bcrypt.hash(newPassword, salt);\n\n    user.salt = salt;\n    user.password = hash;\n    await user.save();\n\n    return {\n        newPassword,\n    };\n}\n\n/**\n * 更新用户标签, 需要管理员权限\n * @param ctx Context\n */\nexport async function setUserTag(\n    ctx: Context<{ username: string; tag: string }>,\n) {\n    const { username, tag } = ctx.data;\n    assert(username !== '', 'username不能为空');\n    assert(tag !== '', 'tag不能为空');\n    assert(\n        /^([0-9a-zA-Z]{1,2}|[\\u4e00-\\u9eff]){1,5}$/.test(tag),\n        '标签不符合要求, 允许5个汉字或者10个字母',\n    );\n\n    const user = await User.findOne({ username });\n    if (!user) {\n        throw new AssertionError({ message: '用户不存在' });\n    }\n\n    user.tag = tag;\n    await user.save();\n\n    const sockets = await Socket.find({ user: user._id });\n    const socketIdList = sockets.map((socket) => socket.id);\n    if (socketIdList.length) {\n        ctx.socket.emit(socketIdList, 'changeTag', user.tag);\n    }\n\n    return {\n        msg: 'ok',\n    };\n}\n\n/**\n * 获取指定在线用户 ip\n */\nexport async function getUserIps(\n    ctx: Context<{ userId: string }>,\n): Promise<string[]> {\n    const { userId } = ctx.data;\n    assert(userId, 'userId不能为空');\n    assert(isValid(userId), '不合法的userId');\n\n    const sockets = await Socket.find({ user: userId });\n    const ipList = sockets.map((socket) => socket.ip) || [];\n    return Array.from(new Set(ipList));\n}\n\nconst UserOnlineStatusCacheExpireTime = 1000 * 60;\nfunction getUserOnlineStatusWrapper() {\n    const cache: Record<\n        string,\n        {\n            value: boolean;\n            expireTime: number;\n        }\n    > = {};\n    return async function getUserOnlineStatus(\n        ctx: Context<{ userId: string }>,\n    ) {\n        const { userId } = ctx.data;\n        assert(userId, 'userId不能为空');\n        assert(isValid(userId), '不合法的userId');\n\n        if (cache[userId] && cache[userId].expireTime > Date.now()) {\n            return {\n                isOnline: cache[userId].value,\n            };\n        }\n\n        const sockets = await Socket.find({ user: userId });\n        const isOnline = sockets.length > 0;\n        cache[userId] = {\n            value: isOnline,\n            expireTime: Date.now() + UserOnlineStatusCacheExpireTime,\n        };\n        return {\n            isOnline,\n        };\n    };\n}\nexport const getUserOnlineStatus = getUserOnlineStatusWrapper();\n"
  },
  {
    "path": "packages/server/src/types/index.d.ts",
    "content": "declare module 'regex-escape';\n"
  },
  {
    "path": "packages/server/src/types/server.d.ts",
    "content": "declare interface Context<T> {\n    data: T;\n    socket: {\n        id: string;\n        ip: string;\n        user: string;\n        isAdmin: boolean;\n        join: (room: string) => void;\n        leave: (room: string) => void;\n        emit: (target: string[] | string, event: string, data: any) => void;\n    };\n}\n\ndeclare interface RouteHandler {\n    (ctx: Context<any>): string | any;\n}\n\ndeclare type Routes = Record<string, RouteHandler | null>;\n\ndeclare type MiddlewareArgs = Array<any>;\n\ndeclare type MiddlewareNext = () => void;\n\ndeclare interface SendMessageData {\n    /** 消息目标 */\n    to: string;\n    /** 消息类型 */\n    type: string;\n    /** 消息内容 */\n    content: string;\n}\n"
  },
  {
    "path": "packages/server/test/helpers/middleware.ts",
    "content": "export function getMiddlewareParams(event = 'login', data = {}) {\n    const cb = jest.fn();\n    const next = jest.fn();\n\n    return {\n        args: [event, data, cb],\n        cb,\n        next,\n    };\n}\n"
  },
  {
    "path": "packages/server/test/middlewares/frequency.spec.ts",
    "content": "import { mocked } from 'ts-jest/utils';\nimport { Redis } from '@fiora/database/redis/initRedis';\nimport { Socket } from 'socket.io';\nimport frequency, {\n    CALL_SERVICE_FREQUENTLY,\n    NEW_USER_CALL_SERVICE_FREQUENTLY,\n} from '../../src/middlewares/frequency';\nimport { getMiddlewareParams } from '../helpers/middleware';\n\njest.mock('@fiora/database/redis/initRedis');\njest.useFakeTimers();\n\ndescribe('server/middlewares/frequency', () => {\n    it('should response call service frequently', async () => {\n        const socket = {\n            id: 'id',\n            data: {},\n        } as Socket;\n        const middleware = frequency(socket, {\n            maxCallPerMinutes: 3,\n        });\n\n        const { args, cb, next } = getMiddlewareParams('sendMessage');\n\n        await middleware(args, next);\n        expect(next).toBeCalledTimes(1);\n\n        await middleware(args, next);\n        await middleware(args, next);\n        await middleware(args, next);\n        expect(cb).toBeCalledWith(CALL_SERVICE_FREQUENTLY);\n    });\n\n    it('should response success when event is not sendMessage', async () => {\n        const socket = {\n            id: 'id',\n        } as Socket;\n        const middleware = frequency(socket, {\n            maxCallPerMinutes: 1,\n        });\n\n        const { args, next } = getMiddlewareParams('login');\n\n        await middleware(args, next);\n        await middleware(args, next);\n        expect(next).toBeCalledTimes(2);\n    });\n\n    it('should stricter for new user', async () => {\n        const socket = {\n            id: 'id',\n            data: {\n                user: '1',\n            },\n        } as Socket;\n        const middleware = frequency(socket, {\n            maxCallPerMinutes: 3,\n            newUserMaxCallPerMinutes: 1,\n        });\n\n        const { args, cb, next } = getMiddlewareParams('sendMessage');\n\n        mocked(Redis.has).mockReturnValue(Promise.resolve(true));\n        await middleware(args, next);\n        await middleware(args, next);\n        expect(cb).toBeCalledWith(NEW_USER_CALL_SERVICE_FREQUENTLY);\n    });\n\n    it('should clear count data regularly ', async () => {\n        const socket = {\n            id: 'id',\n            data: {},\n        } as Socket;\n        const middleware = frequency(socket, {\n            maxCallPerMinutes: 1,\n            clearDataInterval: 1000,\n        });\n\n        const { args, cb, next } = getMiddlewareParams('sendMessage');\n\n        await middleware(args, next);\n        await middleware(args, next);\n        expect(cb).toBeCalledWith(CALL_SERVICE_FREQUENTLY);\n\n        jest.advanceTimersByTime(1000);\n        await middleware(args, next);\n        expect(next).toBeCalledTimes(2);\n    });\n});\n"
  },
  {
    "path": "packages/server/test/middlewares/isAdmin.spec.ts",
    "content": "import { mocked } from 'ts-jest/utils';\nimport config from '@fiora/config/server';\nimport { Socket } from 'socket.io';\nimport isAdmin, {\n    YOU_ARE_NOT_ADMINISTRATOR,\n} from '../../src/middlewares/isAdmin';\nimport { getMiddlewareParams } from '../helpers/middleware';\n\njest.mock('@fiora/config/server');\n\ndescribe('server/middlewares/isAdmin', () => {\n    it('should call service fail when user not administrator', async () => {\n        const socket = {\n            id: 'id',\n            data: {\n                user: 'user',\n            },\n        } as Socket;\n        const middleware = isAdmin(socket);\n\n        const { args, cb, next } = getMiddlewareParams('sealUser');\n\n        await middleware(args, next);\n        expect(cb).toBeCalledWith(YOU_ARE_NOT_ADMINISTRATOR);\n    });\n\n    it('should call service success when user is administrator', async () => {\n        mocked(config).administrator = ['administrator'];\n        const socket = {\n            id: 'id',\n            data: {\n                user: 'administrator',\n            },\n        } as Socket;\n        const middleware = isAdmin(socket);\n\n        const { args, next } = getMiddlewareParams('sealUser');\n\n        await middleware(args, next);\n        expect(next).toBeCalled();\n    });\n});\n"
  },
  {
    "path": "packages/server/test/middlewares/isLogin.spec.ts",
    "content": "import { Socket } from 'socket.io';\nimport isLogin, { PLEASE_LOGIN } from '../../src/middlewares/isLogin';\nimport { getMiddlewareParams } from '../helpers/middleware';\n\ndescribe('server/middlewares/isLogin', () => {\n    it('should call service fail when user not login', async () => {\n        const socket = {\n            id: 'id',\n            data: {},\n        } as Socket;\n        const middleware = isLogin(socket);\n\n        const { args, cb, next } = getMiddlewareParams('sendMessage');\n\n        await middleware(args, next);\n        expect(cb).toBeCalledWith(PLEASE_LOGIN);\n    });\n\n    it('should call service success when user is login', async () => {\n        const socket = {\n            id: 'id',\n            data: {\n                user: 'user',\n            },\n        } as Socket;\n        const middleware = isLogin(socket);\n\n        const { args, next } = getMiddlewareParams('sendMessage');\n\n        await middleware(args, next);\n        expect(next).toBeCalled();\n    });\n\n    it('should call service success when it not need login ', async () => {\n        const socket = {\n            id: 'id',\n            data: {\n                user: 'user',\n            },\n        } as Socket;\n        const middleware = isLogin(socket);\n\n        const { args, next } = getMiddlewareParams('register');\n\n        await middleware(args, next);\n        expect(next).toBeCalled();\n    });\n});\n"
  },
  {
    "path": "packages/server/test/middlewares/seal.spec.ts",
    "content": "import { mocked } from 'ts-jest/utils';\nimport { SEAL_TEXT } from '@fiora/utils/const';\nimport { Socket } from 'socket.io';\nimport { Redis } from '@fiora/database/redis/initRedis';\nimport seal from '../../src/middlewares/seal';\nimport { getMiddlewareParams } from '../helpers/middleware';\n\njest.mock('@fiora/database/redis/initRedis');\n\ndescribe('server/middlewares/seal', () => {\n    it('should call service success', async () => {\n        const socket = ({\n            id: 'id',\n            data: {\n                user: 'user',\n            },\n            handshake: {\n                headers: {\n                    'x-real-ip': '127.0.0.1',\n                },\n            },\n        } as unknown) as Socket;\n        const middleware = seal(socket);\n\n        const { args, next } = getMiddlewareParams();\n\n        await middleware(args, next);\n        expect(next).toBeCalled();\n    });\n\n    it('should call service fail when user has been sealed', async () => {\n        mocked(Redis.has).mockReturnValue(Promise.resolve(true));\n        const socket = ({\n            id: 'id',\n            data: {\n                user: 'user',\n            },\n            handshake: {\n                headers: {\n                    'x-real-ip': '127.0.0.1',\n                },\n            },\n        } as unknown) as Socket;\n        const middleware = seal(socket);\n\n        const { args, cb, next } = getMiddlewareParams();\n\n        await middleware(args, next);\n        expect(cb).toBeCalledWith(SEAL_TEXT);\n    });\n});\n"
  },
  {
    "path": "packages/server/tsconfig.json",
    "content": "{\n  \"extends\": \"../../tsconfig\",\n}"
  },
  {
    "path": "packages/utils/compressImage.ts",
    "content": "/**\n * 压缩图片\n * @param image 要压缩的图片\n * @param mimeType mime类型\n * @param quality 质量\n */\nexport default function compressImage(\n    image: HTMLImageElement,\n    mimeType: string,\n    quality = 1,\n): Promise<Blob | null> {\n    return new Promise((resolve) => {\n        const canvas = document.createElement('canvas');\n        canvas.width = image.width;\n        canvas.height = image.height;\n\n        const ctx = canvas.getContext('2d');\n        if (ctx) {\n            ctx.drawImage(image, 0, 0);\n            canvas.toBlob(resolve, mimeType, quality);\n        } else {\n            resolve(null);\n        }\n    });\n}\n"
  },
  {
    "path": "packages/utils/const.ts",
    "content": "/** 封禁后提示文案 */\nexport const SEAL_TEXT = '你已经被关进小黑屋中, 请反思后再试';\n\n/** 封禁用户释放时间 */\nexport const SEAL_USER_TIMEOUT = 1000 * 60 * 10; // 10分钟\n\n/** 封禁ip释放时间 */\nexport const SEAL_IP_TIMEOUT = 1000 * 60 * 60 * 6; // 6小时\n\n/** 透明图 */\nexport const TRANSPARENT_IMAGE =\n    'data:image/png;base64,R0lGODlhFAAUAIAAAP///wAAACH5BAEAAAAALAAAAAAUABQAAAIRhI+py+0Po5y02ouz3rz7rxUAOw==';\n\n/** 加密salt位数 */\nexport const SALT_ROUNDS = 10;\n\nexport const MB = 1024 * 1024;\n\nexport const NAME_REGEXP = /^([0-9a-zA-Z]{1,2}|[\\u4e00-\\u9eff]|[\\u3040-\\u309Fー]|[\\u30A0-\\u30FF]){1,8}$/;\n"
  },
  {
    "path": "packages/utils/convertMessage.ts",
    "content": "import WuZeiNiangImage from '@fiora/assets/images/wuzeiniang.gif';\n\n// function convertRobot10Message(message) {\n//     if (message.from._id === '5adad39555703565e7903f79') {\n//         try {\n//             const parseMessage = JSON.parse(message.content);\n//             message.from.tag = parseMessage.source;\n//             message.from.avatar = parseMessage.avatar;\n//             message.from.username = parseMessage.username;\n//             message.type = parseMessage.type;\n//             message.content = parseMessage.content;\n//         } catch (err) {\n//             console.warn('解析robot10消息失败', err);\n//         }\n//     }\n// }\n\nfunction convertSystemMessage(message: any) {\n    if (message.type === 'system') {\n        message.from._id = 'system';\n        message.from.originUsername = message.from.username;\n        message.from.username = '乌贼娘殿下';\n        message.from.avatar = WuZeiNiangImage;\n        message.from.tag = 'system';\n\n        const content = JSON.parse(message.content);\n        switch (content.command) {\n            case 'roll': {\n                message.content = `掷出了${content.value}点 (上限${content.top}点)`;\n                break;\n            }\n            case 'rps': {\n                message.content = `使出了 ${content.value}`;\n                break;\n            }\n            default: {\n                message.content = '不支持的指令';\n            }\n        }\n    } else if (message.deleted) {\n        message.type = 'system';\n        message.from._id = 'system';\n        message.from.originUsername = message.from.username;\n        message.from.username = '乌贼娘殿下';\n        message.from.avatar = WuZeiNiangImage;\n        message.from.tag = 'system';\n        message.content = `撤回了消息`;\n    }\n}\n\nexport default function convertMessage(message: any) {\n    convertSystemMessage(message);\n    return message;\n}\n"
  },
  {
    "path": "packages/utils/expressions.ts",
    "content": "export default {\n    default: [\n        '呵呵',\n        '哈哈',\n        '吐舌',\n        '啊',\n        '酷',\n        '怒',\n        '开心',\n        '汗',\n        '泪',\n        '黑线',\n        '鄙视',\n        '不高兴',\n        '真棒',\n        '钱',\n        '疑问',\n        '阴险',\n        '吐',\n        '咦',\n        '委屈',\n        '花心',\n        '呼',\n        '笑眼',\n        '冷',\n        '太开心',\n        '滑稽',\n        '勉强',\n        '狂汗',\n        '乖',\n        '睡觉',\n        '惊哭',\n        '升起',\n        '惊讶',\n        '喷',\n        '爱心',\n        '心碎',\n        '玫瑰',\n        '礼物',\n        '星星月亮',\n        '太阳',\n        '音乐',\n        '灯泡',\n        '蛋糕',\n        '彩虹',\n        '钱币',\n        '咖啡',\n        'haha',\n        '胜利',\n        '大拇指',\n        '弱',\n        'ok',\n    ],\n};\n"
  },
  {
    "path": "packages/utils/getFriendId.ts",
    "content": "/**\n * Combina two users id as frind id\n * The result has nothing to do with the order of the parameters\n * @param userId1 user id\n * @param userId2 user id\n */\nexport default function getFriendId(userId1: string, userId2: string) {\n    if (userId1 < userId2) {\n        return userId1 + userId2;\n    }\n    return userId2 + userId1;\n}\n"
  },
  {
    "path": "packages/utils/getRandomAvatar.ts",
    "content": "const AvatarCount = 15;\nconst publicPath = process.env.PublicPath || '/';\n\n/**\n * 获取随机头像\n */\nexport default function getRandomAvatar() {\n    const number = Math.floor(Math.random() * AvatarCount);\n    return `${publicPath}avatar/${number}.jpg`;\n}\n\n/**\n * 获取默认头像\n */\nexport function getDefaultAvatar() {\n    return `${publicPath}avatar/0.jpg`;\n}\n"
  },
  {
    "path": "packages/utils/getRandomColor.ts",
    "content": "import randomColor from 'randomcolor';\n\ntype ColorMode = 'dark' | 'bright' | 'light' | 'random';\n\n/**\n * 获取随机颜色, 刷新页面不变\n * @param seed when passed will cause randomColor to return the same color each time\n */\nexport function getRandomColor(seed: string, luminosity: ColorMode = 'dark') {\n    return randomColor({\n        luminosity,\n        seed,\n    });\n}\n\ntype Cache = {\n    [key: string]: string;\n};\n\nconst cache: Cache = {};\n\n/**\n * 获取随机颜色, 刷新页面后重新随机\n * @param seed 随机种子\n * @param luminosity 亮度\n */\nexport function getPerRandomColor(\n    seed: string,\n    luminosity: ColorMode = 'dark',\n) {\n    if (cache[seed]) {\n        return cache[seed];\n    }\n    cache[seed] = randomColor({ luminosity });\n    return cache[seed];\n}\n"
  },
  {
    "path": "packages/utils/logger.ts",
    "content": "import { getLogger } from 'log4js';\n\nconst logger = getLogger();\nlogger.level = process.env.NODE_ENV === 'development' ? 'trace' : 'info';\n\nexport default logger;\n"
  },
  {
    "path": "packages/utils/package.json",
    "content": "{\n  \"name\": \"@fiora/utils\",\n  \"version\": \"1.0.0\",\n  \"license\": \"MIT\",\n  \"private\": true,\n  \"dependencies\": {\n    \"@fiora/assets\": \"^1.0.0\",\n    \"ali-oss\": \"^6.16.0\",\n    \"axios\": \"^0.21.1\",\n    \"log4js\": \"^6.3.0\",\n    \"randomcolor\": \"^0.6.2\",\n    \"socket.io\": \"^4.1.3\",\n    \"xss\": \"^1.0.9\"\n  },\n  \"devDependencies\": {\n    \"@types/randomcolor\": \"^0.5.6\"\n  }\n}\n"
  },
  {
    "path": "packages/utils/sleep.ts",
    "content": "export default function sleep(duration = 200) {\n    return new Promise((resolve) => {\n        setTimeout(resolve, duration);\n    });\n}\n"
  },
  {
    "path": "packages/utils/socket.ts",
    "content": "import { Socket } from 'socket.io';\n\nexport function getSocketIp(socket: Socket) {\n    return (\n        (socket.handshake.headers['x-real-ip'] as string) ||\n        socket.request.connection.remoteAddress ||\n        ''\n    );\n}\n"
  },
  {
    "path": "packages/utils/test/getFriendId.spec.ts",
    "content": "import getFriendId from '../getFriendId';\n\ndescribe('utils/getFriendId.ts', () => {\n    it('should combina two users id as friend id', () => {\n        const user1 = '111';\n        const user2 = '222';\n        expect(getFriendId(user1, user2)).toBe('111222');\n        expect(getFriendId(user2, user1)).toBe('111222');\n    });\n});\n"
  },
  {
    "path": "packages/utils/test/url.spec.ts",
    "content": "import { addParam } from '../url';\n\ndescribe('utils/url.ts', () => {\n    it('should add ?key=value into url', () => {\n        const url = 'https://fiora.suisuijiang.com';\n        const key = 'key';\n        const value = 'value';\n        const params = {\n            [key]: value,\n        };\n        expect(addParam(url, params)).toBe(`${url}?${key}=${value}`);\n    });\n\n    it('should add &key=value into url', () => {\n        const url = 'https://fiora.suisuijiang.com?a=a';\n        const key = 'key';\n        const value = 'value';\n        const params = {\n            [key]: value,\n        };\n        expect(addParam(url, params)).toBe(`${url}&${key}=${value}`);\n    });\n});\n"
  },
  {
    "path": "packages/utils/time.ts",
    "content": "export default {\n    isToday(time1: Date, time2: Date) {\n        return (\n            time1.getFullYear() === time2.getFullYear() &&\n            time1.getMonth() === time2.getMonth() &&\n            time1.getDate() === time2.getDate()\n        );\n    },\n    isYesterday(time1: Date, time2: Date) {\n        const prevDate = new Date(time1);\n        prevDate.setDate(time1.getDate() - 1);\n        return (\n            prevDate.getFullYear() === time2.getFullYear() &&\n            prevDate.getMonth() === time2.getMonth() &&\n            prevDate.getDate() === time2.getDate()\n        );\n    },\n    getHourMinute(time: Date) {\n        const hours = time.getHours();\n        const minutes = time.getMinutes();\n        return `${hours < 10 ? `0${hours}` : hours}:${\n            minutes < 10 ? `0${minutes}` : minutes\n        }`;\n    },\n    getMonthDate(time: Date) {\n        return `${time.getMonth() + 1}/${time.getDate()}`;\n    },\n};\n"
  },
  {
    "path": "packages/utils/ua.ts",
    "content": "const UA = window.navigator.userAgent;\n\nexport const isiOS = /iPhone/i.test(UA);\n\nexport const isAndroid = /android/i.test(UA);\n\nexport const isMobile = isiOS || isAndroid;\n"
  },
  {
    "path": "packages/utils/url.ts",
    "content": "interface UrlParams {\n    [key: string]: string;\n}\n\n// eslint-disable-next-line import/prefer-default-export\nexport function addParam(url: string, params: UrlParams) {\n    let result = url;\n    Object.keys(params).forEach((key) => {\n        if (result.indexOf('?') === -1) {\n            result += `?${key}=${params[key]}`;\n        } else {\n            result += `&${key}=${params[key]}`;\n        }\n    });\n    return result;\n}\n"
  },
  {
    "path": "packages/utils/xss.ts",
    "content": "import xss from 'xss';\n\n/**\n * xss防护\n * @param text 要处理的文字\n */\nexport default function processXss(text: string) {\n    return xss(text);\n}\n"
  },
  {
    "path": "packages/web/.babelrc",
    "content": "{\n    \"presets\": [\n        [\n            \"@babel/preset-env\",\n            {\n                \"targets\": \"> 0.25%, not dead\",\n                \"useBuiltIns\": \"entry\",\n                \"corejs\": 3,\n                \"modules\": false\n            }\n        ],\n        \"@babel/preset-react\",\n        \"linaria/babel\"\n    ],\n    \"plugins\": [\n        [\n            \"prismjs\",\n            {\n                \"languages\": [\n                    \"clike\",\n                    \"javascript\",\n                    \"typescript\",\n                    \"java\",\n                    \"c\",\n                    \"cpp\",\n                    \"python\",\n                    \"ruby\",\n                    \"markup\",\n                    \"markup-templating\",\n                    \"php\",\n                    \"go\",\n                    \"csharp\",\n                    \"css\",\n                    \"sql\",\n                    \"json\"\n                ],\n                \"plugins\": [\"line-numbers\", \"copy-to-clipboard\", \"show-language\"],\n                \"theme\": \"default\",\n                \"css\": true\n            }\n        ]\n    ]\n}\n"
  },
  {
    "path": "packages/web/build/webpack.common.js",
    "content": "const path = require('path');\nconst webpack = require('webpack');\nconst { CleanWebpackPlugin } = require('clean-webpack-plugin');\nconst HtmlWebpackPlugin = require('html-webpack-plugin');\nconst LessPluginAutoPrefix = require('less-plugin-autoprefix');\nconst Dotenv = require('dotenv-webpack');\n\nmodule.exports = {\n    entry: {\n        app: path.resolve(__dirname, '../src/main.tsx'),\n    },\n    output: {\n        filename: 'js/[name].[chunkhash:8].js',\n        path: path.resolve(__dirname, '../dist/fiora'),\n    },\n    resolve: {\n        extensions: ['.js', '.jsx', '.json', '.ts', '.tsx'],\n    },\n    module: {\n        rules: [\n            {\n                test: /\\.jsx?$/,\n                loader: 'babel-loader',\n                exclude: /node_modules/,\n            },\n            {\n                test: /\\.tsx?$/,\n                use: [\n                    {\n                        loader: 'babel-loader',\n                        options: {\n                            plugins: [\n                                ...(process.env.NODE_ENV === 'development'\n                                    ? [require.resolve('react-refresh/babel')]\n                                    : []),\n                            ],\n                        },\n                    },\n                    {\n                        loader: 'linaria/loader',\n                        options: {\n                            sourceMap: false,\n                        },\n                    },\n                    {\n                        loader: 'ts-loader',\n                        options: {\n                            transpileOnly: true,\n                        },\n                    },\n                ],\n                exclude: /node_modules/,\n            },\n            {\n                test: /\\.less$/,\n                use: [\n                    'style-loader',\n                    {\n                        loader: 'css-loader',\n                        options: {\n                            importLoaders: 2,\n                            modules: {\n                                localIdentName: '[local]--[hash:base64:5]',\n                            },\n                        },\n                    },\n                    {\n                        loader: 'less-loader',\n                        options: {\n                            lessOptions: {\n                                plugins: [\n                                    new LessPluginAutoPrefix({\n                                        enable: true,\n                                        options: {\n                                            browsers: ['last 3 versions'],\n                                        },\n                                    }),\n                                ],\n                            },\n                            sourceMap: false,\n                        },\n                    },\n                ],\n            },\n            {\n                test: /\\.css$/i,\n                use: ['style-loader', 'css-loader'],\n            },\n            {\n                test: /\\.(png|jpe?g|gif|svg)(\\?.*)?$/,\n                loader: 'url-loader',\n                options: {\n                    limit: 4096,\n                    name: 'images/[name].[ext]',\n                    esModule: false,\n                },\n            },\n            {\n                test: /\\.(woff2?|eot|ttf|otf)(\\?.*)?$/,\n                loader: 'url-loader',\n                options: {\n                    limit: 4096,\n                    name: 'fonts/[name].[hash:8].[ext]',\n                    esModule: false,\n                },\n            },\n            {\n                test: /\\.(mp3)(\\?.*)?$/,\n                loader: 'url-loader',\n                options: {\n                    limit: 4096,\n                    name: 'audios/[name].[hash:8].[ext]',\n                    esModule: false,\n                },\n            },\n        ],\n    },\n    plugins: [\n        new HtmlWebpackPlugin({\n            filename: 'index.html',\n            template: path.resolve(__dirname, '../src/template.html'),\n            inject: true,\n        }),\n        new CleanWebpackPlugin(),\n        new Dotenv({\n            silent: true,\n        }),\n    ],\n};\n"
  },
  {
    "path": "packages/web/build/webpack.dev.js",
    "content": "const path = require('path');\nconst { merge } = require('webpack-merge');\nconst ReactRefreshWebpackPlugin = require('@pmmmwh/react-refresh-webpack-plugin');\nconst common = require('./webpack.common.js');\n\nmodule.exports = merge(common, {\n    mode: 'development',\n    output: {\n        publicPath: '/',\n    },\n    devtool: 'inline-source-map',\n    devServer: {\n        hot: true,\n        contentBase: ['./dist'],\n        historyApiFallback: {\n            rewrites: [{ from: /\\/invite\\/group\\/[\\w\\d]+/, to: '/index.html' }],\n        },\n        proxy: {\n            '/avatar': 'http://localhost:9200',\n            '/GroupAvatar': 'http://localhost:9200',\n            '/Avatar': {\n                target: 'http://localhost:9200',\n                pathRewrite: { '^/Avatar': '/avatar' },\n            },\n            '/favicon-*.png': 'http://localhost:9200',\n        },\n    },\n    plugins: [new ReactRefreshWebpackPlugin()],\n});\n"
  },
  {
    "path": "packages/web/build/webpack.prod.js",
    "content": "const { merge } = require('webpack-merge');\nconst TerserPlugin = require('terser-webpack-plugin');\nconst ScriptExtHtmlPlugin = require('script-ext-html-webpack-plugin');\nconst WorkboxPlugin = require('workbox-webpack-plugin');\nconst WebpackBar = require('webpackbar');\nconst common = require('./webpack.common.js');\n\nmodule.exports = merge(common, {\n    mode: 'production',\n    output: {\n        publicPath: process.env.PublicPath || '/',\n    },\n    devtool: false,\n    optimization: {\n        minimize: true,\n        minimizer: [\n            new TerserPlugin({\n                terserOptions: {\n                    format: {\n                        comments: false,\n                    },\n                },\n                extractComments: false,\n            }),\n        ],\n    },\n    plugins: [\n        new ScriptExtHtmlPlugin({\n            custom: [\n                {\n                    test: /\\.js$/,\n                    attribute: 'crossorigin',\n                    value: 'anonymous',\n                },\n            ],\n        }),\n        new WorkboxPlugin.GenerateSW({\n            clientsClaim: true,\n            skipWaiting: true,\n        }),\n        new WebpackBar(),\n    ],\n});\n"
  },
  {
    "path": "packages/web/package.json",
    "content": "{\n  \"name\": \"@fiora/web\",\n  \"version\": \"1.0.0\",\n  \"license\": \"MIT\",\n  \"private\": true,\n  \"scripts\": {\n    \"dev:web\": \"cross-env NODE_ENV=development DOTENV_CONFIG_PATH=../../.env webpack serve --config build/webpack.dev.js\",\n    \"build:web\": \"rm -rf dist && cross-env NODE_ENV=production DOTENV_CONFIG_PATH=../../.env webpack --config build/webpack.prod.js && cp -r -f dist/fiora/* ../server/public\"\n  },\n  \"dependencies\": {\n    \"@fiora/assets\": \"^1.0.0\",\n    \"@fiora/config\": \"^1.0.0\",\n    \"@fiora/utils\": \"^1.0.0\",\n    \"@loadable/component\": \"^5.15.0\",\n    \"@testing-library/jest-dom\": \"^5.14.1\",\n    \"@testing-library/react\": \"^12.0.0\",\n    \"ali-oss\": \"^6.16.0\",\n    \"axios\": \"^0.21.1\",\n    \"brace\": \"^0.11.1\",\n    \"core-js\": \"^3.15.2\",\n    \"cropperjs\": \"^1.5.5\",\n    \"filesize\": \"^7.0.0\",\n    \"linaria\": \"^2.1.0\",\n    \"normalize.css\": \"^8.0.1\",\n    \"platform\": \"^1.3.6\",\n    \"prismjs\": \"^1.24.1\",\n    \"pure-render-decorator\": \"^1.2.1\",\n    \"qrcode.react\": \"^1.0.1\",\n    \"rc-dialog\": \"^8.5.2\",\n    \"rc-dropdown\": \"^3.2.0\",\n    \"rc-menu\": \"^9.0.12\",\n    \"rc-notification\": \"^3.3.1\",\n    \"rc-progress\": \"^2.5.2\",\n    \"rc-select\": \"^9.2.1\",\n    \"rc-tabs\": \"^9.6.4\",\n    \"rc-tooltip\": \"^5.1.1\",\n    \"react\": \"^17.0.2\",\n    \"react-ace\": \"^9.4.1\",\n    \"react-color\": \"^2.19.3\",\n    \"react-copy-to-clipboard\": \"^5.0.3\",\n    \"react-cropper\": \"^1.3.0\",\n    \"react-dom\": \"^17.0.2\",\n    \"react-loading\": \"^2.0.3\",\n    \"react-radio-buttons\": \"^1.2.2\",\n    \"react-redux\": \"^7.2.4\",\n    \"react-switch\": \"^6.0.0\",\n    \"react-viewer\": \"^3.2.2\",\n    \"redux\": \"^4.1.0\",\n    \"regenerator-runtime\": \"^0.13.7\",\n    \"socket.io-client\": \"^4.1.3\",\n    \"wpk-reporter\": \"^0.9.3\"\n  },\n  \"devDependencies\": {\n    \"@babel/core\": \"^7.14.6\",\n    \"@babel/plugin-syntax-dynamic-import\": \"^7.8.3\",\n    \"@babel/preset-env\": \"^7.14.7\",\n    \"@babel/preset-react\": \"^7.14.5\",\n    \"@pmmmwh/react-refresh-webpack-plugin\": \"^0.4.3\",\n    \"@testing-library/jest-dom\": \"^4.2.4\",\n    \"@types/ali-oss\": \"^6.0.10\",\n    \"@types/loadable__component\": \"^5.13.4\",\n    \"@types/platform\": \"^1.3.4\",\n    \"@types/prismjs\": \"^1.16.6\",\n    \"@types/pure-render-decorator\": \"^0.2.28\",\n    \"@types/qrcode.react\": \"^1.0.2\",\n    \"@types/react\": \"^17.0.14\",\n    \"@types/react-color\": \"^3.0.5\",\n    \"@types/react-copy-to-clipboard\": \"^5.0.1\",\n    \"@types/react-cropper\": \"^0.10.7\",\n    \"@types/react-dom\": \"^17.0.9\",\n    \"@types/react-redux\": \"^7.1.18\",\n    \"@types/redux\": \"^3.6.0\",\n    \"babel-loader\": \"^8.2.2\",\n    \"babel-plugin-dynamic-import-node\": \"^2.3.3\",\n    \"babel-plugin-prismjs\": \"^2.1.0\",\n    \"clean-webpack-plugin\": \"^4.0.0-alpha.0\",\n    \"cross-env\": \"^7.0.3\",\n    \"css-loader\": \"^5.0.0\",\n    \"dotenv-webpack\": \"^7.0.3\",\n    \"file-loader\": \"^6.2.0\",\n    \"html-webpack-plugin\": \"^5.3.2\",\n    \"less\": \"^3.12.2\",\n    \"less-loader\": \"^7.0.2\",\n    \"less-plugin-autoprefix\": \"^2.0.0\",\n    \"react-refresh\": \"^0.10.0\",\n    \"script-ext-html-webpack-plugin\": \"^2.1.5\",\n    \"style-loader\": \"^2.0.0\",\n    \"terser-webpack-plugin\": \"^5.1.4\",\n    \"ts-loader\": \"^9.2.3\",\n    \"url-loader\": \"^4.1.1\",\n    \"webpack\": \"^5.45.1\",\n    \"webpack-cli\": \"^4.7.2\",\n    \"webpack-dev-server\": \"^3.11.2\",\n    \"webpack-merge\": \"^5.8.0\",\n    \"webpackbar\": \"^5.0.0-3\",\n    \"workbox-webpack-plugin\": \"^6.1.5\"\n  }\n}\n"
  },
  {
    "path": "packages/web/src/App.less",
    "content": "@import \"./styles/variable.less\";\n\n.app {\n    width: 100%;\n    height: 100%;\n    overflow: hidden;\n}\n\n.blur, .child {\n    position: absolute;\n}\n\n.blur {\n    filter: blur(10px);\n}\n\n.child {\n    display: flex;\n    border-radius: 10px;\n    box-shadow: 0px 0px 60px rgba(0, 0, 0, 0.5);\n\n    @media @mobile {\n        border-radius: 0;\n    }\n}\n"
  },
  {
    "path": "packages/web/src/App.tsx",
    "content": "import React, { useMemo, useState, useEffect, useRef } from 'react';\nimport { useSelector } from 'react-redux';\n\nimport './styles/normalize.less';\nimport './styles/iconfont.less';\n\nimport { isMobile } from '@fiora/utils/ua';\nimport inobounce from './utils/inobounce';\nimport { getOSSFileUrl } from './utils/uploadFile';\n\nimport Style from './App.less';\nimport { State } from './state/reducer';\nimport LoginAndRegister from './modules/LoginAndRegister/LoginAndRegister';\nimport Sidebar from './modules/Sidebar/Sidebar';\nimport FunctionBarAndLinkmanList from './modules/FunctionBarAndLinkmanList/FunctionBarAndLinkmanList';\nimport UserInfo from './modules/UserInfo';\nimport GroupInfo from './modules/GroupInfo';\nimport { ShowUserOrGroupInfoContext } from './context';\nimport Chat from './modules/Chat/Chat';\nimport globalStyles from './globalStyles';\nimport InviteInfo from './modules/InviteInfo';\n\n/**\n * 获取窗口宽度百分比\n */\nfunction getWidthPercent() {\n    let width = 0.6;\n    if (isMobile) {\n        width = 1;\n    } else if (window.innerWidth < 1000) {\n        width = 0.9;\n    } else if (window.innerWidth < 1300) {\n        width = 0.8;\n    } else if (window.innerWidth < 1600) {\n        width = 0.7;\n    } else {\n        width = 0.6;\n    }\n    return width;\n}\n\n/**\n * 获取窗口高度百分比\n */\nfunction getHeightPercent() {\n    let height = 0.8;\n    if (isMobile) {\n        height = 1;\n    } else if (window.innerHeight < 1000) {\n        height = 0.9;\n    } else {\n        height = 0.8;\n    }\n    return height;\n}\n\nfunction App() {\n    const isReady = useSelector((state: State) => state.status.ready);\n    const backgroundImageUrl = useSelector(\n        (state: State) => state.status.backgroundImage,\n    );\n    const backgroundImage = isReady\n        ? getOSSFileUrl(backgroundImageUrl, `image/quality,q_95`)\n        : '#';\n    const $app = useRef(null);\n\n    // 计算窗口高度/宽度百分比\n    const [width, setWidth] = useState(getWidthPercent());\n    const [height, setHeight] = useState(getHeightPercent());\n    useEffect(() => {\n        window.onresize = () => {\n            setWidth(getWidthPercent());\n            setHeight(getHeightPercent());\n        };\n\n        // @ts-ignore\n        inobounce($app.current);\n    }, []);\n\n    // 获取底图尺寸\n    const [backgroundWidth, setBackgroundWidth] = useState(window.innerWidth);\n    const [backgroundHeight, setBackgroundHeight] = useState(\n        window.innerHeight,\n    );\n    useEffect(() => {\n        const img = new Image();\n        img.onload = () => {\n            setBackgroundWidth(Math.max(img.width, window.innerWidth));\n            setBackgroundHeight(Math.max(img.height, window.innerHeight));\n        };\n        img.src = backgroundImage;\n    }, [backgroundImage]);\n\n    // 主体样式\n    const style = useMemo(\n        () => ({\n            backgroundImage: `url(${backgroundImage})`,\n            backgroundSize: `${backgroundWidth}px ${backgroundHeight}px`,\n            backgroundRepeat: 'no-repeat',\n        }),\n        [backgroundImage, backgroundWidth, backgroundHeight],\n    );\n\n    // 聊天窗口样式\n    const childStyle = useMemo(\n        () => ({\n            width: `${width * 100}%`,\n            height: `${height * 100}%`,\n            left: `${((1 - width) / 2) * 100}%`,\n            top: `${((1 - height) / 2) * 100}%`,\n        }),\n        [width, height],\n    );\n\n    // 模糊背景样式\n    const blurStyle = useMemo(\n        () => ({\n            backgroundPosition: `${(-(1 - width) * window.innerWidth) /\n                2}px ${(-(1 - height) * window.innerHeight) / 2}px`,\n            ...style,\n            ...childStyle,\n        }),\n        [width, height, style, childStyle],\n    );\n\n    const [userInfoDialog, toggleUserInfoDialog] = useState(false);\n    const [userInfo, setUserInfo] = useState(null);\n\n    const [groupInfoDialog, toggleGroupInfoDialog] = useState(false);\n    const [groupInfo, setGroupInfo] = useState(null);\n\n    const contextValue = useMemo(\n        () => ({\n            showUserInfo(user: any) {\n                setUserInfo(user);\n                toggleUserInfoDialog(true);\n            },\n            showGroupInfo(group: any) {\n                setGroupInfo(group);\n                toggleGroupInfoDialog(true);\n            },\n        }),\n        [],\n    );\n\n    if (!isReady) {\n        return null;\n    }\n\n    return (\n        <div\n            className={`${Style.app} ${globalStyles}`}\n            style={style}\n            ref={$app}\n        >\n            <div className={Style.blur} style={blurStyle} />\n            <div className={Style.child} style={childStyle}>\n                <ShowUserOrGroupInfoContext.Provider\n                    value={(contextValue as unknown) as null}\n                >\n                    <Sidebar />\n                    <FunctionBarAndLinkmanList />\n                    <Chat />\n                </ShowUserOrGroupInfoContext.Provider>\n            </div>\n            <LoginAndRegister />\n            <InviteInfo />\n            <UserInfo\n                visible={userInfoDialog}\n                onClose={() => toggleUserInfoDialog(false)}\n                // @ts-ignore\n                user={userInfo}\n            />\n            <GroupInfo\n                visible={groupInfoDialog}\n                onClose={() => toggleGroupInfoDialog(false)}\n                // @ts-ignore\n                group={groupInfo}\n            />\n        </div>\n    );\n}\n\nexport default App;\n"
  },
  {
    "path": "packages/web/src/components/Avatar.tsx",
    "content": "import React, { SyntheticEvent, useState, useMemo } from 'react';\nimport { getOSSFileUrl } from '../utils/uploadFile';\n\nexport const avatarFailback = '/avatar/0.jpg';\n\ntype Props = {\n    /** 头像链接 */\n    src: string;\n    /** 展示大小 */\n    size?: number;\n    /** 额外类名 */\n    className?: string;\n    /** 点击事件 */\n    onClick?: () => void;\n    onMouseEnter?: () => void;\n    onMouseLeave?: () => void;\n};\n\nfunction Avatar({\n    src,\n    size = 60,\n    className = '',\n    onClick,\n    onMouseEnter,\n    onMouseLeave,\n}: Props) {\n    const [failTimes, updateFailTimes] = useState(0);\n\n    /**\n     * Handle avatar load fail event. Use faillback avatar instead\n     * If still fail then ignore error event\n     */\n    function handleError(e: SyntheticEvent<HTMLImageElement>) {\n        if (failTimes >= 2) {\n            return;\n        }\n        e.currentTarget.src = avatarFailback;\n        updateFailTimes(failTimes + 1);\n    }\n\n    const url = useMemo(() => {\n        if (/^(blob|data):/.test(src)) {\n            return src;\n        }\n        return getOSSFileUrl(\n            src,\n            `image/resize,w_${size * 2},h_${size * 2}/quality,q_90`,\n        );\n    }, [src]);\n\n    return (\n        <img\n            className={className}\n            style={{ width: size, height: size, borderRadius: size / 2 }}\n            src={url}\n            alt=\"\"\n            onClick={onClick}\n            onError={handleError}\n            onMouseEnter={onMouseEnter}\n            onMouseLeave={onMouseLeave}\n        />\n    );\n}\n\nexport default Avatar;\n"
  },
  {
    "path": "packages/web/src/components/Button.tsx",
    "content": "import React from 'react';\n\nimport { css } from 'linaria';\n\nconst button = css`\n    border: none;\n    background-color: var(--primary-color-8_5);\n    color: var(--primary-text-color-10);\n    border-radius: 4px;\n    font-size: 14px;\n    transition: background-color 0.4s;\n    user-select: none !important;\n\n    &:hover {\n        background-color: var(--primary-color-10);\n    }\n`;\n\ntype Props = {\n    /** 类型: primary / danger */\n    type?: string;\n    /** 按钮文本 */\n    children: string;\n    className?: string;\n    /** 点击事件 */\n    onClick?: () => void;\n};\n\nfunction Button({\n    type = 'primary',\n    children,\n    className = '',\n    onClick,\n}: Props) {\n    return (\n        <button\n            className={`${button} ${type} ${className}`}\n            type=\"button\"\n            onClick={onClick}\n        >\n            {children}\n        </button>\n    );\n}\n\nexport default Button;\n"
  },
  {
    "path": "packages/web/src/components/Dialog.less",
    "content": "@import '../styles/variable.less';\n\n:global {\n    .rc-dialog {\n        width: 450px;\n        top: 45% !important;\n        transform: translateY(-50%) !important;\n    }\n    .rc-dialog-content {\n        width: 100%;\n    }\n    .rc-dialog-title {\n        font-size: 16px !important;\n    }\n    .rc-dialog-wrap {\n        overflow: hidden !important;\n    }\n    .rc-dialog-close {\n        top: 0 !important;\n        right: 10px !important;\n        z-index: 9999;\n    \n        .rc-dialog-close-x {\n            font-size: 32px !important;\n        }\n    }\n    \n    .rc-dialog-body {\n        overflow-y: auto;\n        -webkit-overflow-scrolling: touch;\n        max-height: 60vh;\n    }\n    \n    @media @mobile {\n        .rc-dialog {\n            width: 94% !important;\n            margin: 0 auto;\n        }\n        .rc-dialog-body {\n            padding: 10px 10px;\n            // max-height: 88%;\n        }\n        .rc-dialog-content {\n            max-height: 80vh;\n        }\n    }\n}"
  },
  {
    "path": "packages/web/src/components/Dialog.tsx",
    "content": "import Dialog from 'rc-dialog';\nimport 'rc-dialog/assets/index.css';\n\nimport './Dialog.less';\n\nexport default Dialog;\n"
  },
  {
    "path": "packages/web/src/components/Dropdown.less",
    "content": ":global {\n    .rc-dropdown {\n        max-width: 100%;\n    }\n    \n    .rc-select-dropdown {\n        z-index: 1500 !important;\n    }\n}"
  },
  {
    "path": "packages/web/src/components/Dropdown.tsx",
    "content": "import Dropdown from 'rc-dropdown';\nimport 'rc-dropdown/assets/index.css';\n\nimport './Dropdown.less';\n\nexport default Dropdown;\n"
  },
  {
    "path": "packages/web/src/components/IconButton.less",
    "content": ".iconButton {\n    text-align: center;\n    color: rgba(165, 181, 192, 1);\n    cursor: pointer;\n\n    &:hover {\n        color: rgba(247, 247, 247, 1);\n    }\n}"
  },
  {
    "path": "packages/web/src/components/IconButton.tsx",
    "content": "import React from 'react';\n\nimport Style from './IconButton.less';\n\ntype Props = {\n    width: number;\n    height: number;\n    icon: string;\n    iconSize: number;\n    className?: string;\n    style?: Object;\n    onClick?: () => void;\n};\n\nfunction IconButton({\n    width,\n    height,\n    icon,\n    iconSize,\n    onClick = () => {},\n    className = '',\n    style = {},\n}: Props) {\n    return (\n        <div\n            className={`${Style.iconButton} ${className}`}\n            style={{ width, height, ...style }}\n            onClick={onClick}\n            role=\"button\"\n        >\n            <i\n                className={`iconfont icon-${icon}`}\n                style={{ fontSize: iconSize, lineHeight: `${height}px` }}\n            />\n        </div>\n    );\n}\n\nexport default IconButton;\n"
  },
  {
    "path": "packages/web/src/components/Input.less",
    "content": ".inputContainer {\n    position: relative;\n}\n\n.input {\n    width: 100%;\n    height: 100%;\n    border-radius: 6px;\n    border: 1px solid rgba(0, 0, 0, 0.2);\n    padding: 0 34px 0 8px;\n    font-size: 14px;\n    color: #333;\n    box-sizing: border-box;\n    user-select: auto;\n\n    &:focus {\n        border-color: var(--primary-color-10);\n    }\n}\n\n.inputIconButton {\n    position: absolute;\n    top: 0;\n    bottom: 0;\n    margin: auto;\n    right: 5px;\n\n    &:hover {\n        color: var(--primary-color-10);\n    }\n}"
  },
  {
    "path": "packages/web/src/components/Input.tsx",
    "content": "import React, { useRef, useState } from 'react';\n\nimport IconButton from './IconButton';\nimport Style from './Input.less';\n\ninterface InputProps {\n    value?: string;\n    type?: string;\n    placeholder?: string;\n    className?: string;\n    onChange: (value: string) => void;\n    onEnter?: (value: string) => void;\n    onFocus?: () => void;\n}\n\nfunction Input(props: InputProps) {\n    const {\n        value,\n        type = 'text',\n        placeholder = '',\n        className = '',\n        onChange,\n        onEnter = () => {},\n        onFocus = () => {},\n    } = props;\n\n    function handleInput(e: any) {\n        onChange(e.target.value);\n    }\n\n    const [lockEnter, setLockEnter] = useState(false);\n    function handleIMEStart() {\n        setLockEnter(true);\n    }\n    function handleIMEEnd() {\n        setLockEnter(false);\n    }\n    function handleKeyDown(e: any) {\n        if (lockEnter) {\n            return;\n        }\n        if (e.key === 'Enter') {\n            onEnter(value as string);\n        }\n    }\n\n    const $input = useRef(null);\n    function handleClickClear() {\n        onChange('');\n        // @ts-ignore\n        $input.current.focus();\n    }\n\n    return (\n        <div className={`${Style.inputContainer} ${className}`}>\n            <input\n                className={Style.input}\n                type={type}\n                value={value}\n                onChange={handleInput}\n                onInput={handleInput}\n                placeholder={placeholder}\n                ref={$input}\n                onKeyDown={handleKeyDown}\n                onCompositionStart={handleIMEStart}\n                onCompositionEnd={handleIMEEnd}\n                onFocus={onFocus}\n            />\n            <IconButton\n                className={Style.inputIconButton}\n                width={32}\n                height={32}\n                iconSize={18}\n                icon=\"clear\"\n                onClick={handleClickClear}\n            />\n        </div>\n    );\n}\n\nexport default Input;\n"
  },
  {
    "path": "packages/web/src/components/Loading.tsx",
    "content": "import Loading from 'react-loading';\n\nexport default Loading;\n"
  },
  {
    "path": "packages/web/src/components/Menu.tsx",
    "content": "import Menu, { SubMenu, MenuItem } from 'rc-menu';\nimport 'rc-menu/assets/index.css';\n\nexport { Menu, MenuItem, SubMenu };\n"
  },
  {
    "path": "packages/web/src/components/Message.less",
    "content": ":global {\n    .rc-notification {\n        top: 5px !important;\n        z-index: 1100 !important;\n    }\n}\n\n.componentMessage {\n    height: 100%;\n    display: flex;\n    align-items: center;\n    margin-top: 1px;\n\n    :global {\n        .iconfont {\n            font-size: 22px;\n    \n            &.icon-success {\n                color: green;\n            }\n            &.icon-error {\n                color: #d82e2e;\n            }\n            &.icon-warning {\n                color: orange;\n            }\n            &.icon-info {\n                color: #2773ef;\n            }\n        }\n    }\n}\n\n.messageText {\n    margin-left: 8px;\n    font-size: 16px;\n}"
  },
  {
    "path": "packages/web/src/components/Message.tsx",
    "content": "import React from 'react';\nimport Notification from 'rc-notification';\n\nimport 'rc-notification/dist/rc-notification.min.css';\nimport Style from './Message.less';\n\nfunction showMessage(text: string, duration = 1500, type = 'success') {\n    Notification.newInstance({}, (notification: any) => {\n        notification.notice({\n            content: (\n                <div className={Style.componentMessage}>\n                    <i className={`iconfont icon-${type}`} />\n                    <span className={Style.messageText}>{text}</span>\n                </div>\n            ),\n            duration,\n        });\n    });\n}\n\nexport default {\n    success(text: string, duration = 1.5) {\n        showMessage(text, duration, 'success');\n    },\n    error(text: string, duration = 1.5) {\n        showMessage(text, duration, 'error');\n    },\n    warning(text: string, duration = 1.5) {\n        showMessage(text, duration, 'warning');\n    },\n    info(text: string, duration = 1.5) {\n        showMessage(text, duration, 'info');\n    },\n};\n"
  },
  {
    "path": "packages/web/src/components/Progress.tsx",
    "content": "import { Line as LineProgress, Circle as CircleProgress } from 'rc-progress';\nimport 'rc-progress/assets/index.css';\n\nexport { LineProgress, CircleProgress };\n"
  },
  {
    "path": "packages/web/src/components/Select.tsx",
    "content": "import Select, { Option, OptGroup } from 'rc-select';\nimport 'rc-select/assets/index.css';\n\nexport { Select, Option, OptGroup };\n"
  },
  {
    "path": "packages/web/src/components/Tabs.tsx",
    "content": "import Tabs, { TabPane } from 'rc-tabs';\nimport TabContent from 'rc-tabs/lib/TabContent';\nimport ScrollableInkTabBar from 'rc-tabs/lib/ScrollableInkTabBar';\nimport 'rc-tabs/assets/index.css';\n\nexport { Tabs, TabPane, TabContent, ScrollableInkTabBar };\n"
  },
  {
    "path": "packages/web/src/components/Tooltip.less",
    "content": ":global {\n    .rc-tooltip {\n        display: inline-block !important;\n    }\n    .rc-tooltip-hidden {\n        display: none !important;\n    }\n    .rc-tooltip-inner {\n        span {\n            color: #f1f1f1 !important;\n        }\n    }\n}"
  },
  {
    "path": "packages/web/src/components/Tooltip.tsx",
    "content": "import Tooltip from 'rc-tooltip';\nimport 'rc-tooltip/assets/bootstrap.css';\n\nimport './Tooltip.less';\n\nexport default Tooltip;\n"
  },
  {
    "path": "packages/web/src/context.ts",
    "content": "import { createContext } from 'react';\n\n// eslint-disable-next-line import/prefer-default-export\nexport const ShowUserOrGroupInfoContext = createContext(null);\n"
  },
  {
    "path": "packages/web/src/globalStyles.ts",
    "content": "// @ts-ignore\nimport { css } from 'linaria';\n\nconst globalStyles = css`\n    :global() {\n        .danger {\n            background-color: #dd514c !important;\n\n            &:hover {\n                background-color: #d7342e !important;\n            }\n        }\n    }\n`;\n\nexport default globalStyles;\n"
  },
  {
    "path": "packages/web/src/hooks/useAction.ts",
    "content": "import { useDispatch } from 'react-redux';\nimport convertMessage from '@fiora/utils/convertMessage';\nimport { User, Linkman, Message } from '../state/reducer';\nimport { ActionTypes } from '../state/action';\n\n/**\n * 获取 redux action\n */\nexport default function useAction() {\n    const dispatch = useDispatch();\n\n    return {\n        setUser(user: User) {\n            dispatch({\n                type: ActionTypes.SetUser,\n                payload: user,\n            });\n        },\n\n        logout() {\n            dispatch({\n                type: ActionTypes.Logout,\n            });\n        },\n\n        setAvatar(avatar: string) {\n            dispatch({\n                type: ActionTypes.SetAvatar,\n                payload: avatar,\n            });\n        },\n\n        setFocus(linkmanId: string) {\n            dispatch({\n                type: ActionTypes.SetFocus,\n                payload: linkmanId,\n            });\n        },\n\n        addLinkman(linkman: Linkman, focus = false) {\n            dispatch({\n                type: ActionTypes.AddLinkman,\n                payload: {\n                    linkman,\n                    focus,\n                },\n            });\n        },\n\n        removeLinkman(linkmanId: string) {\n            dispatch({\n                type: ActionTypes.RemoveLinkman,\n                payload: linkmanId,\n            });\n        },\n\n        addLinkmanHistoryMessages(linkmanId: string, messages: Message[]) {\n            messages.forEach((message) => convertMessage(message));\n            dispatch({\n                type: ActionTypes.AddLinkmanHistoryMessages,\n                payload: {\n                    linkmanId,\n                    messages,\n                },\n            });\n        },\n\n        addLinkmanMessage(linkmanId: string, message: Message) {\n            convertMessage(message);\n            dispatch({\n                type: ActionTypes.AddLinkmanMessage,\n                payload: {\n                    linkmanId,\n                    message,\n                },\n            });\n        },\n\n        setLinkmanProperty(linkmanId: string, key: string, value: any) {\n            dispatch({\n                type: ActionTypes.SetLinkmanProperty,\n                payload: {\n                    linkmanId,\n                    key,\n                    value,\n                },\n            });\n        },\n\n        updateMessage(linkmanId: string, messageId: string, value: any) {\n            convertMessage(value);\n            dispatch({\n                type: ActionTypes.UpdateMessage,\n                payload: {\n                    linkmanId,\n                    messageId,\n                    value,\n                },\n            });\n        },\n\n        deleteMessage(linkmanId: string, messageId: string, shouldDelete: boolean = false) {\n            dispatch({\n                type: ActionTypes.DeleteMessage,\n                payload: {\n                    linkmanId,\n                    messageId,\n                    shouldDelete,\n                },\n            });\n        },\n\n        setStatus(key: string, value: any) {\n            dispatch({\n                type: ActionTypes.SetStatus,\n                payload: {\n                    key,\n                    value,\n                },\n            });\n            window.localStorage.setItem(key, value);\n        },\n\n        toggleLoginRegisterDialog(visible: boolean) {\n            dispatch({\n                type: ActionTypes.SetStatus,\n                payload: {\n                    key: 'loginRegisterDialogVisible',\n                    value: visible,\n                },\n            });\n        },\n    };\n}\n"
  },
  {
    "path": "packages/web/src/hooks/useAero.ts",
    "content": "import { useSelector } from 'react-redux';\nimport { State } from '../state/reducer';\n\n/**\n * 获取毛玻璃状态属性\n */\nexport default function useAero() {\n    const aero = useSelector((state: State) => state.status.aero);\n    return {\n        'data-aero': aero,\n    };\n}\n"
  },
  {
    "path": "packages/web/src/hooks/useIsLogin.ts",
    "content": "import { useSelector } from 'react-redux';\nimport { State } from '../state/reducer';\n\n/**\n * 获取登录态\n */\nexport default function useIsLogin() {\n    const isLogin = useSelector(\n        (state: State) => state.user && state.user._id !== '',\n    );\n    return isLogin;\n}\n"
  },
  {
    "path": "packages/web/src/hooks/useStore.ts",
    "content": "import { useSelector } from 'react-redux';\nimport { State, Linkman } from '../state/reducer';\n\nexport function useStore() {\n    return useSelector((state: State) => state);\n}\n\nexport function useFocusLinkman(): Linkman | null {\n    const store = useStore();\n    const { focus } = store;\n    if (focus) {\n        return store.linkmans?.[focus];\n    }\n    return null;\n}\n\nexport function useSelfId() {\n    const store = useStore();\n    return store.user?._id || '';\n}\n"
  },
  {
    "path": "packages/web/src/localStorage.ts",
    "content": "import config from '@fiora/config/client';\nimport themes from './themes';\n\n/** LocalStorage存储的键值 */\nexport enum LocalStorageKey {\n    Theme = 'theme',\n    PrimaryColor = 'primaryColor',\n    PrimaryTextColor = 'primaryTextColor',\n    BackgroundImage = 'backgroundImage',\n    Aero = 'aero',\n    Sound = 'sound',\n    SoundSwitch = 'soundSwitch',\n    NotificationSwitch = 'notificationSwitch',\n    VoiceSwitch = 'voiceSwitch',\n    SelfVoiceSwitch = 'selfVoiceSwitch',\n    TagColorMode = 'tagColorMode',\n    EnableSearchExpression = 'enableSearchExpression',\n}\n\n/**\n * 获取LocalStorage中的文本值\n * @param key 键值\n * @param defaultValue 默认值\n */\nfunction getTextValue(key: string, defaultValue: string) {\n    const value = window.localStorage.getItem(key);\n    return value || defaultValue;\n}\n\n/**\n * 获取LocalStorage中的boolean值\n * @param key 键值\n * @param defaultValue 默认值\n */\nfunction getSwitchValue(key: string, defaultValue: boolean = true) {\n    const value = window.localStorage.getItem(key);\n    return value ? value === 'true' : defaultValue;\n}\n\n/**\n * 获取LocalStorage值\n */\nexport default function getData() {\n    const theme = getTextValue(LocalStorageKey.Theme, config.defaultTheme);\n    let themeConfig = {\n        primaryColor: '',\n        primaryTextColor: '',\n        backgroundImage: '',\n        aero: false,\n    };\n    // @ts-ignore\n    if (theme && themes[theme]) {\n        // @ts-ignore\n        themeConfig = themes[theme];\n    } else {\n        themeConfig = {\n            primaryColor: getTextValue(\n                LocalStorageKey.PrimaryColor,\n                themes[config.defaultTheme]?.primaryColor,\n            ),\n            primaryTextColor: getTextValue(\n                LocalStorageKey.PrimaryTextColor,\n                themes[config.defaultTheme]?.primaryTextColor,\n            ),\n            backgroundImage: getTextValue(\n                LocalStorageKey.BackgroundImage,\n                themes[config.defaultTheme]?.backgroundImage,\n            ),\n            aero: getSwitchValue(LocalStorageKey.Aero, false),\n        };\n    }\n    return {\n        theme,\n        ...themeConfig,\n        sound: getTextValue(LocalStorageKey.Sound, config.sound),\n        soundSwitch: getSwitchValue(LocalStorageKey.SoundSwitch),\n        notificationSwitch: getSwitchValue(LocalStorageKey.NotificationSwitch),\n        voiceSwitch: getSwitchValue(LocalStorageKey.VoiceSwitch),\n        selfVoiceSwitch: getSwitchValue(LocalStorageKey.SelfVoiceSwitch, false),\n        tagColorMode: getTextValue(\n            LocalStorageKey.TagColorMode,\n            config.tagColorMode,\n        ),\n        enableSearchExpression: getSwitchValue(\n            LocalStorageKey.EnableSearchExpression,\n            false,\n        ),\n    };\n}\n"
  },
  {
    "path": "packages/web/src/main.tsx",
    "content": "/* eslint-disable camelcase */\nimport 'core-js/stable';\nimport 'regenerator-runtime/runtime';\n\nimport React from 'react';\nimport ReactDom from 'react-dom';\nimport { Provider } from 'react-redux';\n\nimport config from '@fiora/config/client';\nimport setCssVariable from './utils/setCssVariable';\nimport App from './App';\nimport store from './state/store';\nimport getData from './localStorage';\n\n// 注册 Service Worker\nif (window.location.protocol === 'https:' && 'serviceWorker' in navigator) {\n    window.addEventListener('load', () => {\n        navigator.serviceWorker.register(`/service-worker.js`);\n    });\n}\n\n// 如果配置了前端监控, 动态加载并启动监控\nif (config.frontendMonitorAppId) {\n    // @ts-ignore\n    import(/* webpackChunkName: \"frontend-monitor\" */ 'wpk-reporter').then(\n        (module) => {\n            const WpkReporter = module.default;\n\n            const __wpk = new WpkReporter({\n                bid: config.frontendMonitorAppId,\n                spa: true,\n                rel: config.frontendMonitorAppId,\n                uid: () => localStorage.getItem('username') || '',\n                plugins: [],\n            });\n\n            __wpk.installAll();\n        },\n    );\n}\n\n// 更新 css variable\nconst { primaryColor, primaryTextColor } = getData();\nsetCssVariable(primaryColor, primaryTextColor);\n\n// 请求 Notification 授权\nif (\n    window.Notification &&\n    (window.Notification.permission === 'default' ||\n        window.Notification.permission === 'denied')\n) {\n    window.Notification.requestPermission();\n}\n\nif (window.location.pathname !== '/') {\n    const { pathname } = window.location;\n    window.history.pushState({}, 'fiora', '/');\n    if (pathname.startsWith('/invite/group/')) {\n        const groupId = pathname.replace(`/invite/group/`, '');\n        window.sessionStorage.setItem('inviteGroupId', groupId);\n    }\n}\n\nReactDom.render(\n    <Provider store={store}>\n        <App />\n    </Provider>,\n    document.getElementById('app'),\n);\n"
  },
  {
    "path": "packages/web/src/modules/Chat/Chat.less",
    "content": "@import '../../styles/variable.less';\n\n.chat {\n    flex: 1;\n    display: flex;\n    flex-direction: column;\n    background-color: rgba(241, 241, 241, 0.6);\n    border-top-right-radius: 10px;\n    border-bottom-right-radius: 10px;\n    overflow: hidden;\n    position: relative;\n\n    &[data-aero=true] {\n        background-color: rgba(241, 241, 241, 0.15);\n    }\n\n    @media @mobile {\n        border-top-right-radius: 0;\n        border-bottom-right-radius: 0;\n    }\n}\n\n.noLinkman {\n    flex: 1;\n    display: flex;\n    align-items: center;\n    justify-content: center;\n    flex-direction: column;\n}\n\n.noLinkmanImage {\n    border-radius: 8px;\n    width: 170px;\n    height: 180px;\n    background-image: url('~@fiora/assets/images/no-linkman.jpeg');\n    background-position-y: 180px;\n}\n\n.noLinkmanText {\n    margin-top: 16px;\n    font-size: 14px;\n    color: #666;\n}\n"
  },
  {
    "path": "packages/web/src/modules/Chat/Chat.tsx",
    "content": "import React, { useContext, useState, useEffect } from 'react';\nimport { useSelector } from 'react-redux';\n\nimport Style from './Chat.less';\nimport HeaderBar from './HeaderBar';\nimport MessageList from './MessageList';\nimport ChatInput from './ChatInput';\nimport GroupManagePanel from './GroupManagePanel';\nimport { State, GroupMember } from '../../state/reducer';\nimport { ShowUserOrGroupInfoContext } from '../../context';\nimport useIsLogin from '../../hooks/useIsLogin';\nimport {\n    getGroupOnlineMembers,\n    getUserOnlineStatus,\n    updateHistory,\n} from '../../service';\nimport useAction from '../../hooks/useAction';\nimport useAero from '../../hooks/useAero';\nimport store from '../../state/store';\n\nlet lastMessageIdCache = '';\n\nfunction Chat() {\n    const isLogin = useIsLogin();\n    const action = useAction();\n    const hasUserInfo = useSelector((state: State) => !!state.user);\n    const focus = useSelector((state: State) => state.focus);\n    const linkman = useSelector((state: State) => state.linkmans[focus]);\n    const [groupManagePanel, toggleGroupManagePanel] = useState(false);\n    const context = useContext(ShowUserOrGroupInfoContext);\n    const aero = useAero();\n    const self = useSelector((state: State) => state.user?._id) || '';\n\n    function handleBodyClick(e: MouseEvent) {\n        const { currentTarget } = e;\n        let target = e.target as HTMLDivElement;\n        do {\n            if (target.getAttribute('data-float-panel') === 'true') {\n                return;\n            }\n            // @ts-ignore\n            target = target.parentElement;\n        } while (target && target !== currentTarget);\n        toggleGroupManagePanel(false);\n    }\n    useEffect(() => {\n        document.body.addEventListener('click', handleBodyClick, false);\n        return () => {\n            document.body.removeEventListener('click', handleBodyClick, false);\n        };\n    }, []);\n\n    async function fetchGroupOnlineMembers() {\n        let onlineMembers: GroupMember[] | { cache: true } = [];\n        if (isLogin) {\n            onlineMembers = await getGroupOnlineMembers(focus);\n        }\n        if (Array.isArray(onlineMembers)) {\n            action.setLinkmanProperty(focus, 'onlineMembers', onlineMembers);\n        }\n    }\n    async function fetchUserOnlineStatus() {\n        const isOnline = await getUserOnlineStatus(focus.replace(self, ''));\n        action.setLinkmanProperty(focus, 'isOnline', isOnline);\n    }\n    useEffect(() => {\n        if (!linkman) {\n            return () => {};\n        }\n        const request =\n            linkman.type === 'group'\n                ? fetchGroupOnlineMembers\n                : fetchUserOnlineStatus;\n        request();\n        const timer = setInterval(() => request(), 1000 * 60);\n        return () => clearInterval(timer);\n    }, [focus]);\n\n    async function intervalUpdateHistory() {\n        // Must get real-time state\n        const state = store.getState();\n        if (\n            !window.document.hidden &&\n            state.focus &&\n            state.linkmans[state.focus] &&\n            state.user?._id\n        ) {\n            const messageKeys = Object.keys(\n                state.linkmans[state.focus].messages,\n            );\n            if (messageKeys.length > 0) {\n                const lastMessageId =\n                    state.linkmans[state.focus].messages[\n                        messageKeys[messageKeys.length - 1]\n                    ]._id;\n                if (lastMessageId !== lastMessageIdCache) {\n                    lastMessageIdCache = lastMessageId;\n                    await updateHistory(state.focus, lastMessageId);\n                }\n            }\n        }\n    }\n    useEffect(() => {\n        const timer = setInterval(intervalUpdateHistory, 1000 * 30);\n        return () => clearInterval(timer);\n    }, [focus]);\n\n    if (!hasUserInfo) {\n        return <div className={Style.chat} />;\n    }\n    if (!linkman) {\n        return (\n            <div className={Style.chat}>\n                <HeaderBar id=\"\" name=\"\" type=\"\" onClickFunction={() => {}} />\n                <div className={Style.noLinkman}>\n                    <div className={Style.noLinkmanImage} />\n                    <h2 className={Style.noLinkmanText}>\n                        找个群或者好友呀, 不然怎么聊天~~\n                    </h2>\n                </div>\n            </div>\n        );\n    }\n\n    async function handleClickFunction() {\n        if (linkman.type === 'group') {\n            let onlineMembers: GroupMember[] | { cache: true } = [];\n            if (isLogin) {\n                onlineMembers = await getGroupOnlineMembers(focus);\n            }\n            if (Array.isArray(onlineMembers)) {\n                action.setLinkmanProperty(\n                    focus,\n                    'onlineMembers',\n                    onlineMembers,\n                );\n            }\n            toggleGroupManagePanel(true);\n        } else {\n            // @ts-ignore\n            context.showUserInfo(linkman);\n        }\n    }\n\n    return (\n        <div className={Style.chat} {...aero}>\n            <HeaderBar\n                id={linkman._id}\n                name={linkman.name}\n                type={linkman.type}\n                onlineMembersCount={linkman.onlineMembers?.length}\n                isOnline={linkman.isOnline}\n                onClickFunction={handleClickFunction}\n            />\n            <MessageList />\n            <ChatInput />\n\n            {linkman.type === 'group' && (\n                <GroupManagePanel\n                    visible={groupManagePanel}\n                    onClose={() => toggleGroupManagePanel(false)}\n                    groupId={linkman._id}\n                    avatar={linkman.avatar}\n                    creator={linkman.creator}\n                    onlineMembers={linkman.onlineMembers}\n                />\n            )}\n        </div>\n    );\n}\n\nexport default Chat;\n"
  },
  {
    "path": "packages/web/src/modules/Chat/ChatInput.less",
    "content": "@import '../../styles/variable.less';\n\n.chatInput {\n    height: 70px;\n    background-color: rgba(255, 255, 255, 0.5);\n    display: flex;\n    align-items: center;\n    padding: 0px 20px;\n    border-bottom-right-radius: 10px;\n    position: relative;\n\n    &[data-aero='true'] {\n        background-color: rgba(255, 255, 255, 0.15);\n\n        .input {\n            background-color: rgba(255, 255, 255, 0.6);\n            border: none;\n            &::placeholder {\n                color: #888;\n            }\n        }\n\n        .iconButton {\n            :global {\n                .iconfont {\n                    color: var(--primary-text-color-8);\n                }\n            }\n            &:hover {\n                :global {\n                    .iconfont {\n                        color: var(--primary-color-10);\n                    }\n                }\n            }\n        }\n    }\n\n    @media @mobile {\n        height: 50px;\n        padding: 0 6px;\n        border-bottom-right-radius: 0px;\n        height: calc(50px + env(safe-area-inset-bottom));\n        padding-bottom: env(safe-area-inset-bottom);\n    }\n}\n\n.form {\n    flex: 1;\n    display: flex;\n    position: relative;\n    margin: 0 10px;\n}\n\n.input {\n    flex: 1;\n    padding: 0px 8px;\n    height: 32px;\n    line-height: 32px;\n    outline: none;\n    border: 1px solid rgba(208, 208, 208, 0.5);\n    font-size: 14px;\n    color: #666;\n    user-select: auto;\n\n    &::placeholder {\n        color: #999;\n    }\n}\n\n.tooltip {\n    position: absolute;\n    right: 10px;\n    top: 5px;\n    font-size: 22px;\n    color: #aaa;\n}\n\n.iconButton {\n    :global {\n        .iconfont {\n            color: #aaa;\n        }\n    }\n\n    &:hover {\n        :global {\n            .iconfont {\n                color: var(--primary-color-10);\n            }\n        }\n    }\n}\n\n.guest {\n    color: #333;\n    user-select: none;\n    font-size: 14px;\n    flex: 1;\n    text-align: center;\n    letter-spacing: 1px;\n}\n\n.guestLogin {\n    color: var(--primary-color-10);\n    cursor: pointer;\n    font-size: 16px;\n}\n\n.expressionDropdown {\n    width: 415px;\n    height: 240px;\n    background-color: white;\n    transform: translateY(-8px);\n\n    @media @mobile {\n        width: 100%;\n    }\n}\n\n.featureDropdown {\n    background-color: white;\n\n    :global {\n        .rc-menu {\n            padding: 6px 0;\n        }\n        .rc-menu-item {\n            padding: 12px 20px !important;\n        }\n    }\n}\n\n.atPanel {\n    background-color: white;\n    position: absolute;\n    left: 120px;\n    bottom: 60px;\n    padding: 6px 0;\n    border-radius: 3px;\n    max-height: 266px;\n    overflow-y: auto;\n    -webkit-overflow-scrolling: touch;\n}\n\n.atUserList {\n    display: flex;\n    align-items: center;\n    cursor: pointer;\n    margin-bottom: 2px;\n    height: 30px;\n    padding: 0 8px;\n    min-width: 120px;\n\n    &:last-child {\n        margin-bottom: 0px;\n    }\n    &:hover {\n        background-color: var(--primary-color-4);\n    }\n}\n\n.atText {\n    font-size: 14px;\n    margin-left: 6px;\n    margin-top: 4px;\n    color: #444;\n}\n"
  },
  {
    "path": "packages/web/src/modules/Chat/ChatInput.tsx",
    "content": "import React, { useState, useRef, useEffect } from 'react';\nimport { useSelector } from 'react-redux';\nimport loadable from '@loadable/component';\n\nimport { css } from 'linaria';\nimport xss from '@fiora/utils/xss';\nimport compressImage from '@fiora/utils/compressImage';\nimport config from '@fiora/config/client';\nimport { isMobile } from '@fiora/utils/ua';\nimport fetch from '../../utils/fetch';\nimport voice from '../../utils/voice';\nimport readDiskFile, { ReadFileResult } from '../../utils/readDiskFile';\nimport uploadFile from '../../utils/uploadFile';\nimport getRandomHuaji from '../../utils/getRandomHuaji';\nimport Style from './ChatInput.less';\nimport useIsLogin from '../../hooks/useIsLogin';\nimport useAction from '../../hooks/useAction';\nimport Dropdown from '../../components/Dropdown';\nimport IconButton from '../../components/IconButton';\nimport Avatar from '../../components/Avatar';\nimport Message from '../../components/Message';\nimport { Menu, MenuItem } from '../../components/Menu';\nimport { State } from '../../state/reducer';\nimport { sendMessage } from '../../service';\nimport Tooltip from '../../components/Tooltip';\nimport useAero from '../../hooks/useAero';\n\nconst expressionList = css`\n    display: flex;\n    width: 100%;\n    height: 80px;\n    position: absolute;\n    left: 0;\n    top: -80px;\n    background-color: inherit;\n    overflow-x: auto;\n`;\nconst expressionImageContainer = css`\n    min-width: 80px;\n    height: 80px;\n`;\nconst expressionImage = css`\n    width: 100%;\n    height: 100%;\n    object-fit: cover;\n`;\n\nconst ExpressionAsync = loadable(\n    () =>\n        // @ts-ignore\n        import(/* webpackChunkName: \"expression\" */ './Expression'),\n);\nconst CodeEditorAsync = loadable(\n    // @ts-ignore\n    () => import(/* webpackChunkName: \"code-editor\" */ './CodeEditor'),\n);\n\nlet searchExpressionTimer: number = 0;\n\nlet inputIME = false;\n\nfunction ChatInput() {\n    const action = useAction();\n    const isLogin = useIsLogin();\n    const connect = useSelector((state: State) => state.connect);\n    const selfId = useSelector((state: State) => state.user?._id);\n    const username = useSelector((state: State) => state.user?.username);\n    const avatar = useSelector((state: State) => state.user?.avatar);\n    const tag = useSelector((state: State) => state.user?.tag);\n    const focus = useSelector((state: State) => state.focus);\n    const linkman = useSelector((state: State) => state.linkmans[focus]);\n    const selfVoiceSwitch = useSelector(\n        (state: State) => state.status.selfVoiceSwitch,\n    );\n    const enableSearchExpression = useSelector(\n        (state: State) => state.status.enableSearchExpression,\n    );\n    const [expressionDialog, toggleExpressionDialog] = useState(false);\n    const [codeEditorDialog, toggleCodeEditorDialog] = useState(false);\n    const [inputFocus, toggleInputFocus] = useState(false);\n    const [at, setAt] = useState({ enable: false, content: '' });\n    const $input = useRef<HTMLInputElement>(null);\n    const aero = useAero();\n    const [expressions, setExpressions] = useState<\n        { image: string; width: number; height: number }[]\n    >([]);\n\n    /** 全局输入框聚焦快捷键 */\n    function focusInput(e: KeyboardEvent) {\n        const $target: HTMLElement = e.target as HTMLElement;\n        if (\n            $target.tagName === 'INPUT' ||\n            $target.tagName === 'TEXTAREA' ||\n            e.key !== 'i'\n        ) {\n            return;\n        }\n        e.preventDefault();\n        // @ts-ignore\n        $input.current.focus(e);\n    }\n    useEffect(() => {\n        window.addEventListener('keydown', focusInput);\n        return () => window.removeEventListener('keydown', focusInput);\n    }, []);\n\n    useEffect(() => {\n        setExpressions([]);\n    }, [enableSearchExpression]);\n\n    if (!isLogin) {\n        return (\n            <div className={Style.chatInput}>\n                <p className={Style.guest}>\n                    游客朋友你好, 请\n                    <b\n                        className={Style.guestLogin}\n                        onClick={() =>\n                            action.setStatus('loginRegisterDialogVisible', true)\n                        }\n                        role=\"button\"\n                    >\n                        登录\n                    </b>\n                    后参与聊天\n                </p>\n            </div>\n        );\n    }\n\n    /**\n     * 插入文本到输入框光标处\n     * @param value 要插入的文本\n     */\n    function insertAtCursor(value: string) {\n        const input = $input.current as unknown as HTMLInputElement;\n        if (input.selectionStart || input.selectionStart === 0) {\n            const startPos = input.selectionStart;\n            const endPos = input.selectionEnd;\n            const restoreTop = input.scrollTop;\n            input.value =\n                input.value.substring(0, startPos) +\n                value +\n                input.value.substring(endPos as number, input.value.length);\n            if (restoreTop > 0) {\n                input.scrollTop = restoreTop;\n            }\n            input.focus();\n            input.selectionStart = startPos + value.length;\n            input.selectionEnd = startPos + value.length;\n        } else {\n            input.value += value;\n            input.focus();\n        }\n    }\n\n    function handleSelectExpression(expression: string) {\n        toggleExpressionDialog(false);\n        insertAtCursor(`#(${expression})`);\n    }\n\n    function addSelfMessage(type: string, content: string) {\n        const _id = focus + Date.now();\n        const message = {\n            _id,\n            type,\n            content,\n            createTime: Date.now(),\n            from: {\n                _id: selfId,\n                username,\n                avatar,\n                tag,\n            },\n            loading: true,\n            percent: type === 'image' || type === 'file' ? 0 : 100,\n        };\n        // @ts-ignore\n        action.addLinkmanMessage(focus, message);\n\n        if (selfVoiceSwitch && type === 'text') {\n            const text = content\n                .replace(\n                    /https?:\\/\\/(www\\.)?[-a-zA-Z0-9@:%._+~#=]{2,256}\\.[a-z]{2,6}\\b([-a-zA-Z0-9@:%_+.~#?&//=]*)/g,\n                    '',\n                )\n                .replace(/#/g, '');\n\n            if (text.length > 0 && text.length <= 100) {\n                voice.push(text, Math.random().toString());\n            }\n        }\n\n        return _id;\n    }\n\n    // eslint-disable-next-line react/destructuring-assignment\n    async function handleSendMessage(\n        localId: string,\n        type: string,\n        content: string,\n        linkmanId = focus,\n    ) {\n        if (linkman.unread > 0) {\n            action.setLinkmanProperty(linkman._id, 'unread', 0);\n        }\n        const [error, message] = await sendMessage(linkmanId, type, content);\n        if (error) {\n            action.deleteMessage(focus, localId, true);\n        } else {\n            message.loading = false;\n            action.updateMessage(focus, localId, message);\n        }\n    }\n\n    function sendImageMessage(image: string): void;\n    function sendImageMessage(image: ReadFileResult): void;\n    function sendImageMessage(image: string | ReadFileResult) {\n        if (typeof image === 'string') {\n            const id = addSelfMessage('image', image);\n            handleSendMessage(id, 'image', image);\n            toggleExpressionDialog(false);\n            return;\n        }\n\n        if (image.length > config.maxImageSize) {\n            Message.warning('要发送的图片过大', 3);\n            return;\n        }\n\n        // @ts-ignore\n        const ext = image.type.split('/').pop().toLowerCase();\n        const url = URL.createObjectURL(image.result);\n\n        const img = new Image();\n        img.onload = async () => {\n            const id = addSelfMessage(\n                'image',\n                `${url}?width=${img.width}&height=${img.height}`,\n            );\n            try {\n                const imageUrl = await uploadFile(\n                    image.result as Blob,\n                    `ImageMessage/${selfId}_${Date.now()}.${ext}`,\n                );\n                handleSendMessage(\n                    id,\n                    'image',\n                    `${imageUrl}?width=${img.width}&height=${img.height}`,\n                    focus,\n                );\n            } catch (err) {\n                console.error(err);\n                Message.error('上传图片失败');\n            }\n        };\n        img.src = url;\n    }\n\n    async function sendFileMessage(file: ReadFileResult) {\n        if (file.length > config.maxFileSize) {\n            Message.warning('要发送的文件过大', 3);\n            return;\n        }\n\n        const id = addSelfMessage(\n            'file',\n            JSON.stringify({\n                filename: file.filename,\n                size: file.length,\n                ext: file.ext,\n            }),\n        );\n        try {\n            const fileUrl = await uploadFile(\n                file.result as Blob,\n                `FileMessage/${selfId}_${Date.now()}.${file.ext}`,\n            );\n            handleSendMessage(\n                id,\n                'file',\n                JSON.stringify({\n                    fileUrl,\n                    filename: file.filename,\n                    size: file.length,\n                    ext: file.ext,\n                }),\n                focus,\n            );\n        } catch (err) {\n            console.error(err);\n            Message.error('上传文件失败');\n        }\n    }\n\n    async function handleSendImage() {\n        if (!connect) {\n            return Message.error('发送消息失败, 您当前处于离线状态');\n        }\n        const image = await readDiskFile(\n            'blob',\n            'image/png,image/jpeg,image/gif',\n        );\n        if (!image) {\n            return null;\n        }\n        sendImageMessage(image);\n        return null;\n    }\n    async function sendHuaji() {\n        const huaji = getRandomHuaji();\n        const id = addSelfMessage('image', huaji);\n        handleSendMessage(id, 'image', huaji);\n    }\n    async function handleSendFile() {\n        if (!connect) {\n            Message.error('发送消息失败, 您当前处于离线状态');\n            return;\n        }\n        const file = await readDiskFile('blob');\n        if (!file) {\n            return;\n        }\n        sendFileMessage(file);\n    }\n\n    function handleFeatureMenuClick({\n        key,\n        domEvent,\n    }: {\n        key: string;\n        domEvent: any;\n    }) {\n        // Quickly hitting the Enter key causes the button to repeatedly trigger the problem\n        if (domEvent.keyCode === 13) {\n            return;\n        }\n\n        switch (key) {\n            case 'image': {\n                handleSendImage();\n                break;\n            }\n            case 'huaji': {\n                sendHuaji();\n                break;\n            }\n            case 'code': {\n                toggleCodeEditorDialog(true);\n                break;\n            }\n            case 'file': {\n                handleSendFile();\n                break;\n            }\n            default:\n        }\n    }\n\n    async function handlePaste(e: any) {\n        // eslint-disable-next-line react/destructuring-assignment\n        if (!connect) {\n            e.preventDefault();\n            return Message.error('发送消息失败, 您当前处于离线状态');\n        }\n        const { items, types } =\n            e.clipboardData || e.originalEvent.clipboardData;\n\n        // 如果包含文件内容\n        if (types.indexOf('Files') > -1) {\n            for (let index = 0; index < items.length; index++) {\n                const item = items[index];\n                if (item.kind === 'file') {\n                    const file = item.getAsFile();\n                    if (file) {\n                        const reader = new FileReader();\n                        reader.onloadend = function handleLoad() {\n                            const image = new Image();\n                            image.onload = async () => {\n                                const imageBlob = await compressImage(\n                                    image,\n                                    file.type,\n                                    0.8,\n                                );\n                                // @ts-ignore\n                                sendImageMessage({\n                                    filename: file.name,\n                                    ext: imageBlob?.type.split('/').pop(),\n                                    length: imageBlob?.size,\n                                    type: imageBlob?.type,\n                                    result: imageBlob,\n                                });\n                            };\n                            // eslint-disable-next-line react/no-this-in-sfc\n                            image.src = this.result as string;\n                        };\n                        reader.readAsDataURL(file);\n                    }\n                }\n            }\n            e.preventDefault();\n        }\n        return null;\n    }\n\n    function sendTextMessage() {\n        if (!connect) {\n            return Message.error('发送消息失败, 您当前处于离线状态');\n        }\n\n        // @ts-ignore\n        const message = $input.current.value.trim();\n        if (message.length === 0) {\n            return null;\n        }\n\n        if (\n            message.startsWith(window.location.origin) &&\n            message.match(/\\/invite\\/group\\/[\\w\\d]+/)\n        ) {\n            const groupId = message.replace(\n                `${window.location.origin}/invite/group/`,\n                '',\n            );\n            const id = addSelfMessage(\n                'inviteV2',\n                JSON.stringify({\n                    inviter: selfId,\n                    inviterName: username,\n                    group: groupId,\n                    groupName: '',\n                }),\n            );\n            handleSendMessage(id, 'inviteV2', groupId);\n        } else {\n            const id = addSelfMessage('text', xss(message));\n            handleSendMessage(id, 'text', message);\n        }\n\n        // @ts-ignore\n        $input.current.value = '';\n        setExpressions([]);\n        return null;\n    }\n\n    async function getExpressionsFromContent() {\n        if ($input.current) {\n            const content = $input.current.value.trim();\n            if (searchExpressionTimer) {\n                clearTimeout(searchExpressionTimer);\n            }\n            // @ts-ignore\n            searchExpressionTimer = setTimeout(async () => {\n                if (content.length >= 1 && content.length <= 4) {\n                    const [err, res] = await fetch(\n                        'searchExpression',\n                        { keywords: content, limit: 10 },\n                        { toast: false },\n                    );\n                    if (!err && $input.current?.value.trim() === content) {\n                        setExpressions(res);\n                        return;\n                    }\n                }\n                setExpressions([]);\n            }, 500);\n        }\n    }\n\n    async function handleInputKeyDown(e: any) {\n        if (e.key === 'Tab') {\n            e.preventDefault();\n        } else if (e.key === 'Enter' && !inputIME) {\n            sendTextMessage();\n        } else if (e.altKey && (e.key === 's' || e.key === 'ß')) {\n            sendHuaji();\n            e.preventDefault();\n        } else if (e.altKey && (e.key === 'd' || e.key === '∂')) {\n            toggleExpressionDialog(true);\n            e.preventDefault();\n        } else if (e.key === '@') {\n            // 如果按下@建, 则进入@计算模式\n            // @ts-ignore\n            if (!/@/.test($input.current.value)) {\n                setAt({\n                    enable: true,\n                    content: '',\n                });\n            }\n            // eslint-disable-next-line react/destructuring-assignment\n        } else if (at.enable) {\n            // 如果处于@计算模式\n            const { key } = e;\n            // 延时, 以便拿到新的value和ime状态\n            setTimeout(() => {\n                // 如果@已经被删掉了, 退出@计算模式\n                // @ts-ignore\n                if (!/@/.test($input.current.value)) {\n                    setAt({ enable: false, content: '' });\n                    return;\n                }\n                // 如果是输入中文, 并且不是空格键, 忽略输入\n                if (inputIME && key !== ' ') {\n                    return;\n                }\n                // 如果是不是输入中文, 并且是空格键, 则@计算模式结束\n                if (!inputIME && key === ' ') {\n                    setAt({ enable: false, content: '' });\n                    return;\n                }\n\n                // 如果是正在输入中文, 则直接返回, 避免取到拼音字母\n                if (inputIME) {\n                    return;\n                }\n                // @ts-ignore\n                const regexResult = /@([^ ]*)/.exec($input.current.value);\n                if (regexResult) {\n                    setAt({ enable: true, content: regexResult[1] });\n                }\n            }, 100);\n        } else if (enableSearchExpression) {\n            // Set timer to get current input value\n            setTimeout(() => {\n                if (inputIME) {\n                    return;\n                }\n                if ($input.current?.value) {\n                    getExpressionsFromContent();\n                } else {\n                    clearTimeout(searchExpressionTimer);\n                    setExpressions([]);\n                }\n            });\n        }\n    }\n\n    function getSuggestion() {\n        if (!at.enable || linkman.type !== 'group') {\n            return [];\n        }\n        return linkman.onlineMembers.filter((member) => {\n            const regex = new RegExp(`^${at.content}`);\n            if (regex.test(member.user.username)) {\n                return true;\n            }\n            return false;\n        });\n    }\n\n    function replaceAt(targetUsername: string) {\n        // @ts-ignore\n        $input.current.value = $input.current.value.replace(\n            `@${at.content}`,\n            `@${targetUsername} `,\n        );\n        setAt({\n            enable: false,\n            content: '',\n        });\n        // @ts-ignore\n        $input.current.focus();\n    }\n\n    function handleSendCode(language: string, rawCode: string) {\n        if (!connect) {\n            return Message.error('发送消息失败, 您当前处于离线状态');\n        }\n\n        if (rawCode === '') {\n            return Message.warning('请输入内容');\n        }\n\n        const code = `@language=${language}@${rawCode}`;\n        const id = addSelfMessage('code', code);\n        handleSendMessage(id, 'code', code);\n        toggleCodeEditorDialog(false);\n        return null;\n    }\n\n    function handleClickExpressionImage(\n        image: string,\n        width: number,\n        height: number,\n    ) {\n        sendImageMessage(`${image}?width=${width}&height=${height}`);\n        setExpressions([]);\n        if ($input.current) {\n            $input.current.value = '';\n        }\n    }\n\n    return (\n        <div className={Style.chatInput} {...aero}>\n            <Dropdown\n                trigger={['click']}\n                visible={expressionDialog}\n                onVisibleChange={toggleExpressionDialog}\n                overlay={\n                    <div className={Style.expressionDropdown}>\n                        <ExpressionAsync\n                            onSelectText={handleSelectExpression}\n                            onSelectImage={sendImageMessage}\n                        />\n                    </div>\n                }\n                animation=\"slide-up\"\n                placement=\"topLeft\"\n            >\n                <IconButton\n                    className={Style.iconButton}\n                    width={44}\n                    height={44}\n                    icon=\"expression\"\n                    iconSize={32}\n                />\n            </Dropdown>\n            <Dropdown\n                trigger={['click']}\n                overlay={\n                    <div className={Style.featureDropdown}>\n                        <Menu onClick={handleFeatureMenuClick}>\n                            <MenuItem key=\"huaji\">发送滑稽</MenuItem>\n                            <MenuItem key=\"image\">发送图片</MenuItem>\n                            <MenuItem key=\"code\">发送代码</MenuItem>\n                            <MenuItem key=\"file\">发送文件</MenuItem>\n                        </Menu>\n                    </div>\n                }\n                animation=\"slide-up\"\n                placement=\"topLeft\"\n            >\n                <IconButton\n                    className={Style.iconButton}\n                    width={44}\n                    height={44}\n                    icon=\"feature\"\n                    iconSize={32}\n                />\n            </Dropdown>\n            <form\n                className={Style.form}\n                autoComplete=\"off\"\n                onSubmit={(e) => e.preventDefault()}\n            >\n                <input\n                    className={Style.input}\n                    type=\"text\"\n                    placeholder=\"随便聊点啥吧, 不要无意义刷屏~~\"\n                    maxLength={2048}\n                    ref={$input}\n                    onKeyDown={handleInputKeyDown}\n                    onPaste={handlePaste}\n                    onCompositionStart={() => {\n                        inputIME = true;\n                    }}\n                    onCompositionEnd={() => {\n                        inputIME = false;\n                    }}\n                    onFocus={() => toggleInputFocus(true)}\n                    onBlur={() => toggleInputFocus(false)}\n                />\n\n                {!isMobile && !inputFocus && (\n                    <Tooltip\n                        placement=\"top\"\n                        mouseEnterDelay={0.5}\n                        overlay={\n                            <span>\n                                支持粘贴图片发图\n                                <br />\n                                全局按 i 键聚焦\n                            </span>\n                        }\n                    >\n                        <i className={`iconfont icon-about ${Style.tooltip}`} />\n                    </Tooltip>\n                )}\n            </form>\n            <IconButton\n                className={Style.iconButton}\n                width={44}\n                height={44}\n                icon=\"send\"\n                iconSize={32}\n                onClick={sendTextMessage}\n            />\n\n            <div className={Style.atPanel}>\n                {at.enable &&\n                    getSuggestion().map((member) => (\n                        <div\n                            className={Style.atUserList}\n                            key={member.user._id}\n                            onClick={() => replaceAt(member.user.username)}\n                            role=\"button\"\n                        >\n                            <Avatar size={24} src={member.user.avatar} />\n                            <p className={Style.atText}>\n                                {member.user.username}\n                            </p>\n                        </div>\n                    ))}\n            </div>\n\n            {codeEditorDialog && (\n                <CodeEditorAsync\n                    visible={codeEditorDialog}\n                    onClose={() => toggleCodeEditorDialog(false)}\n                    onSend={handleSendCode}\n                />\n            )}\n\n            {expressions.length > 0 && (\n                <div className={expressionList}>\n                    {expressions.map(({ image, width, height }) => (\n                        <div className={expressionImageContainer}>\n                            <img\n                                className={expressionImage}\n                                src={image}\n                                key={image}\n                                alt=\"表情图\"\n                                onClick={() =>\n                                    handleClickExpressionImage(\n                                        image,\n                                        width,\n                                        height,\n                                    )\n                                }\n                            />\n                        </div>\n                    ))}\n                </div>\n            )}\n        </div>\n    );\n}\n\nexport default ChatInput;\n"
  },
  {
    "path": "packages/web/src/modules/Chat/CodeEditor.less",
    "content": "@import '../../styles/variable.less';\n\n.codeEditor {\n    width: 600px;\n\n    @media @mobile {\n        width: 100%;\n        height: 410px;\n    }\n\n    :global {\n        .rc-dialog-body {\n            overflow-y: hidden;\n        }\n    }\n}\n\n.container {\n    height: 55vh;\n    display: flex;\n    flex-direction: column;\n}\n\n.selectContainer {\n    display: flex;\n    align-items: center;\n}\n\n.languageSelect {\n    width: 150px;\n    margin-left: 10px;\n}\n\n.title {\n    font-size: 14px;\n    font-weight: normal;\n    color: #666;\n}\n\n.editorContainer {\n    flex: 1;\n    margin-top: 10px;\n    border: 1px solid #f1f1f1;\n    border-radius: 2px;\n}\n\n.sendButton {\n    width: 100px;\n    height: 32px;\n    margin-top: 8px;\n    align-self: flex-end;\n}"
  },
  {
    "path": "packages/web/src/modules/Chat/CodeEditor.tsx",
    "content": "import React, { useState, useEffect } from 'react';\nimport AceEditor from 'react-ace';\nimport 'brace/theme/tomorrow';\nimport 'brace/ext/language_tools';\n\nimport Style from './CodeEditor.less';\nimport Dialog from '../../components/Dialog';\nimport { Select, Option } from '../../components/Select';\nimport Button from '../../components/Button';\n\nconst languages = [\n    'javascript',\n    'typescript',\n    'text',\n    'java',\n    'c_cpp',\n    'python',\n    'ruby',\n    'php',\n    'golang',\n    'csharp',\n    'html',\n    'css',\n    'sql',\n    'json',\n];\n\ninterface LoadedLanguage {\n    [key: string]: boolean;\n}\nconst loadedLanguage: LoadedLanguage = {};\n\ninterface CodeEditorProps {\n    visible: boolean;\n    onClose: () => void;\n    onSend: (language: string, code: string) => void;\n}\n\nfunction CodeEditor(props: CodeEditorProps) {\n    const { visible, onClose, onSend } = props;\n\n    const [language, setLanguage] = useState('javascript');\n    const [value, setValue] = useState('');\n    const [timestamp, setTimestamp] = useState(0);\n\n    useEffect(() => {\n        if (visible) {\n            setValue('');\n        }\n    }, [visible]);\n\n    useEffect(() => {\n        (async () => {\n            // 动态加载语言包\n            if (visible && !loadedLanguage[language]) {\n                /**\n                 * 为了减小打包产物体积, 这里是逐个引入所需的语音包\n                 * 如果直接用变量路径, 会将该目录下的文件全部打包\n                 * 另外不单个引入也不好设置 webpackChunkName\n                 */\n                switch (language) {\n                    case 'javascript': {\n                        // @ts-ignore\n                        await import(\n                            /* webpackChunkName: \"react-ace-mode-javascript\" */ 'brace/mode/javascript'\n                        );\n                        break;\n                    }\n                    case 'typescript': {\n                        // @ts-ignore\n                        await import(\n                            /* webpackChunkName: \"react-ace-mode-typescript\" */ 'brace/mode/typescript'\n                        );\n                        break;\n                    }\n                    case 'java': {\n                        // @ts-ignore\n                        await import(\n                            /* webpackChunkName: \"react-ace-mode-java\" */ 'brace/mode/java'\n                        );\n                        break;\n                    }\n                    case 'c_cpp': {\n                        // @ts-ignore\n                        await import(\n                            /* webpackChunkName: \"react-ace-mode-c_cpp\" */ 'brace/mode/c_cpp'\n                        );\n                        break;\n                    }\n                    case 'python': {\n                        // @ts-ignore\n                        await import(\n                            /* webpackChunkName: \"react-ace-mode-python\" */ 'brace/mode/python'\n                        );\n                        break;\n                    }\n                    case 'ruby': {\n                        // @ts-ignore\n                        await import(\n                            /* webpackChunkName: \"react-ace-mode-ruby\" */ 'brace/mode/ruby'\n                        );\n                        break;\n                    }\n                    case 'php': {\n                        // @ts-ignore\n                        await import(\n                            /* webpackChunkName: \"react-ace-mode-php\" */ 'brace/mode/php'\n                        );\n                        break;\n                    }\n                    case 'golang': {\n                        // @ts-ignore\n                        await import(\n                            /* webpackChunkName: \"react-ace-mode-golang\" */ 'brace/mode/golang'\n                        );\n                        break;\n                    }\n                    case 'csharp': {\n                        // @ts-ignore\n                        await import(\n                            /* webpackChunkName: \"react-ace-mode-csharp\" */ 'brace/mode/csharp'\n                        );\n                        break;\n                    }\n                    case 'html': {\n                        // @ts-ignore\n                        await import(\n                            /* webpackChunkName: \"react-ace-mode-html\" */ 'brace/mode/html'\n                        );\n                        break;\n                    }\n                    case 'css': {\n                        // @ts-ignore\n                        await import(\n                            /* webpackChunkName: \"react-ace-mode-css\" */ 'brace/mode/css'\n                        );\n                        break;\n                    }\n                    case 'text': {\n                        // @ts-ignore\n                        await import(\n                            /* webpackChunkName: \"react-ace-mode-text\" */ 'brace/mode/markdown'\n                        );\n                        break;\n                    }\n                    case 'sql': {\n                        // @ts-ignore\n                        await import(\n                            /* webpackChunkName: \"react-ace-mode-sql\" */ 'brace/mode/sql'\n                        );\n                        break;\n                    }\n                    case 'json': {\n                        // @ts-ignore\n                        await import(\n                            /* webpackChunkName: \"react-ace-mode-json\" */ 'brace/mode/json'\n                        );\n                        break;\n                    }\n                    default: {\n                        console.warn('不支持的语言包', language);\n                    }\n                }\n                loadedLanguage[language] = true;\n                setTimestamp(Date.now());\n            }\n        })();\n    }, [visible, language]);\n\n    function renderEditor() {\n        if (!loadedLanguage[language]) {\n            return null;\n        }\n\n        const editorProps = {\n            theme: 'tomorrow',\n            value,\n            onChange: (newValue: string) => setValue(newValue),\n            fontSize: 12,\n            width: '100%',\n            height: '100%',\n            showPrintMargin: true,\n            showGutter: true,\n            highlightActiveLine: true,\n            setOptions: {\n                enableBasicAutocompletion: true,\n                enableLiveAutocompletion: true,\n                enableSnippets: false,\n                showLineNumbers: true,\n                tabSize: 4,\n            },\n        };\n\n        return (\n            <AceEditor\n                mode={language === 'text' ? 'markdown' : language}\n                {...editorProps}\n            />\n        );\n    }\n\n    return (\n        <>\n            <Dialog\n                className={Style.codeEditor}\n                title=\"请输入要发送的代码\"\n                visible={visible}\n                onClose={onClose}\n            >\n                <div className={Style.container}>\n                    <div className={Style.selectContainer}>\n                        <h3 className={Style.title}>编程语言: </h3>\n                        <Select\n                            className={Style.languageSelect}\n                            defaultValue={languages[0]}\n                            // @ts-ignore\n                            onSelect={(lang: string) => setLanguage(lang)}\n                        >\n                            {languages.map((lang) => (\n                                <Option value={lang} key={lang}>\n                                    {lang}\n                                </Option>\n                            ))}\n                        </Select>\n                    </div>\n                    <div className={Style.editorContainer}>\n                        {renderEditor()}\n                    </div>\n                    <Button\n                        className={Style.sendButton}\n                        onClick={() => onSend(language, value)}\n                    >\n                        发送\n                    </Button>\n                </div>\n            </Dialog>\n            <span className=\"hide\">{timestamp}</span>\n        </>\n    );\n}\n\nexport default CodeEditor;\n"
  },
  {
    "path": "packages/web/src/modules/Chat/Expression.less",
    "content": "@import '../../styles/variable.less';\n\n.expression {\n    height: 100%;\n    \n    :global {\n        .rc-tabs {\n            height: 100%;\n            display: flex;\n            flex-direction: column;\n        }\n        .rc-tabs-content {\n            flex: 1;\n        }\n    }\n}\n\n.defaultExpression {\n    width: 100%;\n    height: 100%;\n    display: flex;\n    flex-direction: row;\n    flex-wrap: wrap;\n    padding: 0 7px;\n}\n\n.defaultExpressionBlock {\n    width: 40px;\n    height: 40px;;\n    padding: 5px;\n    transition: all 0.3s;\n\n    &:hover {\n        background-color: #e9e9e9;\n        cursor: pointer;\n    }\n\n    @media @mobile {\n        width: 10%;\n        height: auto;\n    }\n}\n\n.defaultExpressionItem {\n    width: 30px;\n    height: 30px;\n    background-repeat: no-repeat;\n    background-size: 30px auto;\n    background-image: url('@fiora/assets/images/baidu.png');\n}\n\n.searchExpression {\n    width: 100%;\n    height: 100%;\n    display: flex;\n    flex-direction: column;\n    padding: 0 10px;\n    position: relative;\n}\n\n.searchExpressionInputBlock {\n    height: 40px;\n    display: flex;\n    align-items: center;\n}\n\n.searchExpressionInput {\n    height: 34px;\n    flex: 1;\n}\n\n.searchExpressionButton {\n    height: 34px;\n    width: 100px;\n    margin-left: 12px;\n}\n\n.loading {\n    position: absolute;\n    top: 50%;\n    left: 50%;\n    transform: translate(-50%, -50%);\n}\n\n.searchResult {\n    height: 155px;\n    overflow-y: auto;\n    -webkit-overflow-scrolling: touch;\n    padding-left: 2px;\n}\n\n.searchImage {\n    width: 90px;\n    height: 90px;\n    margin: 0 4px;\n    display: inline-block;\n    position: relative;\n\n    & > img {\n        max-width: 90px;\n        max-height: 90px;\n        position: absolute;\n        left: 50%;\n        top: 50%;\n        transform: translate(-50%, -50%);\n    }\n}"
  },
  {
    "path": "packages/web/src/modules/Chat/Expression.tsx",
    "content": "import React, { useState } from 'react';\nimport Loading from 'react-loading';\n\nimport expressions from '@fiora/utils/expressions';\nimport { addParam } from '@fiora/utils/url';\nimport BaiduImage from '@fiora/assets/images/baidu.png';\nimport Style from './Expression.less';\nimport {\n    Tabs,\n    TabPane,\n    TabContent,\n    ScrollableInkTabBar,\n} from '../../components/Tabs';\nimport Input from '../../components/Input';\nimport Button from '../../components/Button';\nimport { searchExpression } from '../../service';\nimport Message from '../../components/Message';\n\ninterface ExpressionProps {\n    onSelectText: (expression: string) => void;\n    onSelectImage: (expression: string) => void;\n}\n\nfunction Expression(props: ExpressionProps) {\n    const { onSelectText, onSelectImage } = props;\n\n    const [keywords, setKeywords] = useState('');\n    const [searchLoading, toggleSearchLoading] = useState(false);\n    const [searchResults, setSearchResults] = useState([]);\n\n    async function handleSearchExpression() {\n        if (keywords) {\n            toggleSearchLoading(true);\n            setSearchResults([]);\n            const result = await searchExpression(keywords);\n            if (result) {\n                if (result.length !== 0) {\n                    setSearchResults(result);\n                } else {\n                    Message.info('没有相关表情, 换个关键字试试吧');\n                }\n            }\n            toggleSearchLoading(false);\n        }\n    }\n\n    const renderDefaultExpression = (\n        <div className={Style.defaultExpression}>\n            {expressions.default.map((e, index) => (\n                <div\n                    className={Style.defaultExpressionBlock}\n                    key={e}\n                    data-name={e}\n                    onClick={(event) =>\n                        onSelectText(event.currentTarget.dataset.name as string)\n                    }\n                    role=\"button\"\n                >\n                    <div\n                        className={Style.defaultExpressionItem}\n                        style={{\n                            backgroundPosition: `left ${-30 * index}px`,\n                            backgroundImage: `url(${BaiduImage})`,\n                        }}\n                    />\n                </div>\n            ))}\n        </div>\n    );\n\n    function handleClickExpression(e: any) {\n        const $target = e.target;\n        const url = addParam($target.src, {\n            width: $target.naturalWidth,\n            height: $target.naturalHeight,\n        });\n        onSelectImage(url);\n    }\n\n    const renderSearchExpression = (\n        <div className={Style.searchExpression}>\n            <div className={Style.searchExpressionInputBlock}>\n                <Input\n                    className={Style.searchExpressionInput}\n                    value={keywords}\n                    onChange={setKeywords}\n                    onEnter={handleSearchExpression}\n                />\n                <Button\n                    className={Style.searchExpressionButton}\n                    onClick={handleSearchExpression}\n                >\n                    搜索\n                </Button>\n            </div>\n            <div\n                className={`${Style.loading} ${\n                    searchLoading ? 'show' : 'hide'\n                }`}\n            >\n                <Loading\n                    type=\"spinningBubbles\"\n                    color=\"#4A90E2\"\n                    height={100}\n                    width={100}\n                />\n            </div>\n            <div className={Style.searchResult}>\n                {searchResults.map(({ image }) => (\n                    <div className={Style.searchImage}>\n                        <img\n                            src={image}\n                            alt=\"表情\"\n                            key={image}\n                            onClick={handleClickExpression}\n                        />\n                    </div>\n                ))}\n            </div>\n        </div>\n    );\n\n    return (\n        <div className={Style.expression}>\n            <Tabs\n                defaultActiveKey=\"default\"\n                renderTabBar={() => <ScrollableInkTabBar />}\n                renderTabContent={() => <TabContent />}\n            >\n                <TabPane tab=\"默认表情\" key=\"default\">\n                    {renderDefaultExpression}\n                </TabPane>\n                <TabPane tab=\"搜索表情包\" key=\"search\">\n                    {renderSearchExpression}\n                </TabPane>\n            </Tabs>\n        </div>\n    );\n}\n\nexport default Expression;\n"
  },
  {
    "path": "packages/web/src/modules/Chat/GroupManagePanel.less",
    "content": "@import '../../styles/variable.less';\n\n.groupManagePanel {\n    height: 100%;\n    width: 300px;\n    position: absolute;\n    right: 0;\n    transition: transform 0.5s;\n\n    @media @mobile {\n        width: 100%;\n        background-color: rgba(37, 37, 37, 0.5);\n    }\n}\n\n.show {\n    transform: translateX(0);\n}\n.hide {\n    transform: translateX(100%);\n}\n\n.container {\n    width: 300px;\n    height: 100%;\n    display: flex;\n    flex-direction: column;\n    background-color: rgba(250, 250, 250, 0.95);\n    border-top-right-radius: 10px;\n    border-bottom-right-radius: 10px;\n    position: absolute;\n    right: 0;\n\n    @media @mobile {\n        border-top-right-radius: 0;\n        border-bottom-right-radius: 0;\n    }\n}\n\n.title {\n    height: 70px;\n    border-bottom: 1px solid #e8e8e8;\n    box-sizing: border-box;\n    text-align: center;\n    line-height: 70px;\n    font-size: 14px;\n    color: #666;\n    font-weight: bold;\n\n    @media @mobile {\n        height: 50px;\n    }\n}\n\n.content {\n    flex: 1;\n    padding: 12px;\n    overflow-y: auto;\n    -webkit-overflow-scrolling: touch;\n}\n\n.block {\n    margin-bottom: 10px;\n}\n\n.blockTitle {\n    line-height: 33px;\n    font-size: 14px;\n    color: #333;\n    font-weight: bold;\n}\n\n.name {\n    & > .component-input {\n        height: 36px;\n    }\n    & > .component-button {\n        height: 36px;\n        line-height: 36px;\n        margin-top: 8px;\n    }\n}\n.input {\n    height: 36px;\n}\n\n.button {\n    height: 36px;\n    line-height: 36px;\n    margin-top: 8px;\n}\n\n.avatar {\n    width: 100px;\n    height: 100px;\n    cursor: pointer;\n    &:hover {\n        filter: blur(3px);\n    }\n}\n\n.onlineMember{\n    display: flex;\n    justify-content: space-between;\n    align-items: center;\n    margin-bottom: 6px;\n    cursor: default;\n}\n\n.userinfoBlock {\n    display: flex;\n    align-items: center;\n    cursor: pointer;\n}\n\n.username {\n    margin-left: 10px;\n    color: #333;\n    font-size: 14px;\n    word-break: keep-all;\n    max-width: 120px;\n}\n\n.clientInfoText {\n    color: #666;\n    font-size: 12px;\n    overflow: hidden;\n    white-space: nowrap;\n    text-overflow: ellipsis;\n    margin-left: 12px;\n    text-align: right;\n}\n\n.deleteGroupConfirmDialog {\n    width: 250px;\n    :global {\n        .rc-dialog-body {\n            display: flex;\n            justify-content: flex-end;\n        }\n    }\n}\n\n.deleteGroupConfirmButton {\n    width: 60px;\n    height: 36px;\n    margin-left: 12px;\n}"
  },
  {
    "path": "packages/web/src/modules/Chat/GroupManagePanel.tsx",
    "content": "import React, { useState, useContext } from 'react';\nimport { useSelector } from 'react-redux';\n\nimport readDiskFIle from '../../utils/readDiskFile';\nimport uploadFile, { getOSSFileUrl } from '../../utils/uploadFile';\nimport Style from './GroupManagePanel.less';\nimport useIsLogin from '../../hooks/useIsLogin';\nimport { State, GroupMember } from '../../state/reducer';\nimport Input from '../../components/Input';\nimport Button from '../../components/Button';\nimport Message from '../../components/Message';\nimport Avatar from '../../components/Avatar';\nimport Tooltip from '../../components/Tooltip';\nimport Dialog from '../../components/Dialog';\nimport {\n    changeGroupName,\n    changeGroupAvatar,\n    deleteGroup,\n    leaveGroup,\n} from '../../service';\nimport useAction from '../../hooks/useAction';\nimport config from '../../../../config/client';\nimport { ShowUserOrGroupInfoContext } from '../../context';\n\ninterface GroupManagePanelProps {\n    visible: boolean;\n    onClose: () => void;\n    groupId: string;\n    avatar: string;\n    creator: string;\n    onlineMembers: GroupMember[];\n}\n\nfunction GroupManagePanel(props: GroupManagePanelProps) {\n    const { visible, onClose, groupId, avatar, creator, onlineMembers } = props;\n\n    const action = useAction();\n    const isLogin = useIsLogin();\n    const selfId = useSelector((state: State) => state.user?._id);\n    const [deleteConfirmDialog, setDialogStatus] = useState(false);\n    const [groupName, setGroupName] = useState('');\n    const context = useContext(ShowUserOrGroupInfoContext);\n\n    async function handleChangeGroupName() {\n        const isSuccess = await changeGroupName(groupId, groupName);\n        if (isSuccess) {\n            Message.success('修改群名称成功');\n            action.setLinkmanProperty(groupId, 'name', groupName);\n        }\n    }\n\n    async function handleChangeGroupAvatar() {\n        const image = await readDiskFIle(\n            'blob',\n            'image/png,image/jpeg,image/gif',\n        );\n        if (!image) {\n            return;\n        }\n        if (image.length > config.maxAvatarSize) {\n            // eslint-disable-next-line consistent-return\n            return Message.error('设置群头像失败, 请选择小于1.5MB的图片');\n        }\n\n        try {\n            const imageUrl = await uploadFile(\n                image.result as Blob,\n                `GroupAvatar/${selfId}_${Date.now()}.${image.ext}`,\n            );\n            const isSuccess = await changeGroupAvatar(groupId, imageUrl);\n            if (isSuccess) {\n                action.setLinkmanProperty(\n                    groupId,\n                    'avatar',\n                    URL.createObjectURL(image.result),\n                );\n                Message.success('修改群头像成功');\n            }\n        } catch (err) {\n            console.error(err);\n            Message.error('上传群头像失败');\n        }\n    }\n\n    async function handleDeleteGroup() {\n        const isSuccess = await deleteGroup(groupId);\n        if (isSuccess) {\n            setDialogStatus(false);\n            onClose();\n            action.removeLinkman(groupId);\n            Message.success('解散群组成功');\n        }\n    }\n\n    async function handleLeaveGroup() {\n        const isSuccess = await leaveGroup(groupId);\n        if (isSuccess) {\n            onClose();\n            action.removeLinkman(groupId);\n            Message.success('退出群组成功');\n        }\n    }\n\n    function handleClickMask(e: React.MouseEvent) {\n        if (e.target === e.currentTarget) {\n            onClose();\n        }\n    }\n\n    function handleShowUserInfo(userInfo: any) {\n        if (userInfo._id === selfId) {\n            return;\n        }\n        // @ts-ignore\n        context.showUserInfo(userInfo);\n        onClose();\n    }\n\n    return (\n        <div\n            className={`${Style.groupManagePanel} ${visible ? 'show' : 'hide'}`}\n            onClick={handleClickMask}\n            role=\"button\"\n            data-float-panel=\"true\"\n        >\n            <div\n                className={`${Style.container} ${\n                    visible ? Style.show : Style.hide\n                }`}\n            >\n                <p className={Style.title}>群组信息</p>\n                <div className={Style.content}>\n                    {isLogin && selfId === creator ? (\n                        <div className={Style.block}>\n                            <p className={Style.blockTitle}>修改群名称</p>\n                            <Input\n                                className={Style.input}\n                                value={groupName}\n                                onChange={setGroupName}\n                            />\n                            <Button\n                                className={Style.button}\n                                onClick={handleChangeGroupName}\n                            >\n                                确认修改\n                            </Button>\n                        </div>\n                    ) : null}\n                    {isLogin && selfId === creator ? (\n                        <div className={Style.block}>\n                            <p className={Style.blockTitle}>修改群头像</p>\n                            <img\n                                className={Style.avatar}\n                                src={getOSSFileUrl(avatar)}\n                                alt=\"群头像预览\"\n                                onClick={handleChangeGroupAvatar}\n                            />\n                        </div>\n                    ) : null}\n\n                    <div className={Style.block}>\n                        <p className={Style.blockTitle}>功能</p>\n                        {selfId === creator ? (\n                            <Button\n                                className={Style.button}\n                                type=\"danger\"\n                                onClick={() => setDialogStatus(true)}\n                            >\n                                解散群组\n                            </Button>\n                        ) : (\n                            <Button\n                                className={Style.button}\n                                type=\"danger\"\n                                onClick={handleLeaveGroup}\n                            >\n                                退出群组\n                            </Button>\n                        )}\n                    </div>\n                    <div className={Style.block}>\n                        <p className={Style.blockTitle}>\n                            在线成员 &nbsp;<span>{onlineMembers.length}</span>\n                        </p>\n                        <div>\n                            {onlineMembers.map((member) => (\n                                <div\n                                    key={member.user._id}\n                                    className={Style.onlineMember}\n                                >\n                                    <div\n                                        className={Style.userinfoBlock}\n                                        onClick={() =>\n                                            handleShowUserInfo(member.user)\n                                        }\n                                        role=\"button\"\n                                    >\n                                        <Avatar\n                                            size={24}\n                                            src={member.user.avatar}\n                                        />\n                                        <p className={Style.username}>\n                                            {member.user.username}\n                                        </p>\n                                    </div>\n                                    <Tooltip\n                                        placement=\"top\"\n                                        trigger={['hover']}\n                                        overlay={\n                                            <span>{member.environment}</span>\n                                        }\n                                    >\n                                        <p className={Style.clientInfoText}>\n                                            {member.browser}\n                                            &nbsp;&nbsp;\n                                            {member.os ===\n                                            'Windows Server 2008 R2 / 7'\n                                                ? 'Windows 7'\n                                                : member.os}\n                                        </p>\n                                    </Tooltip>\n                                </div>\n                            ))}\n                        </div>\n                    </div>\n                    <Dialog\n                        className={Style.deleteGroupConfirmDialog}\n                        title=\"再次确认是否解散群组?\"\n                        visible={deleteConfirmDialog}\n                        onClose={() => setDialogStatus(false)}\n                    >\n                        <Button\n                            className={Style.deleteGroupConfirmButton}\n                            type=\"danger\"\n                            onClick={handleDeleteGroup}\n                        >\n                            确认\n                        </Button>\n                        <Button\n                            className={Style.deleteGroupConfirmButton}\n                            onClick={() => setDialogStatus(false)}\n                        >\n                            取消\n                        </Button>\n                    </Dialog>\n                </div>\n            </div>\n        </div>\n    );\n}\n\nexport default GroupManagePanel;\n"
  },
  {
    "path": "packages/web/src/modules/Chat/HeaderBar.less",
    "content": "@import '../../styles/variable.less';\n\n.headerBar {\n    height: 70px;\n    border-bottom: 1px solid rgba(208, 208, 208, 0.6);\n    display: flex;\n    align-items: center;\n    padding: 0px 18px;\n    justify-content: space-between;\n    position: relative;\n\n    &[data-aero=true] {\n        border-bottom: 1px solid rgba(208, 208, 208, 0.3);\n    }\n\n    @media @mobile {\n        height: 50px;\n        padding: 0 6px;\n    }\n\n    :global {\n        .iconfont {\n            color: var(--primary-color-10);\n            &:hover {\n                color: var(--primary-color-8);\n            }\n        }\n\n        .online, .offline {\n            display: inline-block;\n            width: 10px;\n            height: 10px;\n            border-radius: 50%;\n            margin-right: 4px;\n            transform: translateY(1px);\n        }\n    }\n}\n\n.buttonContainer {\n    display: flex;\n    width: 80px;\n}\n\n.rightButtonContainer {\n    justify-content: flex-end;\n}\n\n.name {\n    font-size: 16px;\n    color: #333;\n\n    @media @mobile {\n        font-size: 14px;\n        height: 100%;\n        display: flex;\n        flex-direction: column;\n        align-items: center;\n        justify-content: center;\n        flex: 1;\n        transform: translateY(2px);\n    }\n}\n\n.status {\n    color: #999;\n    font-size: 12px;\n    transform: scale(0.6);\n}\n"
  },
  {
    "path": "packages/web/src/modules/Chat/HeaderBar.tsx",
    "content": "import React from 'react';\nimport { useSelector } from 'react-redux';\nimport CopyToClipboard from 'react-copy-to-clipboard';\nimport { css } from 'linaria';\n\nimport { isMobile } from '@fiora/utils/ua';\nimport { State } from '../../state/reducer';\nimport useIsLogin from '../../hooks/useIsLogin';\nimport useAction from '../../hooks/useAction';\nimport IconButton from '../../components/IconButton';\nimport Message from '../../components/Message';\n\nimport Style from './HeaderBar.less';\nimport useAero from '../../hooks/useAero';\n\nconst styles = {\n    count: css`\n        font-size: 14px;\n        @media (max-width: 500px) {\n            font-size: 12px;\n        }\n    `,\n};\n\ntype Props = {\n    id: string;\n    /** 联系人名称, 没有联系人时会传空 */\n    name: string;\n    /** 联系人类型, 没有联系人时会传空 */\n    type: string;\n    onlineMembersCount?: number;\n    isOnline?: boolean;\n    /** 功能按钮点击事件 */\n    onClickFunction: () => void;\n};\n\nfunction HeaderBar(props: Props) {\n    const {\n        id,\n        name,\n        type,\n        onlineMembersCount,\n        isOnline,\n        onClickFunction,\n    } = props;\n\n    const action = useAction();\n    const connectStatus = useSelector((state: State) => state.connect);\n    const isLogin = useIsLogin();\n    const sidebarVisible = useSelector(\n        (state: State) => state.status.sidebarVisible,\n    );\n    const aero = useAero();\n\n    function handleShareGroup() {\n        Message.success('已复制邀请链接到粘贴板, 去邀请其它人加入群组吧');\n    }\n\n    return (\n        <div className={Style.headerBar} {...aero}>\n            {isMobile && (\n                <div className={Style.buttonContainer}>\n                    <IconButton\n                        width={40}\n                        height={40}\n                        icon=\"feature\"\n                        iconSize={24}\n                        onClick={() =>\n                            action.setStatus('sidebarVisible', !sidebarVisible)\n                        }\n                    />\n                    <IconButton\n                        width={40}\n                        height={40}\n                        icon=\"friends\"\n                        iconSize={24}\n                        onClick={() =>\n                            action.setStatus(\n                                'functionBarAndLinkmanListVisible',\n                                true,\n                            )\n                        }\n                    />\n                </div>\n            )}\n            <h2 className={Style.name}>\n                {name && (\n                    <span>\n                        {name}{' '}\n                        {isLogin && onlineMembersCount !== undefined && (\n                            <b\n                                className={styles.count}\n                            >{`(${onlineMembersCount})`}</b>\n                        )}\n                        {isLogin && isOnline !== undefined && (\n                            <b className={styles.count}>{`(${\n                                isOnline ? '在线' : '离线'\n                            })`}</b>\n                        )}\n                    </span>\n                )}\n                {isMobile && (\n                    <span className={Style.status}>\n                        <div className={connectStatus ? 'online' : 'offline'} />\n                        {connectStatus ? '在线' : '离线'}\n                    </span>\n                )}\n            </h2>\n            {isLogin && type ? (\n                <div\n                    className={`${Style.buttonContainer} ${Style.rightButtonContainer}`}\n                >\n                    {type === 'group' && (\n                        <CopyToClipboard\n                            text={`${window.location.origin}/invite/group/${id}`}\n                        >\n                            <IconButton\n                                width={40}\n                                height={40}\n                                icon=\"share\"\n                                iconSize={24}\n                                onClick={handleShareGroup}\n                            />\n                        </CopyToClipboard>\n                    )}\n                    <IconButton\n                        width={40}\n                        height={40}\n                        icon=\"gongneng\"\n                        iconSize={24}\n                        onClick={onClickFunction}\n                    />\n                </div>\n            ) : (\n                <div className={Style.buttonContainer} />\n            )}\n        </div>\n    );\n}\n\nexport default HeaderBar;\n"
  },
  {
    "path": "packages/web/src/modules/Chat/Message/CodeDialog.tsx",
    "content": "import React from 'react';\nimport Prism from 'prismjs';\n\nimport xss from '@fiora/utils/xss';\nimport Style from './CodeMessage.less';\nimport Dialog from '../../../components/Dialog';\n\ninterface CodeDialogProps {\n    visible: boolean;\n    onClose: () => void;\n    language: string;\n    code: string;\n}\n\nfunction CodeDialog(props: CodeDialogProps) {\n    const { visible, onClose, language, code } = props;\n    const html =\n        language === 'text'\n            ? xss(code)\n            : // @ts-ignore\n            Prism.highlight(code, Prism.languages[language]);\n    setTimeout(Prism.highlightAll.bind(Prism), 0); // TODO: https://github.com/PrismJS/prism/issues/1487\n\n    return (\n        <Dialog\n            className={Style.codeDialog}\n            title=\"查看代码\"\n            visible={visible}\n            onClose={onClose}\n        >\n            <pre className={`${Style.pre} line-numbers`}>\n                <code\n                    className={`language-${language} ${Style.code}`}\n                    // eslint-disable-next-line react/no-danger\n                    dangerouslySetInnerHTML={{ __html: html }}\n                />\n            </pre>\n        </Dialog>\n    );\n}\n\nexport default CodeDialog;\n"
  },
  {
    "path": "packages/web/src/modules/Chat/Message/CodeMessage.less",
    "content": "@import '../../../styles/variable.less';\n\n.codeMessage {\n    width: 160px;\n    padding: 0 4px;\n    text-align: center;\n    cursor: pointer;\n    color: var(--primary-text-color-10);\n}\n\n.codeInfo {\n    display: flex;\n    border-bottom: 1px solid #eee;\n    align-items: center;\n    justify-content: center;\n}\n\n.icon {\n    width: 30px;\n    height: 30px;\n    text-align: center;\n    line-height: 30px;\n    margin: 0 2px;\n}\n\n.codeSize {\n    margin-left: 8px;\n}\n\n.codeViewButton {\n    display: inline-block;\n    font-size: 12px;\n    text-align: center;\n    margin-top: 6px;\n}\n\n.codeDialog {\n    width: 800px !important;\n    max-height: 800px;\n    display: flex;\n\n    @media @mobile {\n        max-width: 90vw;\n    }\n\n    :global {\n        .rc-dialog-content {\n            flex: 1;\n            min-height: 300px;\n            display: flex;\n            flex-direction: column;\n        }\n        .rc-dialog-body {\n            padding: 0 16px;\n            overflow-y: auto;\n            -webkit-overflow-scrolling: touch;\n            max-width: 800px;\n            position: relative;\n            flex: 1;\n            * {\n                user-select: text;\n            }\n            @media @mobile {\n                max-width: 100%;\n            }\n        }\n    }\n}\n\n.codeDialogButton {\n    height: 26px;\n    position: fixed;\n    top: 58px;\n    right: 18px;\n    z-index: 999;\n    font-size: 13px;\n}\n\n.pre {\n    margin: 0 !important;\n    font-size: 12px !important;\n    background-color: white !important;\n    position: relative !important;\n\n    :global {\n        .line-numbers-rows {\n            position: absolute;\n            left: 0 !important;\n            top: 12px !important;\n            background-color: white;\n        }\n    }\n}\n\n.code {\n    position: static !important;\n}"
  },
  {
    "path": "packages/web/src/modules/Chat/Message/CodeMessage.tsx",
    "content": "import React, { useState } from 'react';\nimport loadable from '@loadable/component';\n\nimport Style from './CodeMessage.less';\n\nconst CodeDialogAsync = loadable(() =>\n    // @ts-ignore\n    import(/* webpackChunkName: \"code-dialog\" */ './CodeDialog'),\n);\n\ntype LanguageMap = {\n    [language: string]: string;\n};\n\nconst languagesMap: LanguageMap = {\n    javascript: 'javascript',\n    typescript: 'typescript',\n    java: 'java',\n    c_cpp: 'cpp',\n    python: 'python',\n    ruby: 'ruby',\n    php: 'php',\n    golang: 'go',\n    csharp: 'csharp',\n    html: 'html',\n    css: 'css',\n    sql: 'sql',\n    json: 'json',\n    text: 'text',\n};\n\ninterface CodeMessageProps {\n    code: string;\n}\n\nfunction CodeMessage(props: CodeMessageProps) {\n    const { code } = props;\n\n    const [codeDialog, toggleCodeDialog] = useState(false);\n\n    const parseResult = /@language=([_a-z]+)@/.exec(code);\n    if (!parseResult) {\n        return <pre className=\"code\">不支持的编程语言</pre>;\n    }\n\n    const language = languagesMap[parseResult[1]] || 'text';\n    const rawCode = code.replace(/@language=[_a-z]+@/, '');\n    let size = `${rawCode.length}B`;\n    if (rawCode.length > 1024) {\n        size = `${Math.ceil((rawCode.length / 1024) * 100) / 100}KB`;\n    }\n\n    return (\n        <>\n            <div\n                className={Style.codeMessage}\n                onClick={() => toggleCodeDialog(true)}\n                role=\"button\"\n            >\n                <div className={Style.codeInfo}>\n                    <div className={Style.icon}>\n                        <i className=\"iconfont icon-code\" />\n                    </div>\n                    <div>\n                        <span>{language}</span>\n                        <span className={Style.codeSize}>{size}</span>\n                    </div>\n                </div>\n                <p className={Style.codeViewButton}>查看</p>\n            </div>\n            {codeDialog && (\n                <CodeDialogAsync\n                    visible={codeDialog}\n                    onClose={() => toggleCodeDialog(false)}\n                    language={language}\n                    code={rawCode}\n                />\n            )}\n        </>\n    );\n}\n\nexport default CodeMessage;\n"
  },
  {
    "path": "packages/web/src/modules/Chat/Message/FileMessage.tsx",
    "content": "import React from 'react';\nimport { css } from 'linaria';\nimport filesize from 'filesize';\nimport { getOSSFileUrl } from '../../../utils/uploadFile';\n\nconst styles = {\n    container: css`\n        display: block;\n        min-width: 160px;\n        max-width: 240px;\n        padding: 0 4px;\n        text-align: center;\n        cursor: pointer;\n        color: var(--primary-text-color-10);\n        text-decoration: none;\n    `,\n    fileInfo: css`\n        display: flex;\n        flex-direction: column;\n        align-items: center;\n        justify-content: center;\n        border-bottom: 1px solid #eee;\n    `,\n    fileInfoText: css`\n        word-break: break-all;\n    `,\n    button: css`\n        display: inline-block;\n        font-size: 12px;\n        text-align: center;\n        margin-top: 6px;\n    `,\n};\n\ntype Props = {\n    file: string;\n    percent: number;\n};\n\nfunction FileMessage({ file, percent }: Props) {\n    const { fileUrl, filename, size } = JSON.parse(file);\n    const url = fileUrl && getOSSFileUrl(fileUrl);\n\n    return (\n        <a\n            className={styles.container}\n            {...(fileUrl\n                ? { href: url, download: filename, target: '_blank' }\n                : {})}\n        >\n            <div className={styles.fileInfo}>\n                <span className={styles.fileInfoText}>{filename}</span>\n                <span className={styles.fileInfoText}>{filesize(size)}</span>\n            </div>\n            <p className={styles.button}>\n                {percent === undefined || percent >= 100\n                    ? '下载'\n                    : `上传中... ${percent.toFixed(0)}%`}\n            </p>\n        </a>\n    );\n}\n\nexport default React.memo(FileMessage);\n"
  },
  {
    "path": "packages/web/src/modules/Chat/Message/ImageMessage.tsx",
    "content": "import React, { useState, useCallback, useRef, MouseEvent } from 'react';\nimport loadable from '@loadable/component';\n\nimport { isMobile } from '@fiora/utils/ua';\nimport { getOSSFileUrl } from '../../../utils/uploadFile';\nimport Style from './Message.less';\nimport { CircleProgress } from '../../../components/Progress';\n\nconst ReactViewerAsync = loadable(\n    async () =>\n        // @ts-ignore \n        import(/* webpackChunkName: \"react-viewer\" */ 'react-viewer'),\n);\n\ninterface ImageMessageProps {\n    src: string;\n    loading: boolean;\n    percent: number;\n}\n\nfunction ImageMessage(props: ImageMessageProps) {\n    const { src, loading, percent } = props;\n\n    const [viewer, toggleViewer] = useState(false);\n    const closeViewer = useCallback(() => toggleViewer(false), []);\n    const $container = useRef(null);\n\n    let imageSrc = src;\n    const containerWidth = isMobile ? window.innerWidth - 25 - 50 : 450;\n    const maxWidth = containerWidth - 100 > 500 ? 500 : containerWidth - 100;\n    const maxHeight = 200;\n    let width = 200;\n    let height = 200;\n    const parseResult = /width=([0-9]+)&height=([0-9]+)/.exec(imageSrc);\n    if (parseResult) {\n        const natureWidth = +parseResult[1];\n        const naturehHeight = +parseResult[2];\n        let scale = 1;\n        if (natureWidth * scale > maxWidth) {\n            scale = maxWidth / natureWidth;\n        }\n        if (naturehHeight * scale > maxHeight) {\n            scale = maxHeight / naturehHeight;\n        }\n        width = natureWidth * scale;\n        height = naturehHeight * scale;\n        imageSrc = /^(blob|data):/.test(imageSrc)\n            ? imageSrc.split('?')[0]\n            : getOSSFileUrl(\n                src,\n                `image/resize,w_${Math.floor(width)},h_${Math.floor(\n                    height,\n                )}/quality,q_90`,\n            );\n    }\n\n    let className = Style.imageMessage;\n    if (loading) {\n        className += ` ${Style.iamgeLoading}`;\n    }\n    if (/huaji=true/.test(imageSrc)) {\n        className += ` ${Style.huaji}`;\n    }\n\n    function handleImageViewerMaskClick(e: MouseEvent) {\n        // @ts-ignore\n        if (e.target?.tagName !== 'IMG') {\n            closeViewer();\n        }\n    }\n\n    return (\n        <>\n            <div className={className} ref={$container}>\n                <img\n                    className={Style.image}\n                    src={imageSrc}\n                    alt=\"消息图片\"\n                    width={width}\n                    height={height}\n                    onClick={() => toggleViewer(true)}\n                />\n                <CircleProgress\n                    className={Style.imageProgress}\n                    percent={percent}\n                    strokeWidth={5}\n                    strokeColor=\"#a0c672\"\n                    trailWidth={5}\n                />\n                <div\n                    className={`${Style.imageProgress} ${Style.imageProgressNumber}`}\n                >\n                    {Math.ceil(percent)}%\n                </div>\n                {viewer && (\n                    <ReactViewerAsync\n                        // eslint-disable-next-line react/destructuring-assignment\n                        visible={viewer}\n                        onClose={closeViewer}\n                        onMaskClick={handleImageViewerMaskClick}\n                        images={[\n                            {\n                                src: getOSSFileUrl(src, `image/quality,q_95`),\n                                alt: '',\n                            },\n                        ]}\n                        noNavbar\n                    />\n                )}\n            </div>\n        </>\n    );\n}\n\nexport default React.memo(ImageMessage);\n"
  },
  {
    "path": "packages/web/src/modules/Chat/Message/InviteMessage.less",
    "content": ".inviteMessage {\n    width: 160px;\n    padding: 0 4px;\n    text-align: center;\n    cursor: pointer;\n    color: var(--primary-text-color-10);\n}\n\n.info {\n    display: flex;\n    border-bottom: 1px solid #eee;\n    align-items: center;\n}\n\n.infoText {\n    line-height: 18px;\n}\n\n.join {\n    display: inline-block;\n    font-size: 12px;\n    text-align: center;\n    margin-top: 6px;\n}"
  },
  {
    "path": "packages/web/src/modules/Chat/Message/InviteMessageV2.tsx",
    "content": "import React from 'react';\n\nimport Style from './InviteMessage.less';\nimport { joinGroup, getLinkmanHistoryMessages } from '../../../service';\nimport useAction from '../../../hooks/useAction';\nimport Message from '../../../components/Message';\n\ninterface InviteMessageProps {\n    inviteInfo: string;\n}\n\nfunction InviteMessage(props: InviteMessageProps) {\n    const { inviteInfo } = props;\n    const invite = JSON.parse(inviteInfo);\n\n    const action = useAction();\n\n    async function handleJoinGroup() {\n        const group = await joinGroup(invite.group);\n        if (group) {\n            group.type = 'group';\n            action.addLinkman(group, true);\n            Message.success('加入群组成功');\n            const messages = await getLinkmanHistoryMessages(invite.group, 0);\n            if (messages) {\n                action.addLinkmanHistoryMessages(invite.group, messages);\n            }\n        }\n    }\n\n    return (\n        <div\n            className={Style.inviteMessage}\n            onClick={handleJoinGroup}\n            role=\"button\"\n        >\n            <div className={Style.info}>\n                <span className={Style.info}>\n                    &quot;{invite.inviterName}&quot; 邀请你加入群组「\n                    {invite.groupName}」\n                </span>\n            </div>\n            <p className={Style.join}>加入</p>\n        </div>\n    );\n}\n\nexport default InviteMessage;\n"
  },
  {
    "path": "packages/web/src/modules/Chat/Message/Message.less",
    "content": ".message {\n    display: flex;\n    margin-right: 54px;\n    margin-bottom: 10px;\n    position: relative;\n}\n\n.avatar {\n    min-width: 44px;\n}\n\n.right {\n    margin-left: 12px;\n    display: flex;\n    flex-direction: column;\n    align-items: flex-start;\n    overflow: hidden;\n}\n\n.nicknameTimeBlock {\n    height: 20px;\n    display: flex;\n    align-items: flex-end;\n}\n\n.tag {\n    height: 16px;\n    line-height: 16px;\n    border-radius: 3px;\n    padding: 0 5px;\n    display: inline-block;\n    font-size: 12px;\n    background-color: var(--primary-color-9);\n    color: var(--primary-text-color-10);\n    transform: scale(0.9);\n    margin-left: -1px;\n    margin-right: 2px;\n    user-select: text;\n}\n\n.nickname {\n    color: #333;\n    font-size: 13px;\n    user-select: text;\n    margin-right: 4px;\n}\n\n.time {\n    color: #666;\n    font-size: 12px;\n}\n\n.contentButtonBlock {\n    display: flex;\n    position: relative;\n    padding-right: 30px;\n    max-width: -webkit-fill-available;\n}\n\n.content {\n    display: inline-block;\n    color: #555;\n    font-size: 14px;\n    background-color: rgba(255, 255, 255, 0.8);\n    padding: 6px 8px;\n    border-radius: 8px;\n    border-top-left-radius: 0px;\n    min-height: 28px;\n    margin-top: 3px;\n    max-width: -webkit-fill-available;\n}\n\n.arrow {\n    width: 0;\n    height: 0;\n    border-style: solid;\n    border-width: 0px 7px 15px 0;\n    border-color: transparent rgba(255, 255, 255, 0.8) transparent transparent;\n    position: absolute;\n    top: 23px;\n    left: 49px;\n}\n\n.buttonList {\n    display: flex;\n    align-self: flex-end;\n    margin-left: 5px;\n    margin-bottom: 2px;\n    position: absolute;\n    right: 5px;\n}\n\n.button {\n    background-color: rgba(255, 255, 255, 0.8);\n\n    &:hover {\n        background-color: #f0f0f0;\n        color: rgba(165, 181, 192, 1);\n    }\n\n    @borderRadius: 3px;\n    &:first-child {\n        border-top-left-radius: @borderRadius;\n        border-bottom-left-radius: @borderRadius;\n    }\n    &:last-child {\n        border-top-right-radius: @borderRadius;\n        border-bottom-right-radius: @borderRadius;\n    }\n}\n\n.textMessage {\n    user-select: text;\n    word-break: break-word;\n    overflow: hidden;\n    line-height: 17px;\n}\n\n.imageMessage {\n    position: relative;\n}\n\n.imageProgress {\n    display: none;\n    position: absolute;\n    top: 50%;\n    left: 50%;\n    transform: translate(-50%, -50%);\n    width: 80px;\n    height: 80px;\n}\n\n.imageProgressNumber {\n    color: var(--primary-text-color-10);\n    text-align: center;\n    line-height: 80px;\n    font-size: 18px;\n}\n\n.image {\n    max-width: 500px;\n    max-height: 400px;\n}\n\n.iamgeLoading {\n    .image {\n        filter: blur(5px);\n    }\n    .imageProgress {\n        display: block;\n    }\n    .imageProgressNumber {\n        display: block;\n    }\n}\n\n.huaji {\n    .image {\n        width: auto;\n        max-height: 150px;\n    }\n}\n\n.selecteAble {\n    user-select: text;\n}\n\n.baidu {\n    width: 30px;\n    height: 30px;\n    background-repeat: no-repeat;\n    margin: 0 2px;\n    background-size: 30px auto;\n    background-image: url(~@fiora/assets/images/baidu.png);\n}\n\n.self {\n    flex-direction: row-reverse;\n    margin-right: 0px;\n    margin-left: 54px;\n\n    .right {\n        margin-left: 0px;\n        margin-right: 12px;\n        display: flex;\n        flex-direction: column;\n        align-items: flex-end;\n    }\n    .nicknameTimeBlock {\n        display: flex;\n        flex-direction: row-reverse;\n    }\n    .tag {\n        margin-left: 2px;\n        margin-right: -1px;\n    }\n    .nickname {\n        margin-right: 0;\n        margin-left: 4px;\n    }\n    .contentButtonBlock {\n        flex-direction: row-reverse;\n        padding-left: 30px;\n        padding-right: 0;\n    }\n    .content {\n        color: rgba(255, 255, 255, 0.9);\n        background-color: var(--primary-color-10);\n        border-top-left-radius: 8px;\n        border-top-right-radius: 0px;\n    }\n    .buttonList {\n        margin-right: 5px;\n        left: 0;\n        right: inherit;\n    }\n    .arrow {\n        border-width: 0 0 15px 7px;\n        border-color: transparent transparent transparent var(--primary-color-10);\n        left: initial;\n        right: 49px;\n        top: 23px;\n    }\n    .textMessage {\n        color: var(--primary-text-color-10);\n    }\n}\n"
  },
  {
    "path": "packages/web/src/modules/Chat/Message/Message.tsx",
    "content": "import React, { Component, createRef } from 'react';\nimport pureRender from 'pure-render-decorator';\nimport { connect } from 'react-redux';\n\nimport Time from '@fiora/utils/time';\nimport { getRandomColor, getPerRandomColor } from '@fiora/utils/getRandomColor';\nimport client from '@fiora/config/client';\nimport Style from './Message.less';\nimport Avatar from '../../../components/Avatar';\nimport TextMessage from './TextMessage';\nimport { ShowUserOrGroupInfoContext } from '../../../context';\nimport ImageMessage from './ImageMessage';\nimport CodeMessage from './CodeMessage';\nimport UrlMessage from './UrlMessage';\nimport InviteMessageV2 from './InviteMessageV2';\nimport SystemMessage from './SystemMessage';\nimport store from '../../../state/store';\nimport { ActionTypes, DeleteMessagePayload } from '../../../state/action';\nimport { deleteMessage } from '../../../service';\nimport IconButton from '../../../components/IconButton';\nimport { State } from '../../../state/reducer';\nimport Tooltip from '../../../components/Tooltip';\nimport themes from '../../../themes';\nimport FileMessage from './FileMessage';\n\nconst { dispatch } = store;\n\ninterface MessageProps {\n    id: string;\n    linkmanId: string;\n    isSelf: boolean;\n    userId: string;\n    avatar: string;\n    username: string;\n    originUsername: string;\n    tag: string;\n    time: string;\n    type: string;\n    content: string;\n    loading: boolean;\n    percent: number;\n    shouldScroll: boolean;\n    tagColorMode: string;\n    isAdmin?: boolean;\n}\n\ninterface MessageState {\n    showButtonList: boolean;\n}\n\n/**\n * Message组件用hooks实现有些问题\n * 功能上要求Message组件渲染后触发滚动, 实测中发现在useEffect中触发滚动会比在componentDidMount中晚\n * 具体表现就是会先看到历史消息, 然后一闪而过再滚动到合适的位置\n */\n@pureRender\nclass Message extends Component<MessageProps, MessageState> {\n    $container = createRef<HTMLDivElement>();\n\n    constructor(props: MessageProps) {\n        super(props);\n        this.state = {\n            showButtonList: false,\n        };\n    }\n\n    componentDidMount() {\n        const { shouldScroll } = this.props;\n        if (shouldScroll) {\n            // @ts-ignore\n            this.$container.current.scrollIntoView();\n        }\n    }\n\n    handleMouseEnter = () => {\n        const { isAdmin, isSelf, type } = this.props;\n        if (type === 'system') {\n            return;\n        }\n        if (isAdmin || (!client.disableDeleteMessage && isSelf)) {\n            this.setState({ showButtonList: true });\n        }\n    };\n\n    handleMouseLeave = () => {\n        const { isAdmin, isSelf } = this.props;\n        if (isAdmin || (!client.disableDeleteMessage && isSelf)) {\n            this.setState({ showButtonList: false });\n        }\n    };\n\n    /**\n     * 管理员撤回消息\n     */\n    handleDeleteMessage = async () => {\n        const { id, linkmanId, loading, isAdmin } = this.props;\n        if (loading) {\n            dispatch({\n                type: ActionTypes.DeleteMessage,\n                payload: {\n                    linkmanId,\n                    messageId: id,\n                    shouldDelete: isAdmin,\n                } as DeleteMessagePayload,\n            });\n            return;\n        }\n\n        const isSuccess = await deleteMessage(id);\n        if (isSuccess) {\n            dispatch({\n                type: ActionTypes.DeleteMessage,\n                payload: {\n                    linkmanId,\n                    messageId: id,\n                    shouldDelete: isAdmin,\n                } as DeleteMessagePayload,\n            });\n            this.setState({ showButtonList: false });\n        }\n    };\n\n    handleClickAvatar(showUserInfo: (userinfo: any) => void) {\n        const { isSelf, userId, type, username, avatar } = this.props;\n        if (!isSelf && type !== 'system') {\n            showUserInfo({\n                _id: userId,\n                username,\n                avatar,\n            });\n        }\n    }\n\n    formatTime() {\n        const { time } = this.props;\n        const messageTime = new Date(time);\n        const nowTime = new Date();\n        if (Time.isToday(nowTime, messageTime)) {\n            return Time.getHourMinute(messageTime);\n        }\n        if (Time.isYesterday(nowTime, messageTime)) {\n            return `昨天 ${Time.getHourMinute(messageTime)}`;\n        }\n        return `${Time.getMonthDate(messageTime)} ${Time.getHourMinute(\n            messageTime,\n        )}`;\n    }\n\n    renderContent() {\n        const { type, content, loading, percent, originUsername } = this.props;\n        switch (type) {\n            case 'text': {\n                return <TextMessage content={content} />;\n            }\n            case 'image': {\n                return (\n                    <ImageMessage\n                        src={content}\n                        loading={loading}\n                        percent={percent}\n                    />\n                );\n            }\n            case 'file': {\n                return <FileMessage file={content} percent={percent} />;\n            }\n            case 'code': {\n                return <CodeMessage code={content} />;\n            }\n            case 'url': {\n                return <UrlMessage url={content} />;\n            }\n            case 'inviteV2': {\n                return <InviteMessageV2 inviteInfo={content} />;\n            }\n            case 'system': {\n                return (\n                    <SystemMessage\n                        message={content}\n                        username={originUsername}\n                    />\n                );\n            }\n            default:\n                return <div className=\"unknown\">不支持的消息类型</div>;\n        }\n    }\n\n    render() {\n        const { isSelf, avatar, tag, tagColorMode, username } = this.props;\n        const { showButtonList } = this.state;\n\n        let tagColor = `rgb(${themes.default.primaryColor})`;\n        if (tagColorMode === 'fixedColor') {\n            tagColor = getRandomColor(tag);\n        } else if (tagColorMode === 'randomColor') {\n            tagColor = getPerRandomColor(username);\n        }\n\n        return (\n            <div\n                className={`${Style.message} ${isSelf ? Style.self : ''}`}\n                ref={this.$container}\n            >\n                <ShowUserOrGroupInfoContext.Consumer>\n                    {(context) => (\n                        <Avatar\n                            className={Style.avatar}\n                            src={avatar}\n                            size={44}\n                            onClick={() =>\n                                // @ts-ignore\n                                this.handleClickAvatar(context.showUserInfo)\n                            }\n                        />\n                    )}\n                </ShowUserOrGroupInfoContext.Consumer>\n                <div className={Style.right}>\n                    <div className={Style.nicknameTimeBlock}>\n                        {tag && (\n                            <span\n                                className={Style.tag}\n                                style={{ backgroundColor: tagColor }}\n                            >\n                                {tag}\n                            </span>\n                        )}\n                        <span className={Style.nickname}>{username}</span>\n                        <span className={Style.time}>{this.formatTime()}</span>\n                    </div>\n                    <div\n                        className={Style.contentButtonBlock}\n                        onMouseEnter={this.handleMouseEnter}\n                        onMouseLeave={this.handleMouseLeave}\n                    >\n                        <div className={Style.content}>\n                            {this.renderContent()}\n                        </div>\n                        {showButtonList && (\n                            <div className={Style.buttonList}>\n                                <Tooltip\n                                    placement={isSelf ? 'left' : 'right'}\n                                    mouseEnterDelay={0.3}\n                                    overlay={<span>撤回消息</span>}\n                                >\n                                    <div>\n                                        <IconButton\n                                            className={Style.button}\n                                            icon=\"recall\"\n                                            iconSize={16}\n                                            width={20}\n                                            height={20}\n                                            onClick={this.handleDeleteMessage}\n                                        />\n                                    </div>\n                                </Tooltip>\n                            </div>\n                        )}\n                    </div>\n                    <div className={Style.arrow} />\n                </div>\n            </div>\n        );\n    }\n}\n\nexport default connect((state: State) => ({\n    isAdmin: !!(state.user && state.user.isAdmin),\n}))(Message);\n"
  },
  {
    "path": "packages/web/src/modules/Chat/Message/SystemMessage.tsx",
    "content": "import React from 'react';\nimport { getPerRandomColor } from '@fiora/utils/getRandomColor';\n\ninterface SystemMessageProps {\n    message: string;\n    username: string;\n}\n\nfunction SystemMessage(props: SystemMessageProps) {\n    const { message, username } = props;\n    return (\n        <div className=\"system\">\n            <span style={{ color: getPerRandomColor(username) }}>\n                {username}\n            </span>\n            &nbsp;\n            {message}\n        </div>\n    );\n}\n\nexport default SystemMessage;\n"
  },
  {
    "path": "packages/web/src/modules/Chat/Message/TextMessage.tsx",
    "content": "import React from 'react';\n\nimport expressions from '@fiora/utils/expressions';\nimport { TRANSPARENT_IMAGE } from '@fiora/utils/const';\nimport Style from './Message.less';\n\ninterface TextMessageProps {\n    content: string;\n}\n\nfunction TextMessage(props: TextMessageProps) {\n    // eslint-disable-next-line react/destructuring-assignment\n    const content = props.content\n        .replace(\n            /https?:\\/\\/(www\\.)?[-a-zA-Z0-9@:%._+~#=]{2,256}(\\.[a-z]{2,6})?\\b(:[0-9]{2,5})?([-a-zA-Z0-9@:%_+.~#?&//=]*)/g,\n            (r) =>\n                `<a class=\"${Style.selecteAble}\" href=\"${r}\" rel=\"noopener noreferrer\" target=\"_blank\">${r}</a>`,\n        )\n        .replace(/#\\(([\\u4e00-\\u9fa5a-z]+)\\)/g, (r, e) => {\n            const index = expressions.default.indexOf(e);\n            if (index !== -1) {\n                return `<img class=\"${Style.baidu} ${\n                    Style.selecteAble\n                }\" src=\"${TRANSPARENT_IMAGE}\" style=\"background-position: left ${-30 *\n                    index}px;\" onerror=\"this.style.display='none'\" alt=\"${r}\">`;\n            }\n            return r;\n        });\n\n    return (\n        <div\n            className={Style.textMessage}\n            // eslint-disable-next-line react/no-danger\n            dangerouslySetInnerHTML={{ __html: content }}\n        />\n    );\n}\n\nexport default TextMessage;\n"
  },
  {
    "path": "packages/web/src/modules/Chat/Message/UrlMessage.tsx",
    "content": "import React from 'react';\n\ninterface UrlMessageProps {\n    url: string;\n}\n\nfunction UrlMessage(props: UrlMessageProps) {\n    const { url } = props;\n    return (\n        <a href={url} target=\"_black\" rel=\"noopener noreferrer\">\n            {url}\n        </a>\n    );\n}\n\nexport default UrlMessage;\n"
  },
  {
    "path": "packages/web/src/modules/Chat/MessageList.less",
    "content": "@import '../../styles/variable.less';\n\n.messageList {\n    width: 100%;\n    height: 100%;\n    padding: 8px 10px 0 10px;\n    overflow-y: auto;\n    overflow-x: hidden;\n    -webkit-overflow-scrolling: touch;\n    position: relative;\n\n    @media @mobile {\n        padding: 8px 6px 0 6px;\n    }\n}"
  },
  {
    "path": "packages/web/src/modules/Chat/MessageList.tsx",
    "content": "import React, { useRef } from 'react';\nimport { useSelector } from 'react-redux';\n\nimport { css } from 'linaria';\nimport { State, Message } from '../../state/reducer';\nimport useIsLogin from '../../hooks/useIsLogin';\nimport useAction from '../../hooks/useAction';\nimport {\n    getLinkmanHistoryMessages,\n    getDefaultGroupHistoryMessages,\n    updateHistory,\n} from '../../service';\nimport MessageComponent from './Message/Message';\n\nimport Style from './MessageList.less';\n\nconst styles = {\n    container: css`\n        flex: 1;\n        position: relative;\n        overflow: hidden;\n    `,\n    unread: css`\n        position: absolute;\n        bottom: 6px;\n        left: 50%;\n        transform: translateX(-50%);\n        background-color: var(--primary-color-8);\n        font-size: 14px;\n        color: var(--primary-text-color-9);\n        padding: 3px 8px;\n        border-radius: 3px;\n        display: flex;\n        align-items: center;\n        justify-content: center;\n        cursor: pointer;\n    `,\n};\n\nfunction MessageList() {\n    const action = useAction();\n    const selfId = useSelector((state: State) => state.user?._id || '');\n    const focus = useSelector((state: State) => state.focus);\n    const isGroup = useSelector(\n        (state: State) => state.linkmans[focus].type === 'group',\n    );\n    const creator = useSelector(\n        (state: State) => state.linkmans[focus].creator,\n    );\n    const messages = useSelector(\n        (state: State) => state.linkmans[focus].messages,\n    );\n    const unread = useSelector((state: State) => state.linkmans[focus].unread);\n    const isLogin = useIsLogin();\n    const tagColorMode = useSelector(\n        (state: State) => state.status.tagColorMode,\n    );\n\n    const $list = useRef<HTMLDivElement>(null);\n\n    function clearUnread() {\n        action.setLinkmanProperty(focus, 'unread', 0);\n        const messageKeys = Object.keys(messages);\n        if (messageKeys.length > 0) {\n            updateHistory(\n                focus,\n                messages[messageKeys[messageKeys.length - 1]]._id,\n            );\n        }\n    }\n\n    let isFetching = false;\n    async function handleScroll(e: any) {\n        // Don't know why the code-view dialog will also trigger when scrolling\n        if ($list.current && e.target !== $list.current) {\n            return;\n        }\n        if (isFetching) {\n            return;\n        }\n\n        const $div = e.target as HTMLDivElement;\n\n        if (\n            unread &&\n            $div.scrollHeight - $div.clientHeight - $div.scrollTop > 50\n        ) {\n            clearUnread();\n        }\n\n        if ($div.scrollTop === 0 && $div.scrollHeight > $div.clientHeight) {\n            isFetching = true;\n            let historyMessages: Message[] = [];\n            if (isLogin) {\n                historyMessages = await getLinkmanHistoryMessages(\n                    focus,\n                    Object.keys(messages).length,\n                );\n            } else {\n                historyMessages = await getDefaultGroupHistoryMessages(\n                    Object.keys(messages).length,\n                );\n            }\n            if (historyMessages && historyMessages.length > 0) {\n                action.addLinkmanHistoryMessages(focus, historyMessages);\n            }\n            isFetching = false;\n        }\n    }\n\n    function renderMessage(message: Message) {\n        const isSelf = message.from._id === selfId;\n        let shouldScroll = true;\n        if ($list.current) {\n            // @ts-ignore\n            const { scrollHeight, clientHeight, scrollTop } = $list.current;\n            shouldScroll =\n                isSelf ||\n                scrollHeight === clientHeight ||\n                scrollTop === 0 ||\n                scrollTop > scrollHeight - clientHeight * 2;\n        }\n\n        let { tag } = message.from;\n        if (!tag && isGroup && message.from._id === creator) {\n            tag = '群主';\n        }\n\n        return (\n            <MessageComponent\n                key={message._id}\n                id={message._id}\n                linkmanId={focus}\n                isSelf={isSelf}\n                userId={message.from._id}\n                avatar={message.from.avatar}\n                username={message.from.username}\n                originUsername={message.from.originUsername}\n                time={message.createTime}\n                type={message.type}\n                content={message.content}\n                tag={tag}\n                loading={message.loading}\n                percent={message.percent}\n                shouldScroll={shouldScroll}\n                tagColorMode={tagColorMode}\n            />\n        );\n    }\n\n    return (\n        <div className={styles.container}>\n            <div\n                className={`${Style.messageList} show-scrollbar`}\n                onScroll={handleScroll}\n                ref={$list}\n            >\n                {Object.values(messages).map((message) =>\n                    renderMessage(message),\n                )}\n            </div>\n        </div>\n    );\n}\n\nexport default MessageList;\n"
  },
  {
    "path": "packages/web/src/modules/FunctionBarAndLinkmanList/CreateGroup.less",
    "content": ".createGroup {\n    \n}\n\n.container {\n    display: flex;\n    flex-direction: column;\n}\n\n.text {\n    font-size: 14px;\n    font-weight:normal;\n    line-height: 31px;\n    color: #333;\n}\n\n.input {\n    height: 40px;\n}\n\n.button {\n    width: 66px;\n    height: 36px;\n    border-radius: 6px;\n    margin-top: 12px;\n    background-color: var(--primary-color-10);\n    color: var(--primary-text-color-10);\n    align-self: flex-end;\n    font-size: 14px;\n    border: none;\n\n    &:hover {\n        background-color: var(--primary-color-8);\n    }\n}"
  },
  {
    "path": "packages/web/src/modules/FunctionBarAndLinkmanList/CreateGroup.tsx",
    "content": "import React, { useState } from 'react';\n\nimport Style from './CreateGroup.less';\nimport Dialog from '../../components/Dialog';\nimport Input from '../../components/Input';\nimport Message from '../../components/Message';\nimport { createGroup } from '../../service';\nimport useAction from '../../hooks/useAction';\n\ninterface CreateGroupProps {\n    visible: boolean;\n    onClose: () => void;\n}\n\nfunction CreateGroup(props: CreateGroupProps) {\n    const { visible, onClose } = props;\n    const action = useAction();\n    const [groupName, setGroupName] = useState('');\n\n    async function handleCreateGroup() {\n        const group = await createGroup(groupName);\n        if (group) {\n            group.type = 'group';\n            action.addLinkman(group, true);\n            setGroupName('');\n            onClose();\n            Message.success('创建群组成功');\n        }\n    }\n\n    return (\n        <Dialog title=\"创建群组\" visible={visible} onClose={onClose}>\n            <div className={Style.container}>\n                <h3 className={Style.text}>请输入群组名</h3>\n                <Input\n                    className={Style.input}\n                    value={groupName}\n                    onChange={setGroupName}\n                />\n                <button\n                    className={Style.button}\n                    onClick={handleCreateGroup}\n                    type=\"button\"\n                >\n                    创建\n                </button>\n            </div>\n        </Dialog>\n    );\n}\n\nexport default CreateGroup;\n"
  },
  {
    "path": "packages/web/src/modules/FunctionBarAndLinkmanList/FunctionBar.less",
    "content": ".functionBar {\n    display: flex;\n    height: 70px;\n    align-items: center;\n    padding: 0 12px;\n    position: relative;\n}\n\n.form {\n    flex: 1;\n    display: flex;\n}\n\n.input {\n    flex: 1;\n    height: 36px;\n    border-radius: 18px;\n    border: none;\n    font-size: 14px;\n    color: #333;\n\n    :global {\n        input {\n            padding-left: 35px;\n            padding-right: 15px;\n            padding-top: 2px;\n            border: none;\n            border-radius: 18px;\n            background-color: rgba(255, 255, 255, 0.5);\n        }\n    }\n}\n\n.inputFocus {\n    :global {\n        input {\n            background-color: rgba(255, 255, 255, 0.9);\n        }\n    }\n}\n\n.searchIcon {\n    font-size: 22px;\n    position: absolute;\n    left: 20px;\n    top: 22px;\n    color: #666;\n}\n\n.createGroupButton {\n    margin-left: 5px;\n\n    &:hover {\n        :global {\n            .iconfont {\n                color: rgba(255, 255, 255, 0.9);\n            }\n        }\n    }\n\n    :global {\n        .iconfont {\n            color: rgba(255, 255, 255, 0.5);\n        }\n    }\n}\n\n.searchResult {\n    position: absolute;\n    top: 60px;\n    left: 8px;\n    width: 276px;\n    background-color: rgba(255, 255, 255, 0.9);\n    box-shadow: 0 0 10px rgba(0, 0, 0, 0.5);\n    border-top-left-radius: 6px;\n    border-top-right-radius: 6px;\n    z-index: 1300;\n    \n    :global {\n        .rc-tabs-tab {\n            margin-right: 20px;\n        }\n        .rc-tabs-nav {\n            margin-left: 16px !important;\n        }\n    }\n}\n\n.userList {\n    & > div {\n        display: flex;\n        align-items: center;\n        cursor: pointer;\n        height: 56px;\n        padding-left: 8px;\n        & > p {\n            color: #333;\n            margin-left: 10px;\n        }\n        &:hover {\n            background-color: #eee;\n        }\n    }\n}\n\n.groupList {\n    & > div {\n        display: flex;\n        padding-left: 8px;\n        cursor: pointer;\n        height: 56px;\n        align-items: center;\n        & > div {\n            margin-left: 10px;\n            & > p {\n                color: #333;\n                &:last-child {\n                    color: #666;\n                    font-size: 14px;\n                }\n            }\n        }\n        &:hover {\n            background-color: #eee;\n        }\n    }\n}\n\n.allList {\n    padding: 8px 12px;\n    max-height: 440px;\n\n    & > div {\n        & > p {\n            font-size: 14px;\n            color: #333;\n            font-weight: bold;\n            line-height: 31px;\n        }\n    }\n}\n\n.none {\n    font-size: 14px;\n    line-height: 80px;\n    text-align: center;\n    color: #333;\n}\n\n.more {\n    color: var(--primary-color-10);\n    font-size: 12px;\n    text-align: center;\n    & > span {\n        cursor: pointer;\n    }\n}\n\n.only {\n    padding: 6px 12px;\n    max-height: 440px;\n    overflow-y: auto;\n    -webkit-overflow-scrolling: touch;\n}"
  },
  {
    "path": "packages/web/src/modules/FunctionBarAndLinkmanList/FunctionBar.tsx",
    "content": "import React, { useState, useContext, useEffect } from 'react';\n\nimport IconButton from '../../components/IconButton';\nimport Avatar from '../../components/Avatar';\nimport {\n    Tabs,\n    TabPane,\n    TabContent,\n    ScrollableInkTabBar,\n} from '../../components/Tabs';\nimport CreateGroup from './CreateGroup';\nimport { ShowUserOrGroupInfoContext } from '../../context';\nimport { search } from '../../service';\n\nimport Style from './FunctionBar.less';\nimport Input from '../../components/Input';\nimport Message from '../../components/Message';\n\ntype SearchResult = {\n    users: any[];\n    groups: any[];\n};\n\nfunction FunctionBar() {\n    const [keywords, setKeywords] = useState('');\n    const [addButtonVisible, toggleAddButtonVisible] = useState(true);\n    const [searchResultVisible, toggleSearchResultVisible] = useState(false);\n    const [searchResultActiveKey, setSearchResultActiveKey] = useState('all');\n    const [createGroupDialogVisible, toggleCreateGroupDialogVisible] = useState(\n        false,\n    );\n    const [searchResult, setSearchResult] = useState<SearchResult>({\n        users: [],\n        groups: [],\n    });\n\n    const context = useContext(ShowUserOrGroupInfoContext);\n    const placeholder = '搜索群组/用户';\n\n    function resetSearch() {\n        toggleSearchResultVisible(false);\n        toggleAddButtonVisible(true);\n        setSearchResultActiveKey('all');\n        setSearchResult({ users: [], groups: [] });\n        setKeywords('');\n    }\n\n    function handleBodyClick(e: any) {\n        if (\n            e.target.getAttribute('placeholder') === placeholder ||\n            !searchResultVisible\n        ) {\n            return;\n        }\n\n        const { currentTarget } = e;\n        let { target } = e;\n        do {\n            if (target.className.indexOf(Style.searchResult) > -1) {\n                return;\n            }\n            target = target.parentElement;\n        } while (target && target !== currentTarget);\n\n        resetSearch();\n    }\n    useEffect(() => {\n        document.body.addEventListener('click', handleBodyClick, false);\n        return () => {\n            document.body.removeEventListener('click', handleBodyClick, false);\n        };\n    });\n\n    function handleFocus() {\n        toggleAddButtonVisible(false);\n        toggleSearchResultVisible(true);\n    }\n\n    function handleInputEnter() {\n        setTimeout(async () => {\n            if (keywords) {\n                const result = await search(keywords);\n                if (result?.users?.length || result?.groups?.length) {\n                    setSearchResult(result);\n                } else {\n                    Message.warning('没有搜索到内容, 换个关键字试试吧~');\n                    setSearchResult({ users: [], groups: [] });\n                }\n            }\n        }, 0);\n    }\n\n    function renderSearchUsers(count = 999) {\n        const { users } = searchResult;\n        count = Math.min(count, users.length);\n\n        function handleClick(targetUser: any) {\n            // @ts-ignore\n            context.showUserInfo(targetUser);\n            resetSearch();\n        }\n\n        const usersDom = [];\n        for (let i = 0; i < count; i++) {\n            usersDom.push(\n                <div\n                    key={users[i]._id}\n                    onClick={() => handleClick(users[i])}\n                    role=\"button\"\n                >\n                    <Avatar size={40} src={users[i].avatar} />\n                    <p>{users[i].username}</p>\n                </div>,\n            );\n        }\n        return usersDom;\n    }\n\n    function renderSearchGroups(count = 999) {\n        const { groups } = searchResult;\n        count = Math.min(count, groups.length);\n\n        function handleClick(targetGroup: any) {\n            // @ts-ignore\n            context.showGroupInfo(targetGroup);\n            resetSearch();\n        }\n\n        const groupsDom = [];\n        for (let i = 0; i < count; i++) {\n            groupsDom.push(\n                <div\n                    key={groups[i]._id}\n                    onClick={() => handleClick(groups[i])}\n                    role=\"button\"\n                >\n                    <Avatar size={40} src={groups[i].avatar} />\n                    <div>\n                        <p>{groups[i].name}</p>\n                        <p>{groups[i].members}人</p>\n                    </div>\n                </div>,\n            );\n        }\n        return groupsDom;\n    }\n\n    return (\n        <div className={Style.functionBar}>\n            <form\n                className={Style.form}\n                autoComplete=\"off\"\n                onSubmit={(e) => e.preventDefault()}\n            >\n                <Input\n                    className={`${Style.input} ${\n                        searchResultVisible ? Style.inputFocus : ''\n                    }`}\n                    type=\"text\"\n                    placeholder={placeholder}\n                    value={keywords}\n                    // @ts-ignore\n                    onChange={setKeywords}\n                    onFocus={handleFocus}\n                    onEnter={handleInputEnter}\n                />\n            </form>\n            <i className={`iconfont icon-search ${Style.searchIcon}`} />\n            <IconButton\n                className={Style.createGroupButton}\n                style={{ display: addButtonVisible ? 'block' : 'none' }}\n                width={40}\n                height={40}\n                icon=\"add\"\n                iconSize={38}\n                onClick={() => toggleCreateGroupDialogVisible(true)}\n            />\n            <Tabs\n                className={Style.searchResult}\n                style={{ display: searchResultVisible ? 'block' : 'none' }}\n                activeKey={searchResultActiveKey}\n                onChange={setSearchResultActiveKey}\n                renderTabBar={() => <ScrollableInkTabBar />}\n                renderTabContent={() => <TabContent />}\n            >\n                <TabPane tab=\"全部\" key=\"all\">\n                    {searchResult.users.length === 0 &&\n                    searchResult.groups.length === 0 ? (\n                            // eslint-disable-next-line react/jsx-indent\n                            <p className={Style.none}>\n                                没有搜索到内容, 换个关键字试试吧~\n                            </p>\n                        ) : (\n                            <div className={Style.allList}>\n                                <div\n                                    style={{\n                                        display:\n                                        searchResult.users.length > 0\n                                            ? 'block'\n                                            : 'none',\n                                    }}\n                                >\n                                    <p>用户</p>\n                                    <div className={Style.userList}>\n                                        {renderSearchUsers(3)}\n                                    </div>\n                                    <div\n                                        className={Style.more}\n                                        style={{\n                                            display:\n                                            searchResult.users.length > 3\n                                                ? 'block'\n                                                : 'none',\n                                        }}\n                                    >\n                                        <span\n                                            onClick={() =>\n                                                setSearchResultActiveKey('user')\n                                            }\n                                            role=\"button\"\n                                        >\n                                            查看更多\n                                        </span>\n                                    </div>\n                                </div>\n                                <div\n                                    style={{\n                                        display:\n                                        searchResult.groups.length > 0\n                                            ? 'block'\n                                            : 'none',\n                                    }}\n                                >\n                                    <p>群组</p>\n                                    <div className={Style.groupList}>\n                                        {renderSearchGroups(3)}\n                                    </div>\n                                    <div\n                                        className={Style.more}\n                                        style={{\n                                            display:\n                                            searchResult.groups.length > 3\n                                                ? 'block'\n                                                : 'none',\n                                        }}\n                                    >\n                                        <span\n                                            onClick={() =>\n                                                setSearchResultActiveKey('group')\n                                            }\n                                            role=\"button\"\n                                        >\n                                            查看更多\n                                        </span>\n                                    </div>\n                                </div>\n                            </div>\n                        )}\n                </TabPane>\n                <TabPane tab=\"用户\" key=\"user\">\n                    {searchResult.users.length === 0 ? (\n                        <p className={Style.none}>\n                            没有搜索到内容, 换个关键字试试吧~~\n                        </p>\n                    ) : (\n                        <div className={`${Style.userList} ${Style.only}`}>\n                            {renderSearchUsers()}\n                        </div>\n                    )}\n                </TabPane>\n                <TabPane tab=\"群组\" key=\"group\">\n                    {searchResult.groups.length === 0 ? (\n                        <p className={Style.none}>\n                            没有搜索到内容, 换个关键字试试吧~~\n                        </p>\n                    ) : (\n                        <div className={`${Style.groupList} ${Style.only}`}>\n                            {renderSearchGroups()}\n                        </div>\n                    )}\n                </TabPane>\n            </Tabs>\n            <CreateGroup\n                visible={createGroupDialogVisible}\n                onClose={() => toggleCreateGroupDialogVisible(false)}\n            />\n        </div>\n    );\n}\n\nexport default FunctionBar;\n"
  },
  {
    "path": "packages/web/src/modules/FunctionBarAndLinkmanList/FunctionBarAndLinkmanList.less",
    "content": "@import \"../../styles/variable.less\";\n\n.functionBarAndLinkmanList {\n    width: 300px;\n    height: 100%;\n    position: relative;\n    display: flex;\n    background-color: unset;\n\n    @media @mobile {\n        position: absolute;\n        left: 0;\n        z-index: 1;\n        width: 100%;\n        background-color: rgba(37, 37, 37, 0.5);\n\n        .container {\n            background-color: var(--primary-color-7);\n        }\n    }\n}\n\n.container {\n    background-color: var(--primary-color-5);\n    flex: 1;\n    max-width: 300px;\n    display: flex;\n    flex-direction: column;\n\n    &[data-aero=true] {\n        background-color: var(--primary-color-0);\n    }\n\n    @media @mobile {\n        background-color: var(--primary-color-7);\n    }\n}\n"
  },
  {
    "path": "packages/web/src/modules/FunctionBarAndLinkmanList/FunctionBarAndLinkmanList.tsx",
    "content": "import React from 'react';\nimport { useSelector } from 'react-redux';\n\nimport useIsLogin from '../../hooks/useIsLogin';\nimport useAction from '../../hooks/useAction';\nimport FunctionBar from './FunctionBar';\nimport LinkmanList from './LinkmanList';\n\nimport Style from './FunctionBarAndLinkmanList.less';\nimport { State } from '../../state/reducer';\nimport useAero from '../../hooks/useAero';\n\nfunction FunctionBarAndLinkmanList() {\n    const isLogin = useIsLogin();\n    const action = useAction();\n    const functionBarAndLinkmanListVisible = useSelector(\n        (state: State) => state.status.functionBarAndLinkmanListVisible,\n    );\n    const aero = useAero();\n\n    if (!functionBarAndLinkmanListVisible) {\n        return null;\n    }\n\n    function handleClick(e: any) {\n        if (e.target === e.currentTarget) {\n            action.setStatus('functionBarAndLinkmanListVisible', false);\n        }\n    }\n\n    return (\n        <div\n            className={Style.functionBarAndLinkmanList}\n            onClick={handleClick}\n            role=\"button\"\n        >\n            <div className={Style.container} {...aero}>\n                {isLogin && <FunctionBar />}\n                <LinkmanList />\n            </div>\n        </div>\n    );\n}\n\nexport default FunctionBarAndLinkmanList;\n"
  },
  {
    "path": "packages/web/src/modules/FunctionBarAndLinkmanList/Linkman.less",
    "content": "@import '../../styles/variable.less';\n\n.linkman {\n    height: 90px;\n    display: flex;\n    align-items: center;\n    padding: 10px 16px;\n    cursor: default;\n    transition: background-color 0.2s;\n}\n\n.focus {\n    background-color: var(--primary-color-4);\n\n    &[data-aero=true] {\n        background-color: rgba(255, 255, 255, 0.15);\n    }\n\n    @media @mobile {\n        background-color: var(--primary-color-9);\n    }\n}\n\n.container {\n    flex: 1;\n    margin-left: 12px;\n}\n\n.rowContainer {\n    display: flex;\n    justify-content: space-between;\n}\n\n.nameTimeBlock {\n    margin-top: 4px;\n}\n\n.name {\n    color: var(--primary-text-color-10);\n    font-size: 14px;\n}\n\n.time {\n    color: var(--primary-text-color-7);\n    font-size: 12px;\n    position: relative;\n    top: 4px;\n}\n\n.previewUnreadBlock {\n    margin-top: 6px;\n}\n\n.preview {\n    color: var(--primary-text-color-7);\n    font-size: 12px;\n    width: 188px;\n    height: 20px;\n    line-height: 20px;\n    overflow: hidden;\n    white-space: nowrap;\n    text-overflow: ellipsis;\n}\n\n.unread {\n    @size: 18px;\n    background-color: var(--primary-color-10);\n    width: @size;\n    height: @size;\n    border-radius: 50%;\n    text-align: center;\n    & > span {\n        color: #e9e9e9;\n        font-size: 10px;\n        line-height: @size;\n        position: relative;\n        top: -2px;\n    }\n}\n"
  },
  {
    "path": "packages/web/src/modules/FunctionBarAndLinkmanList/Linkman.tsx",
    "content": "import React from 'react';\nimport { useSelector } from 'react-redux';\n\nimport Time from '@fiora/utils/time';\nimport { isMobile } from '@fiora/utils/ua';\nimport Avatar from '../../components/Avatar';\nimport { State } from '../../state/reducer';\nimport useAction from '../../hooks/useAction';\n\nimport Style from './Linkman.less';\nimport useAero from '../../hooks/useAero';\nimport { useStore } from '../../hooks/useStore';\nimport { updateHistory } from '../../service';\n\ninterface LinkmanProps {\n    id: string;\n    name: string;\n    avatar: string;\n    /** 消息预览 */\n    preview: string;\n    unread: number;\n    time: Date;\n}\n\nfunction Linkman(props: LinkmanProps) {\n    const { id, name, avatar, preview, unread, time } = props;\n\n    const action = useAction();\n    const focus = useSelector((state: State) => state.focus);\n    const aero = useAero();\n    const { linkmans } = useStore();\n\n    function formatTime() {\n        const nowTime = new Date();\n        if (Time.isToday(nowTime, time)) {\n            return Time.getHourMinute(time);\n        }\n        if (Time.isYesterday(nowTime, time)) {\n            return '昨天';\n        }\n        return Time.getMonthDate(time);\n    }\n\n    async function handleClick() {\n        // Update next linkman read history\n        const nextFocusLinkman = linkmans[id];\n        if (nextFocusLinkman) {\n            const messageKeys = Object.keys(nextFocusLinkman.messages);\n            if (messageKeys.length > 0) {\n                const lastMessageId =\n                    nextFocusLinkman.messages[\n                        messageKeys[messageKeys.length - 1]\n                    ]._id;\n                updateHistory(nextFocusLinkman._id, lastMessageId);\n            }\n        }\n\n        action.setFocus(id);\n        if (isMobile) {\n            action.setStatus('functionBarAndLinkmanListVisible', false);\n        }\n    }\n\n    return (\n        <div\n            className={`${Style.linkman} ${id === focus ? Style.focus : ''}`}\n            onClick={handleClick}\n            role=\"button\"\n            {...aero}\n        >\n            <Avatar src={avatar} size={48} />\n            <div className={Style.container}>\n                <div className={`${Style.rowContainer} ${Style.nameTimeBlock}`}>\n                    <p className={Style.name}>{name}</p>\n                    <p className={Style.time}>{formatTime()}</p>\n                </div>\n                <div\n                    className={`${Style.rowContainer} ${Style.previewUnreadBlock}`}\n                >\n                    <p\n                        className={Style.preview}\n                        // eslint-disable-next-line react/no-danger\n                        dangerouslySetInnerHTML={{ __html: preview }}\n                    />\n                    {unread > 0 && (\n                        <div className={Style.unread}>\n                            <span>{unread > 99 ? '99+' : unread}</span>\n                        </div>\n                    )}\n                </div>\n            </div>\n        </div>\n    );\n}\n\nexport default Linkman;\n"
  },
  {
    "path": "packages/web/src/modules/FunctionBarAndLinkmanList/LinkmanList.less",
    "content": ".linkmanList {\n    flex: 1;\n    overflow-y: auto;\n    -webkit-overflow-scrolling: touch;\n}"
  },
  {
    "path": "packages/web/src/modules/FunctionBarAndLinkmanList/LinkmanList.tsx",
    "content": "import React from 'react';\nimport { useSelector } from 'react-redux';\n\nimport { Linkman, State } from '../../state/reducer';\nimport LinkmanComponent from './Linkman';\n\nimport Style from './LinkmanList.less';\n\nfunction LinkmanList() {\n    const linkmans = useSelector((state: State) => state.linkmans);\n\n    function renderLinkman(linkman: Linkman) {\n        const messages = Object.values(linkman.messages);\n        const lastMessage =\n            messages.length > 0 ? messages[messages.length - 1] : null;\n\n        let time = new Date(linkman.createTime);\n        let preview = '暂无消息';\n        if (lastMessage) {\n            time = new Date(lastMessage.createTime);\n            const { type } = lastMessage;\n            preview = type === 'text' ? `${lastMessage.content}` : `[${type}]`;\n            if (linkman.type === 'group') {\n                preview = `${lastMessage.from.username}: ${preview}`;\n            }\n        }\n        return (\n            <LinkmanComponent\n                key={linkman._id}\n                id={linkman._id}\n                name={linkman.name}\n                avatar={linkman.avatar}\n                preview={preview}\n                time={time}\n                unread={linkman.unread}\n            />\n        );\n    }\n\n    function getLinkmanLastTime(linkman: Linkman): number {\n        let time = linkman.createTime;\n        const messages = Object.values(linkman.messages);\n        if (messages.length > 0) {\n            time = messages[messages.length - 1].createTime;\n        }\n        return new Date(time).getTime();\n    }\n\n    function sort(linkman1: Linkman, linkman2: Linkman): number {\n        return getLinkmanLastTime(linkman1) < getLinkmanLastTime(linkman2)\n            ? 1\n            : -1;\n    }\n\n    return (\n        <div className={Style.linkmanList}>\n            {Object.values(linkmans)\n                .sort(sort)\n                .map((linkman) => renderLinkman(linkman))}\n        </div>\n    );\n}\n\nexport default LinkmanList;\n"
  },
  {
    "path": "packages/web/src/modules/GroupInfo.tsx",
    "content": "import React, { useState } from 'react';\nimport { useSelector } from 'react-redux';\n\nimport { getOSSFileUrl } from '../utils/uploadFile';\nimport Dialog from '../components/Dialog';\nimport Avatar from '../components/Avatar';\nimport Button from '../components/Button';\nimport { State } from '../state/reducer';\nimport useAction from '../hooks/useAction';\nimport { joinGroup, getLinkmanHistoryMessages } from '../service';\n\nimport Style from './InfoDialog.less';\n\ninterface GroupInfoProps {\n    visible: boolean;\n    group?: {\n        _id: string;\n        name: string;\n        avatar: string;\n        members: number;\n    };\n    onClose: () => void;\n}\n\nfunction GroupInfo(props: GroupInfoProps) {\n    const { visible, onClose, group } = props;\n\n    const action = useAction();\n    const hasLinkman = useSelector(\n        (state: State) => !!state.linkmans[group?._id as string],\n    );\n    const [largerAvatar, toggleLargetAvatar] = useState(false);\n\n    if (!group) {\n        return null;\n    }\n\n    async function handleJoinGroup() {\n        onClose();\n\n        if (!group) {\n            return;\n        }\n        const groupRes = await joinGroup(group._id);\n        if (groupRes) {\n            groupRes.type = 'group';\n            action.addLinkman(groupRes, true);\n\n            const messages = await getLinkmanHistoryMessages(group._id, 0);\n            if (messages) {\n                action.addLinkmanHistoryMessages(group._id, messages);\n            }\n        }\n    }\n\n    function handleFocusGroup() {\n        onClose();\n\n        if (!group) {\n            return;\n        }\n        action.setFocus(group._id);\n    }\n\n    return (\n        <Dialog\n            className={Style.infoDialog}\n            visible={visible}\n            onClose={onClose}\n        >\n            <div className={Style.coantainer}>\n                <div className={Style.header}>\n                    <Avatar\n                        size={60}\n                        src={group.avatar}\n                        onMouseEnter={() => toggleLargetAvatar(true)}\n                        onMouseLeave={() => toggleLargetAvatar(false)}\n                    />\n                    <img\n                        className={`${Style.largeAvatar} ${\n                            largerAvatar ? 'show' : 'hide'\n                        }`}\n                        src={getOSSFileUrl(group.avatar)}\n                        alt=\"群组头像\"\n                    />\n                    <p>{group.name}</p>\n                </div>\n                <div className={Style.info}>\n                    <div className={Style.onlineStatus}>\n                        <p className={Style.onlineText}>成员:</p>\n                        <div>{group.members}人</div>\n                    </div>\n                    {hasLinkman ? (\n                        <Button onClick={handleFocusGroup}>发送消息</Button>\n                    ) : (\n                        <Button onClick={handleJoinGroup}>加入群组</Button>\n                    )}\n                </div>\n            </div>\n        </Dialog>\n    );\n}\n\nexport default GroupInfo;\n"
  },
  {
    "path": "packages/web/src/modules/InfoDialog.less",
    "content": "@import '../styles/variable.less';\n\n.infoDialog {\n    width: 300px !important;\n    @media @mobile {\n        width: 80% !important;\n    }\n\n    :global {\n        .rc-dialog-body {\n            padding: 0;\n            & > div {\n                width: 100%;\n            }\n        }\n    }\n}\n\n.coantainer {\n    text-align: center;\n}\n\n.header {\n    background-color: rgb(240, 242, 245);\n    padding: 20px 0 14px 0;\n    border-top-left-radius: 6px;\n    border-top-right-radius: 6px;\n}\n\n.ip {\n    span {\n        font-size: 13px;\n        color: #888;\n        margin: 0 2px;\n        cursor: pointer;\n    }\n}\n\n.largeAvatar {\n    position: absolute;\n    top: -100px;\n    left: 220px;\n    width: 300px;\n    height: 300px;\n    z-index: 9999;\n\n    @media @mobile {\n        width: 180px;\n        height: 180px;\n        left: 60px;\n        top: -165px;\n    }\n}\n\n.info {\n    padding: 10px 20px 20px 20px;\n    & > button {\n        width: 100%;\n        height: 34px;\n        margin-top: 10px;\n    }\n}\n\n.onlineStatus {\n    text-align: left;\n    display: flex;\n    height: 30px;\n    align-items: center;\n    font-size: 14px;\n}\n\n.onlineText {\n    width: 50px;\n    color: #666;\n}\n"
  },
  {
    "path": "packages/web/src/modules/InviteInfo.tsx",
    "content": "/* eslint-disable no-nested-ternary */\nimport React, { useState, useEffect } from 'react';\nimport { useSelector } from 'react-redux';\nimport fetch from '../utils/fetch';\nimport Dialog from '../components/Dialog';\nimport Avatar from '../components/Avatar';\n\nimport Style from './InfoDialog.less';\nimport { State } from '../state/reducer';\nimport Button from '../components/Button';\nimport { joinGroup, getLinkmanHistoryMessages } from '../service';\nimport useAction from '../hooks/useAction';\n\ntype GroupBasicInfo = {\n    name: string;\n    avatar: string;\n    members: number;\n};\n\nfunction InviteInfo() {\n    const groupId = window.sessionStorage.getItem('inviteGroupId') || '';\n    const action = useAction();\n    const [visible, updateVisible] = useState(!!groupId);\n    const [group, updateGroup] = useState<GroupBasicInfo>();\n    const [largerAvatar, toggleLargetAvatar] = useState(false);\n    const selfId = useSelector((state: State) => state.user?._id);\n    const hasLinkman = useSelector((state: State) => !!state.linkmans[groupId]);\n\n    useEffect(() => {\n        if (!groupId) {\n            return;\n        }\n        (async () => {\n            const [error, groupInfo] = await fetch('getGroupBasicInfo', {\n                groupId,\n            });\n            if (!error) {\n                updateGroup((groupInfo as unknown) as GroupBasicInfo);\n            }\n        })();\n    }, [groupId]);\n\n    function clearInviteId() {\n        window.sessionStorage.removeItem('inviteGroupId');\n    }\n\n    function handleClose() {\n        updateVisible(false);\n    }\n\n    async function handleJoinGroup() {\n        const groupRes = await joinGroup(groupId);\n        if (groupRes) {\n            groupRes.type = 'group';\n            action.addLinkman(groupRes, true);\n\n            const messages = await getLinkmanHistoryMessages(groupId, 0);\n            if (messages) {\n                action.addLinkmanHistoryMessages(groupId, messages);\n            }\n        }\n        clearInviteId();\n        handleClose();\n    }\n\n    function handleFocusGroup() {\n        action.setFocus(groupId);\n        clearInviteId();\n        handleClose();\n    }\n\n    return (\n        <Dialog\n            className={Style.infoDialog}\n            visible={visible}\n            onClose={handleClose}\n            title=\"邀请您加入群组\"\n        >\n            {visible && group && (\n                <div className={Style.coantainer}>\n                    <div className={Style.header}>\n                        <Avatar\n                            size={60}\n                            src={group.avatar}\n                            onMouseEnter={() => toggleLargetAvatar(true)}\n                            onMouseLeave={() => toggleLargetAvatar(false)}\n                        />\n                        <img\n                            className={`${Style.largeAvatar} ${\n                                largerAvatar ? 'show' : 'hide'\n                            }`}\n                            src={group.avatar}\n                            alt=\"用户头像\"\n                        />\n                        <p>{group.name}</p>\n                    </div>\n                    <div className={Style.info}>\n                        <div className={Style.onlineStatus}>\n                            <p className={Style.onlineText}>成员:</p>\n                            <div>{group.members}人</div>\n                        </div>\n                        {selfId ? (\n                            hasLinkman ? (\n                                <Button onClick={handleFocusGroup}>\n                                    发送消息\n                                </Button>\n                            ) : (\n                                <Button onClick={handleJoinGroup}>\n                                    加入群组\n                                </Button>\n                            )\n                        ) : (\n                            <Button\n                                onClick={() =>\n                                    action.setStatus(\n                                        'loginRegisterDialogVisible',\n                                        true,\n                                    )\n                                }\n                            >\n                                登录 / 注册\n                            </Button>\n                        )}\n                    </div>\n                </div>\n            )}\n        </Dialog>\n    );\n}\n\nexport default InviteInfo;\n"
  },
  {
    "path": "packages/web/src/modules/LoginAndRegister/Login.tsx",
    "content": "import React, { useState } from 'react';\nimport platform from 'platform';\nimport { useDispatch } from 'react-redux';\n\nimport getFriendId from '@fiora/utils/getFriendId';\nimport convertMessage from '@fiora/utils/convertMessage';\nimport Input from '../../components/Input';\nimport useAction from '../../hooks/useAction';\n\nimport Style from './LoginRegister.less';\nimport { login, getLinkmansLastMessagesV2 } from '../../service';\nimport { Message } from '../../state/reducer';\nimport { ActionTypes } from '../../state/action';\n\n/** 登录框 */\nfunction Login() {\n    const action = useAction();\n    const dispatch = useDispatch();\n    const [username, setUsername] = useState('');\n    const [password, setPassword] = useState('');\n\n    async function handleLogin() {\n        const user = await login(\n            username,\n            password,\n            platform.os?.family,\n            platform.name,\n            platform.description,\n        );\n        if (user) {\n            action.setUser(user);\n            action.toggleLoginRegisterDialog(false);\n            window.localStorage.setItem('token', user.token);\n\n            const linkmanIds = [\n                ...user.groups.map((group: any) => group._id),\n                ...user.friends.map((friend: any) =>\n                    getFriendId(friend.from, friend.to._id),\n                ),\n            ];\n            const linkmanMessages = await getLinkmansLastMessagesV2(linkmanIds);\n            Object.values(linkmanMessages).forEach(\n                // @ts-ignore\n                ({ messages }: { messages: Message[] }) => {\n                    messages.forEach(convertMessage);\n                },\n            );\n            dispatch({\n                type: ActionTypes.SetLinkmansLastMessages,\n                payload: linkmanMessages,\n            });\n        }\n    }\n\n    return (\n        <div className={Style.loginRegister}>\n            <h3 className={Style.title}>用户名</h3>\n            <Input\n                className={Style.input}\n                value={username}\n                onChange={setUsername}\n                onEnter={handleLogin}\n            />\n            <h3 className={Style.title}>密码</h3>\n            <Input\n                className={Style.input}\n                type=\"password\"\n                value={password}\n                onChange={setPassword}\n                onEnter={handleLogin}\n            />\n            <button\n                className={Style.button}\n                onClick={handleLogin}\n                type=\"button\"\n            >\n                登录\n            </button>\n        </div>\n    );\n}\n\nexport default Login;\n"
  },
  {
    "path": "packages/web/src/modules/LoginAndRegister/LoginAndRegister.less",
    "content": ".login {\n    border-bottom: none;\n    width: 100%;\n\n    :global {\n        .rc-tabs-nav-wrap {\n            width: 166px;\n            margin: 0 auto;\n        }\n        .rc-tabs-tab {\n            font-size: 16px;\n        }\n    }\n}\n"
  },
  {
    "path": "packages/web/src/modules/LoginAndRegister/LoginAndRegister.tsx",
    "content": "import React from 'react';\nimport { useSelector } from 'react-redux';\n\nimport {\n    Tabs,\n    TabPane,\n    TabContent,\n    ScrollableInkTabBar,\n} from '../../components/Tabs';\nimport Style from './LoginAndRegister.less';\nimport Login from './Login';\nimport Register from './Register';\nimport Dialog from '../../components/Dialog';\nimport { State } from '../../state/reducer';\nimport useAction from '../../hooks/useAction';\n\nfunction LoginAndRegister() {\n    const action = useAction();\n    const loginRegisterDialogVisible = useSelector(\n        (state: State) => state.status.loginRegisterDialogVisible,\n    );\n\n    return (\n        <Dialog\n            visible={loginRegisterDialogVisible}\n            closable={false}\n            onClose={() => action.toggleLoginRegisterDialog(false)}\n        >\n            <Tabs\n                className={Style.login}\n                defaultActiveKey=\"login\"\n                renderTabBar={() => <ScrollableInkTabBar />}\n                renderTabContent={() => <TabContent />}\n            >\n                <TabPane tab=\"登录\" key=\"login\">\n                    <Login />\n                </TabPane>\n                <TabPane tab=\"注册\" key=\"register\">\n                    <Register />\n                </TabPane>\n            </Tabs>\n        </Dialog>\n    );\n}\n\nexport default LoginAndRegister;\n"
  },
  {
    "path": "packages/web/src/modules/LoginAndRegister/LoginRegister.less",
    "content": ".loginRegister {\n    height: 260px;\n    display: flex;\n    flex-direction: column;\n    padding: 0 30px;\n}\n\n.title {\n    font-size: 14px;\n        font-weight: normal;\n        line-height: 27px;\n        margin-top: 20px;\n        color: #666;\n}\n\n.input {\n    height: 40px;\n    & > input {\n        line-height: 40px;\n    }\n}\n\n.button {\n    background-color: var(--primary-color-10);\n    color: var(--primary-text-color-10);\n    height: 40px;\n    border: none;\n    margin-top: 30px;\n    transition: background-color 0.2s;\n    border-radius: 6px;\n\n    &:hover {\n        background-color: var(--primary-color-9);\n    }\n}"
  },
  {
    "path": "packages/web/src/modules/LoginAndRegister/Register.tsx",
    "content": "import React, { useState } from 'react';\nimport platform from 'platform';\nimport { useDispatch } from 'react-redux';\n\nimport getFriendId from '@fiora/utils/getFriendId';\nimport convertMessage from '@fiora/utils/convertMessage';\nimport Style from './LoginRegister.less';\nimport Input from '../../components/Input';\nimport useAction from '../../hooks/useAction';\nimport { register, getLinkmansLastMessagesV2 } from '../../service';\nimport { Message } from '../../state/reducer';\nimport { ActionTypes } from '../../state/action';\n\n/** 登录框 */\nfunction Register() {\n    const action = useAction();\n    const dispatch = useDispatch();\n    const [username, setUsername] = useState('');\n    const [password, setPassword] = useState('');\n\n    async function handleRegister() {\n        const user = await register(\n            username,\n            password,\n            platform.os?.family,\n            platform.name,\n            platform.description,\n        );\n        if (user) {\n            action.setUser(user);\n            action.toggleLoginRegisterDialog(false);\n            window.localStorage.setItem('token', user.token);\n\n            const linkmanIds = [\n                ...user.groups.map((group: any) => group._id),\n                ...user.friends.map((friend: any) =>\n                    getFriendId(friend.from, friend.to._id),\n                ),\n            ];\n            const linkmanMessages = await getLinkmansLastMessagesV2(linkmanIds);\n            Object.values(linkmanMessages).forEach(\n                // @ts-ignore\n                ({ messages }: { messages: Message[] }) => {\n                    messages.forEach(convertMessage);\n                },\n            );\n            dispatch({\n                type: ActionTypes.SetLinkmansLastMessages,\n                payload: linkmanMessages,\n            });\n        }\n    }\n\n    return (\n        <div className={Style.loginRegister}>\n            <h3 className={Style.title}>用户名</h3>\n            <Input\n                className={Style.input}\n                value={username}\n                onChange={setUsername}\n                onEnter={handleRegister}\n            />\n            <h3 className={Style.title}>密码</h3>\n            <Input\n                className={Style.input}\n                type=\"password\"\n                value={password}\n                onChange={setPassword}\n                onEnter={handleRegister}\n            />\n            <button\n                className={Style.button}\n                onClick={handleRegister}\n                type=\"button\"\n            >\n                注册\n            </button>\n        </div>\n    );\n}\n\nexport default Register;\n"
  },
  {
    "path": "packages/web/src/modules/Sidebar/About.less",
    "content": "@import \"../../styles/variable.less\";\n\n.about {\n    width: 600px !important;\n\n    @media @mobile {\n        width: 94vw !important;\n    }\n\n    :global {\n        p, li {\n            user-select: text;\n        }\n        a {\n            word-break: break-all;\n        }\n    }\n}\n"
  },
  {
    "path": "packages/web/src/modules/Sidebar/About.tsx",
    "content": "import React from 'react';\n\nimport Dialog from '../../components/Dialog';\nimport Style from './About.less';\nimport Common from './Common.less';\n\ninterface AboutProps {\n    visible: boolean;\n    onClose: () => void;\n}\n\nfunction About(props: AboutProps) {\n    const { visible, onClose } = props;\n    return (\n        <Dialog\n            className={Style.about}\n            visible={visible}\n            title=\"关于\"\n            onClose={onClose}\n        >\n            <div>\n                <div className={Common.block}>\n                    <p className={Common.title}>作者</p>\n                    <a\n                        href=\"https://suisuijiang.com\"\n                        target=\"_black\"\n                        rel=\"noopener noreferrer\"\n                    >\n                        https://suisuijiang.com\n                    </a>\n                </div>\n                <div className={Common.block}>\n                    <p className={Common.title}>如何搭建</p>\n                    <a\n                        href=\"https://yinxin630.github.io/fiora/zh-Hans/\"\n                        target=\"_black\"\n                        rel=\"noopener noreferrer\"\n                    >\n                        https://yinxin630.github.io/fiora/zh-Hans/\n                    </a>\n                </div>\n                <div className={Common.block}>\n                    <p className={Common.title}>隐私条款</p>\n                    <a\n                        href=\"/PrivacyPolicy.html\"\n                        target=\"_black\"\n                        rel=\"noopener noreferrer\"\n                    >\n                        {`${window.location.origin}/PrivacyPolicy.html`}\n                    </a>\n                </div>\n                <div className={Common.block}>\n                    <p className={Common.title}>将fiora安装到主屏(PWA)</p>\n                    <ul>\n                        <li>\n                            点击地址栏最右边三个点按钮(或者地址栏末尾收藏前的按钮)\n                        </li>\n                        <li>选择&quot;安装 fiora&quot;</li>\n                    </ul>\n                </div>\n                <div className={Common.block}>\n                    <p className={Common.title}>输入框快捷键</p>\n                    <ul>\n                        <li>Alt + S: 发送滑稽</li>\n                        <li>Alt + D: 发送表情</li>\n                    </ul>\n                </div>\n                <div className={Common.block}>\n                    <p className={Common.title}>命令消息</p>\n                    <ul>\n                        <li>-roll [number]: 掷点</li>\n                        <li>-rps: 石头剪刀布</li>\n                    </ul>\n                </div>\n                <div className={Common.block}>\n                    <p className={Common.title}>友情链接</p>\n                    <ul>\n                        <li>\n                            <a\n                                href=\"https://wangyaxing.cn/\"\n                                target=\"_black\"\n                                rel=\"noopener noreferrer\"\n                            >\n                                木子星兮\n                            </a>\n                        </li>\n                    </ul>\n                </div>\n            </div>\n        </Dialog>\n    );\n}\n\nexport default About;\n"
  },
  {
    "path": "packages/web/src/modules/Sidebar/Admin.less",
    "content": ".admin {\n    // height: 50% !important;\n}\n\n.inputBlock {\n    display: flex;\n}\n\n.input {\n    flex: 1;\n    height: 36px;\n}\n\n.tagUsernameInput {\n    flex: 3;\n}\n\n.tagInput {\n    flex: 2;\n    margin-left: 6px;\n}\n\n.button {\n    width: 100px;\n    height: 36px;\n    margin-left: 10px;\n}\n\n.sealList {\n    min-height: 22px;\n}\n\n.sealUsername {\n    margin-right: 10px;\n    line-height: 22px;\n    font-size: 14px;\n}"
  },
  {
    "path": "packages/web/src/modules/Sidebar/Admin.tsx",
    "content": "import React, { useEffect, useState } from 'react';\n\nimport { css } from 'linaria';\nimport Style from './Admin.less';\nimport Common from './Common.less';\nimport Dialog from '../../components/Dialog';\nimport Input from '../../components/Input';\nimport Button from '../../components/Button';\nimport Message from '../../components/Message';\nimport {\n    getSealList,\n    resetUserPassword,\n    sealUser,\n    setUserTag,\n    sealIp,\n    toggleSendMessage,\n    toggleNewUserSendMessage,\n    getSystemConfig,\n} from '../../service';\n\nconst styles = {\n    button: css`\n        min-width: 100px;\n        height: 36px;\n        margin-right: 12px;\n        padding: 0 10px;\n    `,\n};\n\ntype SystemConfig = {\n    disableSendMessage: boolean;\n    disableNewUserSendMessage: boolean;\n};\n\ninterface AdminProps {\n    visible: boolean;\n    onClose: () => void;\n}\n\nfunction Admin(props: AdminProps) {\n    const { visible, onClose } = props;\n\n    const [tagUsername, setTagUsername] = useState('');\n    const [tag, setTag] = useState('');\n    const [resetPasswordUsername, setResetPasswordUsername] = useState('');\n    const [sealUsername, setSealUsername] = useState('');\n    const [sealList, setSealList] = useState({ users: [], ips: [] });\n    const [sealIpAddress, setSealIpAddress] = useState('');\n    const [systemConfig, setSystemConfig] = useState<SystemConfig>();\n\n    async function handleGetSealList() {\n        const sealListRes = await getSealList();\n        if (sealListRes) {\n            setSealList(sealListRes);\n        }\n    }\n    async function handleGetSystemConfig() {\n        const systemConfigRes = await getSystemConfig();\n        if (systemConfigRes) {\n            setSystemConfig(systemConfigRes);\n        }\n    }\n    useEffect(() => {\n        if (visible) {\n            handleGetSystemConfig();\n            handleGetSealList();\n        }\n    }, [visible]);\n\n    /**\n     * 处理更新用户标签\n     */\n    async function handleSetTag() {\n        const isSuccess = await setUserTag(tagUsername, tag.trim());\n        if (isSuccess) {\n            Message.success('更新用户标签成功, 请刷新页面更新数据');\n            setTagUsername('');\n            setTag('');\n        }\n    }\n\n    /**\n     * 处理重置用户密码操作\n     */\n    async function handleResetPassword() {\n        const res = await resetUserPassword(resetPasswordUsername);\n        if (res) {\n            Message.success(`已将该用户的密码重置为:${res.newPassword}`);\n            setResetPasswordUsername('');\n        }\n    }\n    /**\n     * 处理封禁用户操作\n     */\n    async function handleSeal() {\n        const isSuccess = await sealUser(sealUsername);\n        if (isSuccess) {\n            Message.success('封禁用户成功');\n            setSealUsername('');\n            handleGetSealList();\n        }\n    }\n\n    async function handleSealIp() {\n        const isSuccess = await sealIp(sealIpAddress);\n        if (isSuccess) {\n            Message.success('封禁ip成功');\n            setSealIpAddress('');\n            handleGetSealList();\n        }\n    }\n\n    async function handleDisableSendMessage() {\n        const isSuccess = await toggleSendMessage(false);\n        if (isSuccess) {\n            Message.success('开启禁言成功');\n            handleGetSystemConfig();\n        }\n    }\n    async function handleEnableSendMessage() {\n        const isSuccess = await toggleSendMessage(true);\n        if (isSuccess) {\n            Message.success('关闭禁言成功');\n            handleGetSystemConfig();\n        }\n    }\n\n    async function handleDisableSNewUserendMessage() {\n        const isSuccess = await toggleNewUserSendMessage(false);\n        if (isSuccess) {\n            Message.success('开启新用户禁言成功');\n            handleGetSystemConfig();\n        }\n    }\n    async function handleEnableNewUserSendMessage() {\n        const isSuccess = await toggleNewUserSendMessage(true);\n        if (isSuccess) {\n            Message.success('关闭新用户禁言成功');\n            handleGetSystemConfig();\n        }\n    }\n\n    return (\n        <Dialog\n            className={Style.admin}\n            visible={visible}\n            title=\"管理员控制台\"\n            onClose={onClose}\n        >\n            <div className={Common.container}>\n                <div className={Common.block}>\n                    {!systemConfig?.disableSendMessage ? (\n                        <Button\n                            className={styles.button}\n                            type=\"danger\"\n                            onClick={handleDisableSendMessage}\n                        >\n                            开启禁言\n                        </Button>\n                    ) : (\n                        <Button\n                            className={styles.button}\n                            onClick={handleEnableSendMessage}\n                        >\n                            关闭禁言\n                        </Button>\n                    )}\n                    {!systemConfig?.disableNewUserSendMessage ? (\n                        <Button\n                            className={styles.button}\n                            type=\"danger\"\n                            onClick={handleDisableSNewUserendMessage}\n                        >\n                            开启新用户禁言\n                        </Button>\n                    ) : (\n                        <Button\n                            className={styles.button}\n                            onClick={handleEnableNewUserSendMessage}\n                        >\n                            关闭新用户禁言\n                        </Button>\n                    )}\n                </div>\n                <div className={Common.block}>\n                    <p className={Common.title}>更新用户标签</p>\n                    <div className={Style.inputBlock}>\n                        <Input\n                            className={`${Style.input} ${Style.tagUsernameInput}`}\n                            value={tagUsername}\n                            onChange={setTagUsername}\n                            placeholder=\"要更新标签的用户名\"\n                        />\n                        <Input\n                            className={`${Style.input} ${Style.tagInput}`}\n                            value={tag}\n                            onChange={setTag}\n                            placeholder=\"标签内容\"\n                        />\n                        <Button className={Style.button} onClick={handleSetTag}>\n                            确定\n                        </Button>\n                    </div>\n                </div>\n                <div className={Common.block}>\n                    <p className={Common.title}>重置用户密码</p>\n                    <div className={Style.inputBlock}>\n                        <Input\n                            className={Style.input}\n                            value={resetPasswordUsername}\n                            onChange={setResetPasswordUsername}\n                            placeholder=\"要重置密码的用户名\"\n                        />\n                        <Button\n                            className={Style.button}\n                            onClick={handleResetPassword}\n                        >\n                            确定\n                        </Button>\n                    </div>\n                </div>\n\n                <div className={Common.block}>\n                    <p className={Common.title}>封禁用户</p>\n                    <div className={Style.inputBlock}>\n                        <Input\n                            className={Style.input}\n                            value={sealUsername}\n                            onChange={setSealUsername}\n                            placeholder=\"要封禁的用户名\"\n                        />\n                        <Button className={Style.button} onClick={handleSeal}>\n                            确定\n                        </Button>\n                    </div>\n                </div>\n                <div className={Common.block}>\n                    <p className={Common.title}>封禁用户列表</p>\n                    <div className={Style.sealList}>\n                        {sealList.users.map((username) => (\n                            <span className={Style.sealUsername} key={username}>\n                                {username}\n                            </span>\n                        ))}\n                    </div>\n                </div>\n\n                <div className={Common.block}>\n                    <p className={Common.title}>封禁ip</p>\n                    <div className={Style.inputBlock}>\n                        <Input\n                            className={Style.input}\n                            value={sealIpAddress}\n                            onChange={setSealIpAddress}\n                            placeholder=\"要封禁的ip\"\n                        />\n                        <Button className={Style.button} onClick={handleSealIp}>\n                            确定\n                        </Button>\n                    </div>\n                </div>\n                <div className={Common.block}>\n                    <p className={Common.title}>封禁ip列表</p>\n                    <div className={Style.sealList}>\n                        {sealList.ips.map((ip) => (\n                            <span className={Style.sealUsername} key={ip}>\n                                {ip}\n                            </span>\n                        ))}\n                    </div>\n                </div>\n            </div>\n        </Dialog>\n    );\n}\n\nexport default Admin;\n"
  },
  {
    "path": "packages/web/src/modules/Sidebar/Common.less",
    "content": ".container {\n\n}\n\n.block {\n    margin-bottom: 14px;\n\n    a, p, li {\n        line-height: 22px;\n        font-size: 14px;\n    }\n    ul {\n        margin: 6px 0;\n        padding-left: 26px;\n    }\n}\n\n.title {\n    line-height: 33px;\n    font-size: 14px;\n    color: #333;\n    font-weight: bold;\n}"
  },
  {
    "path": "packages/web/src/modules/Sidebar/Download.less",
    "content": ".download {\n\n}\n\n.android {\n    p, a {\n        font-size: 14px;\n    }\n    img {\n        margin-top: 8px;\n    }\n}\n\n.ios {\n    p {\n        font-size: 14px;\n        line-height: 20px;\n    }\n    img {\n        width: 400px;\n        height: auto;\n        margin-top: 8px;\n    }\n}"
  },
  {
    "path": "packages/web/src/modules/Sidebar/Download.tsx",
    "content": "import React from 'react';\nimport QRCode from 'qrcode.react';\n\nimport Dialog from '../../components/Dialog';\nimport Style from './Download.less';\nimport Common from './Common.less';\n\ninterface DownloadProps {\n    visible: boolean;\n    onClose: () => void;\n}\n\nfunction Download(props: DownloadProps) {\n    const { visible, onClose } = props;\n    const androidDownloadUrl = `${window.location.origin}/fiora.apk`;\n    const iOSDownloadUrl = 'https://apps.apple.com/cn/app/fiora/id1554719127';\n\n    return (\n        <Dialog\n            className={Style.download}\n            visible={visible}\n            title=\"下载APP\"\n            onClose={onClose}\n        >\n            <div className={Common.container}>\n                <div className={Common.block}>\n                    <p className={Common.title}>Android</p>\n                    <div className={Style.android}>\n                        <p>\n                            链接:{' '}\n                            <a href={androidDownloadUrl}>\n                                {androidDownloadUrl}\n                            </a>\n                        </p>\n                        <QRCode value={androidDownloadUrl} size={200} />\n                    </div>\n                </div>\n                <div className={Common.block}>\n                    <p className={Common.title}>iOS</p>\n                    <div className={Style.ios}>\n                        <p>\n                            链接: <a href={iOSDownloadUrl}>{iOSDownloadUrl}</a>\n                        </p>\n                        <QRCode value={iOSDownloadUrl} size={200} />\n                    </div>\n                </div>\n            </div>\n        </Dialog>\n    );\n}\n\nexport default Download;\n"
  },
  {
    "path": "packages/web/src/modules/Sidebar/OnlineStatus.less",
    "content": ".onlineStatus {\n    width: 16px;\n    height: 16px;\n    background-color: var(--primary-color-8);\n    border-radius: 50%;\n}\n\n.status {\n    width: 12px;\n    height: 12px;\n    margin-top: 2px;\n    margin-left: 2px;\n    border-radius: 50%;\n}"
  },
  {
    "path": "packages/web/src/modules/Sidebar/OnlineStatus.tsx",
    "content": "import React from 'react';\n\nimport Style from './OnlineStatus.less';\n\ninterface OnlineStatusProps {\n    /** 状态, online / offline */\n    status: string;\n    className?: string;\n}\n\nfunction OnlineStatus(props: OnlineStatusProps) {\n    const { status, className } = props;\n\n    return (\n        <div className={`${Style.onlineStatus} ${className}`}>\n            <div className={`${Style.status} ${status}`} />\n        </div>\n    );\n}\n\nexport default OnlineStatus;\n"
  },
  {
    "path": "packages/web/src/modules/Sidebar/Reward.less",
    "content": "@import \"../../styles/variable.less\";\n\n.reward {\n    width: 700px !important;\n\n    @media @mobile {\n        width: 94vw !important;\n    }\n}\n\n.text {\n    color: #333;\n    text-align: center;\n    line-height: 34px;\n}\n\n.imageContainer {\n    display: flex;\n    align-items: center;\n    justify-content: space-around;\n    padding: 0 30px;\n    height: 375px;\n\n    @media @mobile {\n        flex-wrap: wrap;\n        & > img {\n            margin-bottom: 10px;\n        }\n    }\n}\n\n.image {\n    width: 250px;\n    height: auto;\n\n    @media @mobile {\n        margin-bottom: 10px;\n    }\n}\n"
  },
  {
    "path": "packages/web/src/modules/Sidebar/Reward.tsx",
    "content": "import React from 'react';\n\nimport AlipayImage from '@fiora/assets/images/alipay.png';\nimport WxpayImage from '@fiora/assets/images/wxpay.png';\nimport Dialog from '../../components/Dialog';\nimport Style from './Reward.less';\n\ninterface RewardProps {\n    visible: boolean;\n    onClose: () => void;\n}\n\nfunction Reward(props: RewardProps) {\n    const { visible, onClose } = props;\n    return (\n        <Dialog\n            className={Style.reward}\n            visible={visible}\n            title=\"打赏\"\n            onClose={onClose}\n        >\n            <div>\n                <p className={Style.text}>\n                    如果你觉得这个聊天室代码对你有帮助, 希望打赏下给个鼓励~~\n                    <br />\n                    作者大多数时间在线, 欢迎提问, 有问必答\n                </p>\n                <div className={Style.imageContainer}>\n                    <img\n                        className={Style.image}\n                        src={AlipayImage}\n                        alt=\"支付宝二维码\"\n                    />\n                    <img\n                        className={Style.image}\n                        src={WxpayImage}\n                        alt=\"微信二维码\"\n                    />\n                </div>\n            </div>\n        </Dialog>\n    );\n}\n\nexport default Reward;\n"
  },
  {
    "path": "packages/web/src/modules/Sidebar/SelfInfo.less",
    "content": ".selfInfo {\n    height: initial !important;\n    width: 500px;\n}\n\n.changeAvatar {\n    position: relative;\n    .blur {\n        filter: blur(2px);\n    }\n}\n\n.cropper {\n    & > div {\n        margin-bottom: 8px;\n    }\n    .loading {\n        top: 170px;\n        left: 170px;\n    }\n}\n\n.preview {\n    & > img {\n        width: 100px;\n        height: 100px;\n        cursor: pointer;\n\n        &:hover {\n            filter: blur(3px);\n        }\n    }\n    .loading {\n        top: 10px;\n        left: 10px;\n    }\n}\n\n.loading {\n    position: absolute;\n    pointer-events: none;\n}\n\n.input {\n    height: 36px;\n    margin-bottom: 8px;\n}\n\n.button {\n    width: 100px;\n    height: 36px;\n}"
  },
  {
    "path": "packages/web/src/modules/Sidebar/SelfInfo.tsx",
    "content": "import React, { useState, useRef } from 'react';\nimport ReactLoading from 'react-loading';\nimport Cropper from 'react-cropper';\nimport 'cropperjs/dist/cropper.css';\n\nimport { useSelector } from 'react-redux';\nimport config from '@fiora/config/client';\nimport readDiskFile from '../../utils/readDiskFile';\nimport uploadFile, { getOSSFileUrl } from '../../utils/uploadFile';\nimport Dialog from '../../components/Dialog';\nimport Button from '../../components/Button';\nimport Input from '../../components/Input';\nimport { State } from '../../state/reducer';\nimport Message from '../../components/Message';\nimport { changeAvatar, changePassword, changeUsername } from '../../service';\nimport useAction from '../../hooks/useAction';\nimport socket from '../../socket';\n\nimport Style from './SelfInfo.less';\nimport Common from './Common.less';\n\ninterface SelfInfoProps {\n    visible: boolean;\n    onClose: () => void;\n}\n\nfunction SelfInfo(props: SelfInfoProps) {\n    const { visible, onClose } = props;\n\n    const action = useAction();\n    const userId = useSelector((state: State) => state.user?._id);\n    const avatar = useSelector((state: State) => state.user?.avatar);\n    const primaryColor = useSelector(\n        (state: State) => state.status.primaryColor,\n    );\n    const [loading, toggleLoading] = useState(false);\n    const [cropper, setCropper] = useState({\n        enable: false,\n        src: '',\n        ext: '',\n    });\n    const $cropper = useRef(null);\n\n    async function uploadAvatar(blob: Blob, ext = 'png') {\n        toggleLoading(true);\n\n        try {\n            const avatarUrl = await uploadFile(\n                blob,\n                `Avatar/${userId}_${Date.now()}.${ext}`,\n            );\n            const isSuccess = await changeAvatar(avatarUrl);\n            if (isSuccess) {\n                action.setAvatar(URL.createObjectURL(blob));\n                Message.success('修改头像成功');\n            }\n        } catch (err) {\n            console.error(err);\n            Message.error('上传头像失败');\n        } finally {\n            toggleLoading(false);\n            setCropper({ enable: false, src: '', ext: '' });\n        }\n    }\n\n    async function selectAvatar() {\n        const file = await readDiskFile(\n            'blob',\n            'image/png,image/jpeg,image/gif',\n        );\n        if (!file) {\n            return;\n        }\n        if (file.length > config.maxAvatarSize) {\n            // eslint-disable-next-line consistent-return\n            return Message.error('设置头像失败, 请选择小于1.5MB的图片');\n        }\n\n        // gif头像不需要裁剪\n        if (file.ext === 'gif') {\n            uploadAvatar(file.result as Blob, file.ext);\n        } else {\n            // 显示头像裁剪\n            const reader = new FileReader();\n            reader.readAsDataURL(file.result as Blob);\n            reader.onloadend = () => {\n                setCropper({\n                    enable: true,\n                    src: reader.result as string,\n                    ext: file.ext,\n                });\n            };\n        }\n    }\n\n    function handleChangeAvatar() {\n        // @ts-ignore\n        $cropper.current.getCroppedCanvas().toBlob(async (blob: any) => {\n            uploadAvatar(blob, cropper.ext);\n        });\n    }\n\n    function reLogin(message: string) {\n        action.logout();\n        window.localStorage.removeItem('token');\n        Message.success(message);\n        socket.disconnect();\n        socket.connect();\n    }\n\n    const [oldPassword, setOldPassword] = useState('');\n    const [newPassword, setNewPassword] = useState('');\n\n    async function handleChangePassword() {\n        const isSuccess = await changePassword(oldPassword, newPassword);\n        if (isSuccess) {\n            onClose();\n            reLogin('修改密码成功, 请使用新密码重新登录');\n        }\n    }\n\n    const [username, setUsername] = useState('');\n\n    /**\n     * 修改用户名\n     */\n    async function handleChangeUsername() {\n        const isSuccess = await changeUsername(username);\n        if (isSuccess) {\n            onClose();\n            reLogin('修改用户名成功, 请使用新用户名重新登录');\n        }\n    }\n\n    function handleCloseDialog(event: any) {\n        /**\n         * 点击关闭按钮, 或者在非图片裁剪时点击蒙层, 才能关闭弹窗\n         */\n        if (event.target.className === 'rc-dialog-close-x' || !cropper.enable) {\n            onClose();\n        }\n    }\n\n    return (\n        <Dialog\n            className={Style.selfInfo}\n            visible={visible}\n            title=\"个人信息设置\"\n            onClose={handleCloseDialog}\n        >\n            <div className={Common.container}>\n                <div className={Common.block}>\n                    <p className={Common.title}>修改头像</p>\n                    <div className={Style.changeAvatar}>\n                        {cropper.enable ? (\n                            <div className={Style.cropper}>\n                                <Cropper\n                                    className={loading ? 'blur' : ''}\n                                    // @ts-ignore\n                                    ref={$cropper}\n                                    src={cropper.src}\n                                    style={{\n                                        width: 0,\n                                        height: 0,\n                                        paddingBottom: '50%',\n                                    }}\n                                    aspectRatio={1}\n                                />\n                                <Button\n                                    className={Style.button}\n                                    onClick={handleChangeAvatar}\n                                >\n                                    修改头像\n                                </Button>\n                                <ReactLoading\n                                    className={`${Style.loading} ${\n                                        loading ? 'show' : 'hide'\n                                    }`}\n                                    type=\"spinningBubbles\"\n                                    color={`rgb(${primaryColor}`}\n                                    height={120}\n                                    width={120}\n                                />\n                            </div>\n                        ) : (\n                            <div className={Style.preview}>\n                                <img\n                                    className={loading ? 'blur' : ''}\n                                    alt=\"头像预览\"\n                                    src={getOSSFileUrl(avatar as string)}\n                                    onClick={selectAvatar}\n                                />\n                                <ReactLoading\n                                    className={`${Style.loading} ${\n                                        loading ? 'show' : 'hide'\n                                    }`}\n                                    type=\"spinningBubbles\"\n                                    color={`rgb(${primaryColor}`}\n                                    height={80}\n                                    width={80}\n                                />\n                            </div>\n                        )}\n                    </div>\n                </div>\n                <div className={Common.block}>\n                    <p className={Common.title}>修改密码</p>\n                    <div>\n                        <Input\n                            className={Style.input}\n                            value={oldPassword}\n                            onChange={setOldPassword}\n                            type=\"password\"\n                            placeholder=\"旧密码\"\n                        />\n                        <Input\n                            className={Style.input}\n                            value={newPassword}\n                            onChange={setNewPassword}\n                            type=\"password\"\n                            placeholder=\"新密码\"\n                        />\n                        <Button\n                            className={Style.button}\n                            onClick={handleChangePassword}\n                        >\n                            确认修改\n                        </Button>\n                    </div>\n                </div>\n                <div className={Common.block}>\n                    <p className={Common.title}>修改用户名</p>\n                    <div>\n                        <Input\n                            className={Style.input}\n                            value={username}\n                            onChange={setUsername}\n                            type=\"text\"\n                            placeholder=\"用户名\"\n                        />\n                        <Button\n                            className={Style.button}\n                            onClick={handleChangeUsername}\n                        >\n                            确认修改\n                        </Button>\n                    </div>\n                </div>\n            </div>\n        </Dialog>\n    );\n}\n\nexport default SelfInfo;\n"
  },
  {
    "path": "packages/web/src/modules/Sidebar/Setting.less",
    "content": ".setting {\n    :global {\n        .rc-dialog-body {\n            overflow-y: hidden;\n        }\n        .rc-tabs-top {\n            border-bottom: none;\n        }\n    }\n}\n\n.switchContainer {\n    margin-bottom: 4px;\n    display: flex;\n    flex-wrap: wrap;\n}\n\n.button {\n    margin-right: 10px;\n    width: 130px;\n    height: 34px;\n}\n\n.switch {\n    display: flex;\n    align-items: center;\n    margin-right: 10px;\n    margin-bottom: 8px;\n}\n\n.switchText {\n    color: #444;\n    margin-right: 8px;\n    font-size: 14px;\n}\n\n.radioGroup {\n    display: flex !important;\n    flex-wrap: wrap;\n    justify-content: flex-start;\n\n    & > div {\n        width: 120px !important;\n        flex: initial !important;\n        padding: 12px !important;\n        margin-bottom: 6px !important;\n        & > div {\n            display: flex;\n            align-items: center;\n        }\n        & > div > div {\n            padding: 0 !important;\n            font-size: 14px;\n        }\n        & > div > div > div {\n            transform: translate(-2px, -2px);\n        }\n    }\n}\n\n.backgroundTip {\n    font-size: 12px;\n    color: #777;\n}\n\n.backgroundImageContainer {\n    text-align: center;\n    position: relative;\n}\n\n.backgroundImage {\n    width: 98%;\n    height: auto;\n    cursor: pointer;\n\n    &:hover {\n        filter: blur(3px);\n    }\n\n    &.blur {\n        filter: blur(3px);\n    }\n}\n\n.backgroundImageLoading {\n    position: absolute;\n    top: 70px;\n    left: 175px;\n    pointer-events: none;\n}\n\n.colorInfo {\n    display: flex;\n    align-items: center;\n\n    & > div {\n        width: 30px;\n        height: 30px;\n        border-radius: 4px;\n        margin-left: 6px;\n    }\n    & > span {\n        margin-left: 12px;\n        color: #666;\n    }\n}\n\n.colorPicker {\n    margin-top: 20px;\n}\n\n.TagModeRadioGroup {\n    & > div {\n        width: 130px !important;\n        flex: initial !important;\n        padding: 12px !important;\n        margin-bottom: 6px !important;\n        & > div {\n            display: flex;\n            align-items: center;\n        }\n        & > div > div {\n            padding: 0 !important;\n            font-size: 14px;\n        }\n        & > div > div > div {\n            transform: translate(-2px, -2px);\n        }\n    }\n}\n\n.scrollContainer {\n    margin-top: 20px;\n    overflow-y: auto;\n    -webkit-overflow-scrolling: touch;\n    max-height: 52vh;\n}"
  },
  {
    "path": "packages/web/src/modules/Sidebar/Setting.tsx",
    "content": "import React, { useState } from 'react';\nimport { useSelector } from 'react-redux';\nimport Switch from 'react-switch';\nimport { RadioGroup, RadioButton } from 'react-radio-buttons';\nimport ReactLoading from 'react-loading';\nimport { TwitterPicker } from 'react-color';\n\nimport setCssVariable from '../../utils/setCssVariable';\nimport readDiskFile from '../../utils/readDiskFile';\nimport uploadFile, { getOSSFileUrl } from '../../utils/uploadFile';\nimport playSound from '../../utils/playSound';\nimport Dialog from '../../components/Dialog';\nimport config from '../../../../config/client';\nimport Message from '../../components/Message';\nimport useAction from '../../hooks/useAction';\nimport { State } from '../../state/reducer';\n\nimport Style from './Setting.less';\nimport Common from './Common.less';\nimport {\n    Tabs,\n    TabPane,\n    ScrollableInkTabBar,\n    TabContent,\n} from '../../components/Tabs';\nimport { LocalStorageKey } from '../../localStorage';\nimport themes from '../../themes';\n\ninterface SettingProps {\n    visible: boolean;\n    onClose: () => void;\n}\n\ntype Color = {\n    rgb: {\n        r: number;\n        g: number;\n        b: number;\n    };\n};\n\nfunction Setting(props: SettingProps) {\n    const { visible, onClose } = props;\n\n    const action = useAction();\n    const soundSwitch = useSelector((state: State) => state.status.soundSwitch);\n    const notificationSwitch = useSelector(\n        (state: State) => state.status.notificationSwitch,\n    );\n    const voiceSwitch = useSelector((state: State) => state.status.voiceSwitch);\n    const selfVoiceSwitch = useSelector(\n        (state: State) => state.status.selfVoiceSwitch,\n    );\n    const sound = useSelector((state: State) => state.status.sound);\n    const theme = useSelector((state: State) => state.status.theme);\n    const primaryColor = useSelector(\n        (state: State) => state.status.primaryColor,\n    );\n    const primaryTextColor = useSelector(\n        (state: State) => state.status.primaryTextColor,\n    );\n    const backgroundImage = useSelector(\n        (state: State) => state.status.backgroundImage,\n    );\n    const aero = useSelector((state: State) => state.status.aero);\n    const userId = useSelector((state: State) => state.user?._id);\n    const tagColorMode = useSelector(\n        (state: State) => state.status.tagColorMode,\n    );\n    const enableSearchExpression = useSelector(\n        (state: State) => state.status.enableSearchExpression,\n    );\n\n    const [backgroundLoading, toggleBackgroundLoading] = useState(false);\n\n    function setTheme(themeName: string) {\n        action.setStatus('theme', themeName);\n        // @ts-ignore\n        const themeConfig = themes[themeName];\n        if (themeConfig) {\n            action.setStatus('primaryColor', themeConfig.primaryColor);\n            action.setStatus('primaryTextColor', themeConfig.primaryTextColor);\n            action.setStatus('backgroundImage', themeConfig.backgroundImage);\n            action.setStatus('aero', themeConfig.aero);\n            setCssVariable(\n                themeConfig.primaryColor,\n                themeConfig.primaryTextColor,\n            );\n            window.localStorage.removeItem(LocalStorageKey.PrimaryColor);\n            window.localStorage.removeItem(LocalStorageKey.PrimaryTextColor);\n            window.localStorage.removeItem(LocalStorageKey.BackgroundImage);\n            window.localStorage.removeItem(LocalStorageKey.Aero);\n            Message.success('已修改主题');\n        } else {\n            window.localStorage.setItem(\n                LocalStorageKey.PrimaryColor,\n                primaryColor,\n            );\n            window.localStorage.setItem(\n                LocalStorageKey.PrimaryTextColor,\n                primaryTextColor,\n            );\n            window.localStorage.setItem(\n                LocalStorageKey.BackgroundImage,\n                backgroundImage,\n            );\n            window.localStorage.setItem(LocalStorageKey.Aero, aero.toString());\n        }\n    }\n\n    function handleSelectSound(newSound: string) {\n        playSound(newSound);\n        action.setStatus('sound', newSound);\n    }\n\n    async function selectBackgroundImage() {\n        toggleBackgroundLoading(true);\n        try {\n            const image = await readDiskFile(\n                'blob',\n                'image/png,image/jpeg,image/gif',\n            );\n            if (!image) {\n                return;\n            }\n            if (image.length > config.maxBackgroundImageSize) {\n                // eslint-disable-next-line consistent-return\n                return Message.error('设置背景图失败, 请选择小于3MB的图片');\n            }\n            const imageUrl = await uploadFile(\n                image.result as Blob,\n                `BackgroundImage/${userId}_${Date.now()}.${image.ext}`,\n            );\n            action.setStatus('backgroundImage', imageUrl);\n        } finally {\n            toggleBackgroundLoading(false);\n        }\n    }\n\n    function handlePrimaryColorChange(color: Color) {\n        const newPrimaryColor = `${color.rgb.r}, ${color.rgb.g}, ${color.rgb.b}`;\n        action.setStatus('primaryColor', newPrimaryColor);\n        setCssVariable(newPrimaryColor, primaryTextColor);\n    }\n\n    function handlePrimaryTextColorChange(color: Color) {\n        const mewPrimaryTextColor = `${color.rgb.r}, ${color.rgb.g}, ${color.rgb.b}`;\n        action.setStatus('primaryTextColor', mewPrimaryTextColor);\n        setCssVariable(primaryColor, mewPrimaryTextColor);\n    }\n\n    return (\n        <Dialog\n            className={`dialog ${Style.setting}`}\n            visible={visible}\n            onClose={onClose}\n        >\n            <Tabs\n                defaultActiveKey=\"default\"\n                renderTabBar={() => <ScrollableInkTabBar />}\n                renderTabContent={() => <TabContent />}\n            >\n                <TabPane tab=\"功能\" key=\"function\">\n                    <div\n                        className={`${Common.container} ${Style.scrollContainer}`}\n                    >\n                        <div className={Common.block}>\n                            <p className={Common.title}>开关</p>\n                            <div className={Style.switchContainer}>\n                                <div className={Style.switch}>\n                                    <p className={Style.switchText}>声音提醒</p>\n                                    <Switch\n                                        onChange={(value) =>\n                                            action.setStatus(\n                                                'soundSwitch',\n                                                value,\n                                            )\n                                        }\n                                        checked={soundSwitch}\n                                    />\n                                </div>\n                                <div className={Style.switch}>\n                                    <p className={Style.switchText}>桌面提醒</p>\n                                    <Switch\n                                        onChange={(value) =>\n                                            action.setStatus(\n                                                'notificationSwitch',\n                                                value,\n                                            )\n                                        }\n                                        checked={notificationSwitch}\n                                    />\n                                </div>\n                                <div className={Style.switch}>\n                                    <p className={Style.switchText}>语音播报</p>\n                                    <Switch\n                                        onChange={(value) =>\n                                            action.setStatus(\n                                                'voiceSwitch',\n                                                value,\n                                            )\n                                        }\n                                        checked={voiceSwitch}\n                                    />\n                                </div>\n                                <div className={Style.switch}>\n                                    <p className={Style.switchText}>\n                                        播报自己消息\n                                    </p>\n                                    <Switch\n                                        onChange={(value) =>\n                                            action.setStatus(\n                                                'selfVoiceSwitch',\n                                                value,\n                                            )\n                                        }\n                                        checked={selfVoiceSwitch}\n                                    />\n                                </div>\n                                <div className={Style.switch}>\n                                    <p className={Style.switchText}>\n                                        根据输入内容推荐表情\n                                    </p>\n                                    <Switch\n                                        onChange={(value) =>\n                                            action.setStatus(\n                                                'enableSearchExpression',\n                                                value,\n                                            )\n                                        }\n                                        checked={enableSearchExpression}\n                                    />\n                                </div>\n                            </div>\n                        </div>\n                        <div className={Common.block}>\n                            <p className={Common.title}>提示音</p>\n                            <div>\n                                <RadioGroup\n                                    className={Style.radioGroup}\n                                    value={sound}\n                                    onChange={handleSelectSound}\n                                    horizontal\n                                >\n                                    <RadioButton value=\"default\">\n                                        默认\n                                    </RadioButton>\n                                    <RadioButton value=\"apple\">\n                                        苹果\n                                    </RadioButton>\n                                    <RadioButton value=\"pcqq\">\n                                        电脑QQ\n                                    </RadioButton>\n                                    <RadioButton value=\"mobileqq\">\n                                        手机QQ\n                                    </RadioButton>\n                                    <RadioButton value=\"momo\">陌陌</RadioButton>\n                                    <RadioButton value=\"huaji\">\n                                        滑稽\n                                    </RadioButton>\n                                </RadioGroup>\n                            </div>\n                        </div>\n                        <div className={Common.block}>\n                            <p className={Common.title}>标签颜色</p>\n                            <div>\n                                <RadioGroup\n                                    className={Style.TagModeRadioGroup}\n                                    value={tagColorMode}\n                                    onChange={(newValue: string) =>\n                                        action.setStatus(\n                                            'tagColorMode',\n                                            newValue,\n                                        )\n                                    }\n                                    horizontal\n                                >\n                                    <RadioButton value=\"singleColor\">\n                                        单一颜色\n                                    </RadioButton>\n                                    <RadioButton value=\"fixedColor\">\n                                        固定颜色\n                                    </RadioButton>\n                                    <RadioButton value=\"randomColor\">\n                                        随机颜色\n                                    </RadioButton>\n                                </RadioGroup>\n                            </div>\n                        </div>\n                    </div>\n                </TabPane>\n                <TabPane tab=\"主题\" key=\"theme\">\n                    <div\n                        className={`${Common.container} ${Style.scrollContainer}`}\n                    >\n                        <div className={Common.block}>\n                            <div>\n                                <RadioGroup\n                                    className={Style.TagModeRadioGroup}\n                                    value={theme}\n                                    onChange={(newValue: string) =>\n                                        setTheme(newValue)\n                                    }\n                                    horizontal\n                                >\n                                    <RadioButton value=\"default\">\n                                        默认\n                                    </RadioButton>\n                                    <RadioButton value=\"cool\">清爽</RadioButton>\n                                    <RadioButton value=\"custom\">\n                                        自定义\n                                    </RadioButton>\n                                </RadioGroup>\n                            </div>\n                        </div>\n                        {theme === 'custom' && (\n                            <>\n                                <div className={Common.block}>\n                                    <p className={Common.title}>毛玻璃效果</p>\n                                    <div>\n                                        <Switch\n                                            onChange={(value) =>\n                                                action.setStatus('aero', value)\n                                            }\n                                            checked={aero}\n                                        />\n                                    </div>\n                                </div>\n                                <div className={Common.block}>\n                                    <p className={Common.title}>\n                                        背景图{' '}\n                                        <span className={Style.backgroundTip}>\n                                            背景图会被拉伸到浏览器窗口大小,\n                                            合理的比例会取得更好的效果\n                                        </span>\n                                    </p>\n                                    <div\n                                        className={\n                                            Style.backgroundImageContainer\n                                        }\n                                    >\n                                        <img\n                                            className={`${\n                                                Style.backgroundImage\n                                            } ${\n                                                backgroundLoading ? 'blur' : ''\n                                            }`}\n                                            src={getOSSFileUrl(backgroundImage)}\n                                            alt=\"背景图预览\"\n                                            onClick={selectBackgroundImage}\n                                        />\n                                        <ReactLoading\n                                            className={`${\n                                                Style.backgroundImageLoading\n                                            } ${\n                                                backgroundLoading\n                                                    ? 'show'\n                                                    : 'hide'\n                                            }`}\n                                            type=\"spinningBubbles\"\n                                            color={`rgb(${primaryColor}`}\n                                            height={100}\n                                            width={100}\n                                        />\n                                    </div>\n                                </div>\n                                {TwitterPicker && (\n                                    <div className={Common.block}>\n                                        <p className={Common.title}>主题颜色</p>\n                                        <div className={Style.colorInfo}>\n                                            <div\n                                                style={{\n                                                    backgroundColor: `rgb(${primaryColor})`,\n                                                }}\n                                            />\n                                            <span>{`rgb(${primaryColor})`}</span>\n                                        </div>\n                                        <TwitterPicker\n                                            // @ts-ignore\n                                            className={Style.colorPicker}\n                                            color={`rgb(${primaryColor})`}\n                                            onChange={handlePrimaryColorChange}\n                                        />\n                                    </div>\n                                )}\n                                {TwitterPicker && (\n                                    <div className={Common.block}>\n                                        <p className={Common.title}>文字颜色</p>\n                                        <div className={Style.colorInfo}>\n                                            <div\n                                                style={{\n                                                    backgroundColor: `rgb(${primaryTextColor})`,\n                                                }}\n                                            />\n                                            <span>{`rgb(${primaryTextColor})`}</span>\n                                        </div>\n                                        <TwitterPicker\n                                            // @ts-ignore\n                                            className={Style.colorPicker}\n                                            color={`rgb(${primaryTextColor})`}\n                                            onChange={\n                                                handlePrimaryTextColorChange\n                                            }\n                                        />\n                                    </div>\n                                )}\n                            </>\n                        )}\n                    </div>\n                </TabPane>\n            </Tabs>\n        </Dialog>\n    );\n}\n\nexport default Setting;\n"
  },
  {
    "path": "packages/web/src/modules/Sidebar/Sidebar.less",
    "content": "@import '../../styles/variable.less';\n\n.sidebar {\n    width: 80px;\n    min-width: min-content;\n    background-color: var(--primary-color-8);\n    display: flex;\n    flex-direction: column;\n    align-items: center;\n    position: relative;\n    border-top-left-radius: 10px;\n    border-bottom-left-radius: 10px;\n\n    &[data-aero=true] {\n        background-color: var(--primary-color-0);\n    }\n\n    @media @mobile {\n        width: 60px;\n        padding: 0 3px;\n        border-top-left-radius: 0;\n        border-bottom-left-radius: 0;\n    }\n}\n\n.avatar {\n    margin-top: 50px;\n    cursor: pointer;\n}\n\n.status {\n    position: absolute;\n    top: 96px;\n    left: 48px;\n}\n\n.tabs {\n    margin-top: 50px;\n}\n\n.buttons {\n    position: absolute;\n    bottom: 40px;\n    display: flex;\n    flex-direction: column;\n    align-items: center;\n\n    :global {\n        .iconfont {\n            color: var(--primary-text-color-7);\n            transition: color 0.2s;\n        }\n    }\n\n    & > div, .linkButton {\n        margin-top: 10px;\n        &:hover {\n            .iconfont {\n                color: var(--primary-text-color-10);\n            }\n        }\n    }\n}\n\n.linkButton {\n    text-decoration: none;\n}\n"
  },
  {
    "path": "packages/web/src/modules/Sidebar/Sidebar.tsx",
    "content": "import React, { useState } from 'react';\nimport { useSelector } from 'react-redux';\nimport loadable from '@loadable/component';\n\nimport { isMobile } from '@fiora/utils/ua';\nimport { State } from '../../state/reducer';\nimport useIsLogin from '../../hooks/useIsLogin';\nimport Avatar from '../../components/Avatar';\nimport Tooltip from '../../components/Tooltip';\nimport IconButton from '../../components/IconButton';\nimport OnlineStatus from './OnlineStatus';\nimport useAction from '../../hooks/useAction';\nimport socket from '../../socket';\nimport Message from '../../components/Message';\n\nimport Admin from './Admin';\nimport Download from './Download';\nimport Reward from './Reward';\nimport About from './About';\n\nimport Style from './Sidebar.less';\nimport useAero from '../../hooks/useAero';\n\nconst SelfInfoAsync = loadable(\n    () =>\n        // @ts-ignore\n        import(/* webpackChunkName: \"self-info\" */ './SelfInfo'),\n);\nconst SettingAsync = loadable(\n    // @ts-ignore\n    () => import(/* webpackChunkName: \"setting\" */ './Setting'),\n);\n\nfunction Sidebar() {\n    const sidebarVisible = useSelector(\n        (state: State) => state.status.sidebarVisible,\n    );\n    const action = useAction();\n    const isLogin = useIsLogin();\n    const isConnect = useSelector((state: State) => state.connect);\n    const isAdmin = useSelector(\n        (state: State) => state.user && state.user.isAdmin,\n    );\n    const avatar = useSelector(\n        (state: State) => state.user && state.user.avatar,\n    );\n\n    const [selfInfoDialogVisible, toggleSelfInfoDialogVisible] =\n        useState(false);\n    const [adminDialogVisible, toggleAdminDialogVisible] = useState(false);\n    const [downloadDialogVisible, toggleDownloadDialogVisible] =\n        useState(false);\n    const [rewardDialogVisible, toggleRewardDialogVisible] = useState(false);\n    const [aboutDialogVisible, toggleAboutDialogVisible] = useState(false);\n    const [settingDialogVisible, toggleSettingDialogVisible] = useState(false);\n    const aero = useAero();\n\n    if (!sidebarVisible) {\n        return null;\n    }\n\n    function logout() {\n        action.logout();\n        window.localStorage.removeItem('token');\n        Message.success('您已经退出登录');\n        socket.disconnect();\n        socket.connect();\n    }\n\n    function renderTooltip(text: string, component: JSX.Element) {\n        const children = <div>{component}</div>;\n        if (isMobile) {\n            return children;\n        }\n        return (\n            <Tooltip\n                placement=\"right\"\n                mouseEnterDelay={0.3}\n                overlay={<span>{text}</span>}\n            >\n                {children}\n            </Tooltip>\n        );\n    }\n\n    return (\n        <>\n            <div className={Style.sidebar} {...aero}>\n                {isLogin && avatar && (\n                    <Avatar\n                        className={Style.avatar}\n                        src={avatar}\n                        onClick={() => toggleSelfInfoDialogVisible(true)}\n                    />\n                )}\n                {isLogin && (\n                    <OnlineStatus\n                        className={Style.status}\n                        status={isConnect ? 'online' : 'offline'}\n                    />\n                )}\n                <div className={Style.buttons}>\n                    {isLogin &&\n                        isAdmin &&\n                        renderTooltip(\n                            '管理员',\n                            <IconButton\n                                width={40}\n                                height={40}\n                                icon=\"administrator\"\n                                iconSize={28}\n                                onClick={() => toggleAdminDialogVisible(true)}\n                            />,\n                        )}\n                    <Tooltip\n                        placement=\"right\"\n                        mouseEnterDelay={0.3}\n                        overlay={<span>源码</span>}\n                    >\n                        <a\n                            className={Style.linkButton}\n                            href=\"https://github.com/yinxin630/fiora\"\n                            target=\"_black\"\n                            rel=\"noopener noreferrer\"\n                        >\n                            <IconButton\n                                width={40}\n                                height={40}\n                                icon=\"github\"\n                                iconSize={26}\n                            />\n                        </a>\n                    </Tooltip>\n                    {renderTooltip(\n                        '下载APP',\n                        <IconButton\n                            width={40}\n                            height={40}\n                            icon=\"app\"\n                            iconSize={28}\n                            onClick={() => toggleDownloadDialogVisible(true)}\n                        />,\n                    )}\n                    {renderTooltip(\n                        '打赏',\n                        <IconButton\n                            width={40}\n                            height={40}\n                            icon=\"dashang\"\n                            iconSize={26}\n                            onClick={() => toggleRewardDialogVisible(true)}\n                        />,\n                    )}\n                    {renderTooltip(\n                        '关于',\n                        <IconButton\n                            width={40}\n                            height={40}\n                            icon=\"about\"\n                            iconSize={26}\n                            onClick={() => toggleAboutDialogVisible(true)}\n                        />,\n                    )}\n                    {isLogin &&\n                        renderTooltip(\n                            '设置',\n                            <IconButton\n                                width={40}\n                                height={40}\n                                icon=\"setting\"\n                                iconSize={26}\n                                onClick={() => toggleSettingDialogVisible(true)}\n                            />,\n                        )}\n                    {isLogin &&\n                        renderTooltip(\n                            '退出登录',\n                            <IconButton\n                                width={40}\n                                height={40}\n                                icon=\"logout\"\n                                iconSize={26}\n                                onClick={logout}\n                            />,\n                        )}\n                </div>\n\n                {/* 弹窗 */}\n                {isLogin && selfInfoDialogVisible && (\n                    <SelfInfoAsync\n                        visible={selfInfoDialogVisible}\n                        onClose={() => toggleSelfInfoDialogVisible(false)}\n                    />\n                )}\n                {isLogin && isAdmin && (\n                    <Admin\n                        visible={adminDialogVisible}\n                        onClose={() => toggleAdminDialogVisible(false)}\n                    />\n                )}\n                <Download\n                    visible={downloadDialogVisible}\n                    onClose={() => toggleDownloadDialogVisible(false)}\n                />\n                <Reward\n                    visible={rewardDialogVisible}\n                    onClose={() => toggleRewardDialogVisible(false)}\n                />\n                <About\n                    visible={aboutDialogVisible}\n                    onClose={() => toggleAboutDialogVisible(false)}\n                />\n                {isLogin && settingDialogVisible && (\n                    <SettingAsync\n                        visible={settingDialogVisible}\n                        onClose={() => toggleSettingDialogVisible(false)}\n                    />\n                )}\n            </div>\n        </>\n    );\n}\n\nexport default Sidebar;\n"
  },
  {
    "path": "packages/web/src/modules/UserInfo.tsx",
    "content": "import React, { useState, useEffect } from 'react';\nimport { useSelector } from 'react-redux';\n\nimport getFriendId from '@fiora/utils/getFriendId';\nimport { getOSSFileUrl } from '../utils/uploadFile';\nimport Style from './InfoDialog.less';\nimport Dialog from '../components/Dialog';\nimport Avatar from '../components/Avatar';\nimport Button from '../components/Button';\nimport Message from '../components/Message';\nimport { State, Linkman } from '../state/reducer';\nimport useAction from '../hooks/useAction';\nimport {\n    addFriend,\n    getLinkmanHistoryMessages,\n    deleteFriend,\n    sealUser,\n    getUserIps,\n    sealUserOnlineIp,\n} from '../service';\n\ninterface UserInfoProps {\n    visible: boolean;\n    user?: {\n        _id: string;\n        username: string;\n        avatar: string;\n        ip: string;\n        isOnline?: string;\n    };\n    onClose: () => void;\n}\n\nfunction UserInfo(props: UserInfoProps) {\n    const { visible, onClose, user } = props;\n\n    const action = useAction();\n\n    const selfId =\n        useSelector((state: State) => state.user && state.user._id) || '';\n    // 获取好友id\n    if (user && user._id.length === selfId.length) {\n        user._id = getFriendId(selfId, user._id);\n    }\n    /** 获取原始用户id */\n    const originUserId = user && user._id.replace(selfId, '');\n\n    // @ts-ignore\n    const linkman = useSelector((state: State) => state.linkmans[user?._id]);\n    const isFriend = linkman && linkman.type === 'friend';\n    const isAdmin = useSelector(\n        (state: State) => state.user && state.user.isAdmin,\n    );\n    const [largerAvatar, toggleLargetAvatar] = useState(false);\n\n    const [userIps, setUserIps] = useState([]);\n\n    useEffect(() => {\n        if (isAdmin && user && user._id) {\n            (async () => {\n                const ips = await getUserIps(user._id.replace(selfId, ''));\n                setUserIps(ips);\n            })();\n        }\n    }, [isAdmin, selfId, user]);\n\n    if (!user) {\n        return null;\n    }\n\n    function handleFocusUser() {\n        onClose();\n        // @ts-ignore\n        action.setFocus(user._id);\n    }\n\n    async function handleAddFriend() {\n        // @ts-ignore\n        const friend = await addFriend(originUserId);\n        if (friend) {\n            onClose();\n            // @ts-ignore\n            const { _id } = user;\n            let existCount = 0;\n            if (linkman) {\n                existCount = Object.keys(linkman.messages).length;\n                action.setLinkmanProperty(_id, 'type', 'friend');\n            } else {\n                const newLinkman = {\n                    _id,\n                    from: selfId,\n                    to: {\n                        _id: originUserId,\n                        username: friend.username,\n                        avatar: friend.avatar,\n                    },\n                    type: 'friend',\n                    createTime: Date.now(),\n                };\n                action.addLinkman((newLinkman as unknown) as Linkman, true);\n            }\n            const messages = await getLinkmanHistoryMessages(_id, existCount);\n            if (messages) {\n                action.addLinkmanHistoryMessages(_id, messages);\n            }\n            handleFocusUser();\n        }\n    }\n\n    async function handleDeleteFriend() {\n        // @ts-ignore\n        const isSuccess = await deleteFriend(originUserId);\n        if (isSuccess) {\n            onClose();\n            // @ts-ignore\n            action.removeLinkman(user._id);\n            Message.success('删除好友成功');\n        }\n    }\n\n    async function handleSeal() {\n        // @ts-ignore\n        const isSuccess = await sealUser(user.name || user.username);\n        if (isSuccess) {\n            Message.success('封禁用户成功');\n        }\n    }\n\n    async function handleSealIp() {\n        // @ts-ignore\n        const isSuccess = await sealUserOnlineIp(originUserId);\n        if (isSuccess) {\n            Message.success('封禁ip成功');\n        }\n    }\n\n    function searchIp(ip: string) {\n        window.open(`https://www.baidu.com/s?wd=${ip}`);\n    }\n\n    function handleClose() {\n        toggleLargetAvatar(false);\n        onClose();\n    }\n\n    return (\n        <Dialog\n            className={Style.infoDialog}\n            visible={visible}\n            onClose={handleClose}\n        >\n            <div>\n                {visible && user ? (\n                    <div className={Style.coantainer}>\n                        <div className={Style.header}>\n                            <Avatar\n                                size={60}\n                                src={user.avatar}\n                                onMouseEnter={() => toggleLargetAvatar(true)}\n                                onMouseLeave={() => toggleLargetAvatar(false)}\n                            />\n                            <img\n                                className={`${Style.largeAvatar} ${\n                                    largerAvatar ? 'show' : 'hide'\n                                }`}\n                                src={getOSSFileUrl(user.avatar)}\n                                alt=\"用户头像\"\n                            />\n                            <p>{user.username}</p>\n                            <p className={Style.ip}>\n                                {userIps.map((ip) => (\n                                    <span\n                                        key={ip}\n                                        onClick={() => searchIp(ip)}\n                                        role=\"button\"\n                                    >\n                                        {ip}\n                                    </span>\n                                ))}\n                            </p>\n                        </div>\n                        <div className={Style.info}>\n                            {isFriend ? (\n                                <Button onClick={handleFocusUser}>\n                                    发送消息\n                                </Button>\n                            ) : null}\n                            {isFriend ? (\n                                <Button\n                                    type=\"danger\"\n                                    onClick={handleDeleteFriend}\n                                >\n                                    删除好友\n                                </Button>\n                            ) : (\n                                <Button onClick={handleAddFriend}>\n                                    加为好友\n                                </Button>\n                            )}\n                            {isAdmin ? (\n                                <Button type=\"danger\" onClick={handleSeal}>\n                                    封禁用户\n                                </Button>\n                            ) : null}\n                            {isAdmin ? (\n                                <Button type=\"danger\" onClick={handleSealIp}>\n                                    封禁ip\n                                </Button>\n                            ) : null}\n                        </div>\n                    </div>\n                ) : null}\n            </div>\n        </Dialog>\n    );\n}\n\nexport default UserInfo;\n"
  },
  {
    "path": "packages/web/src/service.ts",
    "content": "import fetch from './utils/fetch';\nimport { User, GroupMember } from './state/reducer';\n\nfunction saveUsername(username: string) {\n    window.localStorage.setItem('username', username);\n}\n\n/**\n * 注册新用户\n * @param username 用户名\n * @param password 密码\n * @param os 系统\n * @param browser 浏览器\n * @param environment 环境信息\n */\nexport async function register(\n    username: string,\n    password: string,\n    os = '',\n    browser = '',\n    environment = '',\n) {\n    const [err, user] = await fetch('register', {\n        username,\n        password,\n        os,\n        browser,\n        environment,\n    });\n\n    if (err) {\n        return null;\n    }\n\n    saveUsername(user.username);\n    return user;\n}\n\n/**\n * 使用账密登录\n * @param username 用户名\n * @param password 密码\n * @param os 系统\n * @param browser 浏览器\n * @param environment 环境信息\n */\nexport async function login(\n    username: string,\n    password: string,\n    os = '',\n    browser = '',\n    environment = '',\n) {\n    const [err, user] = await fetch('login', {\n        username,\n        password,\n        os,\n        browser,\n        environment,\n    });\n\n    if (err) {\n        return null;\n    }\n\n    saveUsername(user.username);\n    return user;\n}\n\n/**\n * 使用token登录\n * @param token 登录token\n * @param os 系统\n * @param browser 浏览器\n * @param environment 环境信息\n */\nexport async function loginByToken(\n    token: string,\n    os = '',\n    browser = '',\n    environment = '',\n) {\n    const [err, user] = await fetch(\n        'loginByToken',\n        {\n            token,\n            os,\n            browser,\n            environment,\n        },\n        { toast: false },\n    );\n\n    if (err) {\n        return null;\n    }\n\n    saveUsername(user.username);\n    return user;\n}\n\n/**\n * 游客模式登陆\n * @param os 系统\n * @param browser 浏览器\n * @param environment 环境信息\n */\nexport async function guest(os = '', browser = '', environment = '') {\n    const [err, res] = await fetch('guest', { os, browser, environment });\n    if (err) {\n        return null;\n    }\n    return res;\n}\n\n/**\n * 修用户头像\n * @param avatar 新头像链接\n */\nexport async function changeAvatar(avatar: string) {\n    const [error] = await fetch('changeAvatar', { avatar });\n    return !error;\n}\n\n/**\n * 修改用户密码\n * @param oldPassword 旧密码\n * @param newPassword 新密码\n */\nexport async function changePassword(oldPassword: string, newPassword: string) {\n    const [error] = await fetch('changePassword', {\n        oldPassword,\n        newPassword,\n    });\n    return !error;\n}\n\n/**\n * 修改用户名\n * @param username 新用户名\n */\nexport async function changeUsername(username: string) {\n    const [error] = await fetch('changeUsername', {\n        username,\n    });\n    return !error;\n}\n\n/**\n * 修改群组名\n * @param groupId 目标群组\n * @param name 新名字\n */\nexport async function changeGroupName(groupId: string, name: string) {\n    const [error] = await fetch('changeGroupName', { groupId, name });\n    return !error;\n}\n\n/**\n * 修改群头像\n * @param groupId 目标群组\n * @param name 新头像\n */\nexport async function changeGroupAvatar(groupId: string, avatar: string) {\n    const [error] = await fetch('changeGroupAvatar', { groupId, avatar });\n    return !error;\n}\n\n/**\n * 创建群组\n * @param name 群组名\n */\nexport async function createGroup(name: string) {\n    const [, group] = await fetch('createGroup', { name });\n    return group;\n}\n\n/**\n * 删除群组\n * @param groupId 群组id\n */\nexport async function deleteGroup(groupId: string) {\n    const [error] = await fetch('deleteGroup', { groupId });\n    return !error;\n}\n\n/**\n * 加入群组\n * @param groupId 群组id\n */\nexport async function joinGroup(groupId: string) {\n    const [, group] = await fetch('joinGroup', { groupId });\n    return group;\n}\n\n/**\n * 离开群组\n * @param groupId 群组id\n */\nexport async function leaveGroup(groupId: string) {\n    const [error] = await fetch('leaveGroup', { groupId });\n    return !error;\n}\n\n/**\n * 添加好友\n * @param userId 目标用户id\n */\nexport async function addFriend(userId: string) {\n    const [, user] = await fetch<User>('addFriend', { userId });\n    return user;\n}\n\n/**\n * 删除好友\n * @param userId 目标用户id\n */\nexport async function deleteFriend(userId: string) {\n    const [err] = await fetch('deleteFriend', { userId });\n    return !err;\n}\n\n/**\n * Get the last messages and unread number of a group of linkmans\n * @param linkmanIds Linkman ids who need to get the last messages\n */\nexport async function getLinkmansLastMessagesV2(linkmanIds: string[]) {\n    const [, linkmanMessages] = await fetch('getLinkmansLastMessagesV2', {\n        linkmans: linkmanIds,\n    });\n    return linkmanMessages;\n}\n\n/**\n * 获取联系人历史消息\n * @param linkmanId 联系人id\n * @param existCount 客户端已有消息条数\n */\nexport async function getLinkmanHistoryMessages(\n    linkmanId: string,\n    existCount: number,\n) {\n    const [, messages] = await fetch('getLinkmanHistoryMessages', {\n        linkmanId,\n        existCount,\n    });\n    return messages;\n}\n\n/**\n * 获取默认群组的历史消息\n * @param existCount 客户端已有消息条数\n */\nexport async function getDefaultGroupHistoryMessages(existCount: number) {\n    const [, messages] = await fetch('getDefaultGroupHistoryMessages', {\n        existCount,\n    });\n    return messages;\n}\n\n/**\n * 搜索用户和群组\n * @param keywords 关键字\n */\nexport async function search(keywords: string) {\n    const [, result] = await fetch('search', { keywords });\n    return result;\n}\n\n/**\n * 搜索表情包\n * @param keywords 关键字\n */\nexport async function searchExpression(keywords: string) {\n    const [, result] = await fetch('searchExpression', { keywords });\n    return result;\n}\n\n/**\n * 发送消息\n * @param to 目标\n * @param type 消息类型\n * @param content 消息内容\n */\nexport async function sendMessage(to: string, type: string, content: string) {\n    return fetch('sendMessage', { to, type, content });\n}\n\n/**\n * 删除消息\n * @param messageId 要删除的消息id\n */\nexport async function deleteMessage(messageId: string) {\n    const [err] = await fetch('deleteMessage', { messageId });\n    return !err;\n}\n\n/**\n * 获取目标群组的在线用户列表\n * @param groupId 目标群id\n */\nexport const getGroupOnlineMembers = (() => {\n    let cache: {\n        groupId: string;\n        key: string;\n        members: GroupMember[];\n    } = {\n        groupId: '',\n        key: '',\n        members: [],\n    };\n    return async function _getGroupOnlineMembers(\n        groupId: string,\n    ): Promise<GroupMember[]> {\n        const [, result] = await fetch('getGroupOnlineMembersV2', {\n            groupId,\n            cache: cache.groupId === groupId ? cache.key : undefined,\n        });\n        if (!result) {\n            return [];\n        }\n\n        if (result.cache === cache.key) {\n            return cache.members as GroupMember[];\n        }\n        cache = {\n            groupId,\n            key: result.cache,\n            members: result.members,\n        };\n        return result.members;\n    };\n})();\n\n/**\n * 获取默认群组的在线用户列表\n */\nexport async function getDefaultGroupOnlineMembers() {\n    const [, members] = await fetch('getDefaultGroupOnlineMembers');\n    return members;\n}\n\n/**\n * 封禁用户\n * @param username 目标用户名\n */\nexport async function sealUser(username: string) {\n    const [err] = await fetch('sealUser', { username });\n    return !err;\n}\n\n/**\n * 封禁ip\n * @param ip ip地址\n */\nexport async function sealIp(ip: string) {\n    const [err] = await fetch('sealIp', { ip });\n    return !err;\n}\n\n/**\n * 封禁用户所有在线ip\n * @param userId 用户id\n */\nexport async function sealUserOnlineIp(userId: string) {\n    const [err] = await fetch('sealUserOnlineIp', { userId });\n    return !err;\n}\n\n/**\n * 获取封禁用户列表\n */\nexport async function getSealList() {\n    const [, sealList] = await fetch('getSealList');\n    return sealList;\n}\n\nexport async function getSystemConfig() {\n    const [, systemConfig] = await fetch('getSystemConfig');\n    return systemConfig;\n}\n\n/**\n * 重置指定用户的密码\n * @param username 目标用户名\n */\nexport async function resetUserPassword(username: string) {\n    const [, res] = await fetch('resetUserPassword', { username });\n    return res;\n}\n\n/**\n * 更新指定用户的标签\n * @param username 目标用户名\n * @param tag 标签\n */\nexport async function setUserTag(username: string, tag: string) {\n    const [err] = await fetch('setUserTag', { username, tag });\n    return !err;\n}\n\n/**\n * 获取在线用户 ip\n * @param userId 用户id\n */\nexport async function getUserIps(userId: string) {\n    const [, res] = await fetch('getUserIps', { userId });\n    return res;\n}\n\nexport async function getUserOnlineStatus(userId: string) {\n    const [, res] = await fetch('getUserOnlineStatus', { userId });\n    return res && res.isOnline;\n}\n\nexport async function updateHistory(linkmanId: string, messageId: string) {\n    const [, result] = await fetch('updateHistory', { linkmanId, messageId });\n    return !!result;\n}\n\nexport async function toggleSendMessage(enable: boolean) {\n    const [, result] = await fetch('toggleSendMessage', { enable });\n    return !!result;\n}\n\nexport async function toggleNewUserSendMessage(enable: boolean) {\n    const [, result] = await fetch('toggleNewUserSendMessage', { enable });\n    return !!result;\n}\n"
  },
  {
    "path": "packages/web/src/socket.ts",
    "content": "import IO from 'socket.io-client';\nimport platform from 'platform';\n\nimport convertMessage from '@fiora/utils/convertMessage';\nimport getFriendId from '@fiora/utils/getFriendId';\nimport config from '@fiora/config/client';\nimport notification from './utils/notification';\nimport voice from './utils/voice';\nimport { initOSS } from './utils/uploadFile';\nimport playSound from './utils/playSound';\nimport { Message, Linkman } from './state/reducer';\nimport {\n    ActionTypes,\n    SetLinkmanPropertyPayload,\n    AddLinkmanHistoryMessagesPayload,\n    AddLinkmanMessagePayload,\n    DeleteMessagePayload,\n} from './state/action';\nimport {\n    guest,\n    loginByToken,\n    getLinkmanHistoryMessages,\n    getLinkmansLastMessagesV2,\n} from './service';\nimport store from './state/store';\n\nconst { dispatch } = store;\n\nconst options = {\n    // reconnectionDelay: 1000,\n};\nconst socket = IO(config.server, options);\n\nasync function loginFailback() {\n    const defaultGroup = await guest(\n        platform.os?.family,\n        platform.name,\n        platform.description,\n    );\n    if (defaultGroup) {\n        const { messages } = defaultGroup;\n        dispatch({\n            type: ActionTypes.SetGuest,\n            payload: defaultGroup,\n        });\n\n        messages.forEach(convertMessage);\n        dispatch({\n            type: ActionTypes.AddLinkmanHistoryMessages,\n            payload: {\n                linkmanId: defaultGroup._id,\n                messages,\n            },\n        });\n    }\n}\n\nsocket.on('connect', async () => {\n    dispatch({ type: ActionTypes.Connect, payload: '' });\n\n    await initOSS();\n    dispatch({ type: ActionTypes.Ready, payload: '' });\n\n    const token = window.localStorage.getItem('token');\n    if (token) {\n        const user = await loginByToken(\n            token,\n            platform.os?.family,\n            platform.name,\n            platform.description,\n        );\n        if (user) {\n            dispatch({\n                type: ActionTypes.SetUser,\n                payload: user,\n            });\n            const linkmanIds = [\n                ...user.groups.map((group: any) => group._id),\n                ...user.friends.map((friend: any) =>\n                    getFriendId(friend.from, friend.to._id),\n                ),\n            ];\n            const linkmanMessages = await getLinkmansLastMessagesV2(linkmanIds);\n            Object.values(linkmanMessages).forEach(\n                // @ts-ignore\n                ({ messages }: { messages: Message[] }) => {\n                    messages.forEach(convertMessage);\n                },\n            );\n            dispatch({\n                type: ActionTypes.SetLinkmansLastMessages,\n                payload: linkmanMessages,\n            });\n            return;\n        }\n    }\n    loginFailback();\n});\n\nsocket.on('disconnect', () => {\n    // @ts-ignore\n    dispatch({ type: ActionTypes.Disconnect, payload: null });\n});\n\nlet windowStatus = 'focus';\nwindow.onfocus = () => {\n    windowStatus = 'focus';\n};\nwindow.onblur = () => {\n    windowStatus = 'blur';\n};\n\nlet prevFrom: string | null = '';\nlet prevName = '';\nsocket.on('message', async (message: any) => {\n    convertMessage(message);\n\n    const state = store.getState();\n    const isSelfMessage = message.from._id === state.user?._id;\n    if (isSelfMessage && message.from.tag !== state.user?.tag) {\n        dispatch({\n            type: ActionTypes.UpdateUserInfo,\n            payload: {\n                tag: message.from.tag,\n            },\n        });\n    }\n\n    const linkman = state.linkmans[message.to];\n    let title = '';\n    if (linkman) {\n        dispatch({\n            type: ActionTypes.AddLinkmanMessage,\n            payload: {\n                linkmanId: message.to,\n                message,\n            } as AddLinkmanMessagePayload,\n        });\n        if (linkman.type === 'group') {\n            title = `${message.from.username} 在 ${linkman.name} 对大家说:`;\n        } else {\n            title = `${message.from.username} 对你说:`;\n        }\n    } else {\n        // 联系人不存在并且是自己发的消息, 不创建新联系人\n        if (isSelfMessage) {\n            return;\n        }\n        const newLinkman = {\n            _id: getFriendId(state.user?._id as string, message.from._id),\n            type: 'temporary',\n            createTime: Date.now(),\n            avatar: message.from.avatar,\n            name: message.from.username,\n            messages: [],\n            unread: 1,\n        };\n        dispatch({\n            type: ActionTypes.AddLinkman,\n            payload: {\n                linkman: newLinkman as unknown as Linkman,\n                focus: false,\n            },\n        });\n        title = `${message.from.username} 对你说:`;\n\n        const messages = await getLinkmanHistoryMessages(newLinkman._id, 0);\n        if (messages) {\n            dispatch({\n                type: ActionTypes.AddLinkmanHistoryMessages,\n                payload: {\n                    linkmanId: newLinkman._id,\n                    messages,\n                } as AddLinkmanHistoryMessagesPayload,\n            });\n        }\n    }\n\n    if (windowStatus === 'blur' && state.status.notificationSwitch) {\n        notification(\n            title,\n            message.from.avatar,\n            message.type === 'text'\n                ? message.content.replace(/&lt;/g, '<').replace(/&gt;/g, '>')\n                : `[${message.type}]`,\n            Math.random().toString(),\n        );\n    }\n\n    if (state.status.soundSwitch) {\n        const soundType = state.status.sound;\n        playSound(soundType);\n    }\n\n    if (state.status.voiceSwitch) {\n        if (message.type === 'text') {\n            const text = message.content\n                .replace(\n                    /https?:\\/\\/(www\\.)?[-a-zA-Z0-9@:%._+~#=]{2,256}\\.[a-z]{2,6}\\b([-a-zA-Z0-9@:%_+.~#?&//=]*)/g,\n                    '',\n                )\n                .replace(/#/g, '');\n\n            if (text.length > 100) {\n                return;\n            }\n\n            const from =\n                linkman && linkman.type === 'group'\n                    ? `${message.from.username}${\n                        linkman.name === prevName ? '' : `在${linkman.name}`\n                    }说`\n                    : `${message.from.username}对你说`;\n            if (text) {\n                voice.push(\n                    from !== prevFrom ? from + text : text,\n                    message.from.username,\n                );\n            }\n            prevFrom = from;\n            prevName = message.from.username;\n        } else if (message.type === 'system') {\n            voice.push(message.from.originUsername + message.content, '');\n            prevFrom = null;\n        }\n    }\n});\n\nsocket.on(\n    'changeGroupName',\n    ({ groupId, name }: { groupId: string; name: string }) => {\n        dispatch({\n            type: ActionTypes.SetLinkmanProperty,\n            payload: {\n                linkmanId: groupId,\n                key: 'name',\n                value: name,\n            } as SetLinkmanPropertyPayload,\n        });\n    },\n);\n\nsocket.on('deleteGroup', ({ groupId }: { groupId: string }) => {\n    dispatch({\n        type: ActionTypes.RemoveLinkman,\n        payload: groupId,\n    });\n});\n\nsocket.on('changeTag', (tag: string) => {\n    dispatch({\n        type: ActionTypes.UpdateUserInfo,\n        payload: {\n            tag,\n        },\n    });\n});\n\nsocket.on(\n    'deleteMessage',\n    ({\n        linkmanId,\n        messageId,\n        isAdmin,\n    }: {\n        linkmanId: string;\n        messageId: string;\n        isAdmin: boolean;\n    }) => {\n        dispatch({\n            type: ActionTypes.DeleteMessage,\n            payload: {\n                linkmanId,\n                messageId,\n                shouldDelete: isAdmin,\n            } as DeleteMessagePayload,\n        });\n    },\n);\n\nexport default socket;\n"
  },
  {
    "path": "packages/web/src/state/action.ts",
    "content": "import { Group, Friend, Message, Linkman } from './reducer';\n\n// eslint-disable-next-line import/prefer-default-export\nexport enum ActionTypes {\n    /** 设置游客信息 */\n    SetGuest = 'SetGuest',\n    /** 设置用户信息 */\n    SetUser = 'SetUser',\n    /** 更新用户信息 */\n    UpdateUserInfo = 'UpdateUserInfo',\n    /** 更新客户端状态 */\n    SetStatus = 'SetStatus',\n    /** 退出登录 */\n    Logout = 'Logout',\n    /** 设置用户头像 */\n    SetAvatar = 'SetAvatar',\n    /** 添加新联系人 */\n    AddLinkman = 'AddLinkman',\n    /** 移除指定联系人 */\n    RemoveLinkman = 'RemoveLinkman',\n    /** 设置聚焦的联系人 */\n    SetFocus = 'SetFocus',\n    /** 设置各联系人历史消息 */\n    SetLinkmansLastMessages = 'SetLinkmansLastMessages',\n    /** 添加联系人历史消息 */\n    AddLinkmanHistoryMessages = 'AddLinkmanHistoryMessages',\n    /** 添加联系人新消息 */\n    AddLinkmanMessage = 'AddLinkmanMessage',\n    /** 设置联系人指定属性值 */\n    SetLinkmanProperty = 'SetLinkmanProperty',\n    /** 更新消息 */\n    UpdateMessage = 'UpdateMessage',\n    /** 删除消息 */\n    DeleteMessage = 'DeleteMessage',\n    /** socket连接成功 */\n    Connect = 'Connect',\n    /** socket断开连接 */\n    Disconnect = 'Disconnect',\n    /** Aliyun OSS ready */\n    Ready = 'Ready',\n}\n\nexport type SetGuestPayload = Group;\n\nexport type SetUserPayload = {\n    _id: string;\n    username: string;\n    tag: string;\n    avatar: string;\n    groups: Group[];\n    friends: Friend[];\n    isAdmin: boolean;\n};\n\nexport type UpdateUserInfoPayload = Object;\n\nexport interface SetStatusPayload {\n    key: string;\n    value: any;\n}\n\nexport type SetAvatarPayload = string;\n\nexport interface AddLinkmanPayload {\n    linkman: Linkman;\n    focus: boolean;\n}\n\nexport type SetFocusPayload = string;\n\nexport interface SetLinkmansLastMessagesPayload {\n    [linkmanId: string]: {\n        messages: Message[];\n        unread: number;\n    };\n}\n\nexport interface AddLinkmanHistoryMessagesPayload {\n    linkmanId: string;\n    messages: Message[];\n}\n\nexport interface AddLinkmanMessagePayload {\n    linkmanId: string;\n    message: Message;\n}\n\nexport interface SetLinkmanPropertyPayload {\n    linkmanId: string;\n    key: string;\n    value: any;\n}\n\nexport type RemoveLinkmanPayload = string;\n\nexport interface UpdateMessagePayload {\n    linkmanId: string;\n    messageId: string;\n    value: any;\n}\n\nexport interface DeleteMessagePayload {\n    linkmanId: string;\n    messageId: string;\n    shouldDelete: boolean;\n}\n\nexport interface Action {\n    type: ActionTypes;\n    payload:\n        | SetUserPayload\n        | UpdateUserInfoPayload\n        | SetGuestPayload\n        | SetStatusPayload\n        | SetAvatarPayload\n        | AddLinkmanPayload\n        | SetFocusPayload\n        | AddLinkmanHistoryMessagesPayload\n        | AddLinkmanMessagePayload\n        | SetLinkmanPropertyPayload\n        | RemoveLinkmanPayload\n        | SetLinkmansLastMessagesPayload\n        | UpdateMessagePayload\n        | DeleteMessagePayload;\n}\n"
  },
  {
    "path": "packages/web/src/state/reducer.ts",
    "content": "import { isMobile } from '@fiora/utils/ua';\nimport getFriendId from '@fiora/utils/getFriendId';\nimport convertMessage from '@fiora/utils/convertMessage';\nimport getData from '../localStorage';\nimport {\n    Action,\n    ActionTypes,\n    SetUserPayload,\n    SetStatusPayload,\n    AddLinkmanPayload,\n    AddLinkmanHistoryMessagesPayload,\n    SetLinkmansLastMessagesPayload,\n    SetLinkmanPropertyPayload,\n    UpdateMessagePayload,\n    AddLinkmanMessagePayload,\n    UpdateUserInfoPayload,\n    DeleteMessagePayload,\n} from './action';\n\n/** 聊天消息 */\nexport interface Message {\n    _id: string;\n    type: string;\n    content: string;\n    from: {\n        _id: string;\n        username: string;\n        avatar: string;\n        originUsername: string;\n        tag: string;\n    };\n    loading: boolean;\n    percent: number;\n    createTime: string;\n    deleted?: boolean;\n}\n\nexport interface MessagesMap {\n    [messageId: string]: Message;\n}\n\nexport interface GroupMember {\n    user: {\n        _id: string;\n        username: string;\n        avatar: string;\n    };\n    os: string;\n    browser: string;\n    environment: string;\n}\n\n/** 群组 */\nexport interface Group {\n    _id: string;\n    name: string;\n    avatar: string;\n    createTime: string;\n    creator: string;\n    onlineMembers: GroupMember[];\n}\n\n/** 好友 */\nexport interface Friend {\n    _id: string;\n    name: string;\n    avatar: string;\n    createTime: string;\n}\n\n/** 联系人 */\nexport interface Linkman extends Group, User {\n    type: string;\n    unread: number;\n    messages: MessagesMap;\n}\n\nexport interface LinkmansMap {\n    [linkmanId: string]: Linkman;\n}\n\n/** 用户信息 */\nexport interface User {\n    _id: string;\n    username: string;\n    avatar: string;\n    isOnline: boolean;\n}\n\n/** redux store state */\nexport interface State {\n    /** 用户信息 */\n    user: {\n        _id: string;\n        username: string;\n        avatar: string;\n        tag: string;\n        isAdmin: boolean;\n    } | null;\n    linkmans: LinkmansMap;\n    /** 聚焦的联系人 */\n    focus: string;\n    /** 客户端连接状态 */\n    connect: boolean;\n    /** 客户端的一些状态值 */\n    status: {\n        ready: boolean;\n        /** 是否显示登陆注册框 */\n        loginRegisterDialogVisible: boolean;\n        /** 主题 */\n        theme: string;\n        /** 主题主色调 */\n        primaryColor: string;\n        /** 主题文字主色调 */\n        primaryTextColor: string;\n        /** 背景图 */\n        backgroundImage: string;\n        /** 启用毛玻璃效果 */\n        aero: boolean;\n        /** 新消息声音提示开关 */\n        soundSwitch: boolean;\n        /** 声音类型 */\n        sound: string;\n        /** 新消息桌面提醒开关 */\n        notificationSwitch: boolean;\n        /** 新消息语言朗读开关 */\n        voiceSwitch: boolean;\n        /** 是否朗读个人发送的消息开关 */\n        selfVoiceSwitch: boolean;\n        /**\n         * 用户标签颜色模式\n         * singleColor: 固定颜色\n         * fixedColor: 同一词始终同一颜色\n         * randomColor: 同一词在每次渲染中保持同一颜色\n         */\n        tagColorMode: string;\n        /** 是否展示侧边栏 */\n        sidebarVisible: boolean;\n        /** 是否展示搜索+联系人列表栏 */\n        functionBarAndLinkmanListVisible: boolean;\n        /** enable search expression when input some phrase */\n        enableSearchExpression: boolean;\n    };\n}\n\n/**\n * 将联系人以_id为键转为对象结构\n * @param linkmans 联系人数组\n */\nfunction getLinkmansMap(linkmans: Linkman[]) {\n    return linkmans.reduce((map: LinkmansMap, linkman) => {\n        map[linkman._id] = linkman;\n        return map;\n    }, {});\n}\n\n/**\n * 将消息以_id为键转为对象结构\n * @param messages 消息数组\n */\nfunction getMessagesMap(messages: Message[]) {\n    return messages.reduce((map: MessagesMap, message) => {\n        map[message._id] = message;\n        return map;\n    }, {});\n}\n\n/**\n * 删除对象中的对个键值\n * @param obj 目标对象\n * @param keys 要删除的键列表\n */\nfunction deleteObjectKeys<T>(obj: T, keys: string[]): T {\n    let entries = Object.entries(obj);\n    const keysSet = new Set(keys);\n    entries = entries.filter((entry) => !keysSet.has(entry[0]));\n    return entries.reduce((result: any, entry) => {\n        const [k, v] = entry;\n        result[k] = v;\n        return result;\n    }, {});\n}\n\n/**\n * 删除对象中的某个键值\n * 直接调用delete删除键值据说性能差(我没验证)\n * @param obj 目标对象\n * @param key 要删除的键\n */\nfunction deleteObjectKey<T>(obj: T, key: string): T {\n    return deleteObjectKeys(obj, [key]);\n}\n\n/**\n * 初始化联系人部分公共字段\n * @param linkman 联系人\n * @param type 联系人类型\n */\nfunction initLinkmanFields(linkman: Linkman, type: string) {\n    linkman.type = type;\n    linkman.unread = 0;\n    linkman.messages = {};\n}\n\n/**\n * 转换群组数据结构\n * @param group 群组\n */\nfunction transformGroup(group: Linkman): Linkman {\n    initLinkmanFields(group, 'group');\n    group.creator = group.creator || '';\n    group.onlineMembers = [];\n    return group;\n}\n\n/**\n * 转换好友数据结构\n * @param friend 好友\n */\nfunction transformFriend(friend: Linkman): Linkman {\n    // @ts-ignore\n    const { from, to } = friend;\n    const transformedFriend = {\n        _id: getFriendId(from, to._id),\n        name: to.username,\n        avatar: to.avatar,\n        // @ts-ignore\n        createTime: friend.createTime,\n    };\n    initLinkmanFields(transformedFriend as unknown as Linkman, 'friend');\n    return transformedFriend as Linkman;\n}\n\nfunction transformTemporary(temporary: Linkman): Linkman {\n    initLinkmanFields(temporary, 'temporary');\n    return temporary;\n}\n\nconst localStorage = getData();\nexport const initialState: State = {\n    user: null,\n    linkmans: {},\n    focus: '',\n    connect: false,\n    status: {\n        ready: false,\n        loginRegisterDialogVisible: false,\n        theme: localStorage.theme,\n        primaryColor: localStorage.primaryColor,\n        primaryTextColor: localStorage.primaryTextColor,\n        backgroundImage: localStorage.backgroundImage,\n        aero: localStorage.aero,\n        soundSwitch: localStorage.soundSwitch,\n        sound: localStorage.sound,\n        notificationSwitch: localStorage.notificationSwitch,\n        voiceSwitch: localStorage.voiceSwitch,\n        selfVoiceSwitch: localStorage.selfVoiceSwitch,\n        tagColorMode: localStorage.tagColorMode,\n        sidebarVisible: !isMobile,\n        functionBarAndLinkmanListVisible: !isMobile,\n        enableSearchExpression: localStorage.enableSearchExpression,\n    },\n};\n\nfunction reducer(state: State = initialState, action: Action): State {\n    switch (action.type) {\n        case ActionTypes.Ready: {\n            return {\n                ...state,\n                status: {\n                    ...state.status,\n                    ready: true,\n                },\n            };\n        }\n        case ActionTypes.Connect: {\n            return {\n                ...state,\n                connect: true,\n            };\n        }\n        case ActionTypes.Disconnect: {\n            return {\n                ...state,\n                connect: false,\n            };\n        }\n\n        case ActionTypes.SetGuest: {\n            const group = action.payload as Linkman;\n            transformGroup(group);\n            return {\n                ...state,\n                user: {\n                    _id: '',\n                    username: '',\n                    avatar: '',\n                    tag: '',\n                    isAdmin: false,\n                },\n                linkmans: {\n                    [group._id]: group,\n                },\n                focus: group._id,\n            };\n        }\n\n        case ActionTypes.SetUser: {\n            const { _id, username, avatar, tag, groups, friends, isAdmin } =\n                action.payload as SetUserPayload;\n            // @ts-ignore\n            const linkmans: Linkman[] = [\n                // @ts-ignore\n                ...groups.map(transformGroup),\n                // @ts-ignore\n                ...friends.map(transformFriend),\n            ];\n\n            // 如果没登录过, 则将聚焦联系人设置为第一个联系人\n            let { focus } = state;\n            /* istanbul ignore next */\n            if (!state.user && linkmans.length > 0) {\n                focus = linkmans[0]._id;\n            }\n\n            return {\n                ...state,\n                user: {\n                    _id,\n                    username,\n                    avatar,\n                    tag,\n                    isAdmin,\n                },\n                linkmans: getLinkmansMap(linkmans),\n                focus,\n            };\n        }\n\n        case ActionTypes.UpdateUserInfo: {\n            const payload = action.payload as UpdateUserInfoPayload;\n            return {\n                ...state,\n                // @ts-ignore\n                user: {\n                    ...state.user,\n                    ...payload,\n                },\n            };\n        }\n\n        case ActionTypes.Logout: {\n            return {\n                ...initialState,\n                status: {\n                    ...state.status,\n                },\n            };\n        }\n\n        case ActionTypes.SetAvatar: {\n            return {\n                ...state,\n                // @ts-ignore\n                user: {\n                    ...state.user,\n                    avatar: action.payload as string,\n                },\n            };\n        }\n\n        case ActionTypes.SetFocus: {\n            const focus = action.payload as string;\n            if (!state.linkmans[focus]) {\n                /* istanbul ignore next */\n                if (!__TEST__) {\n                    console.warn(\n                        `ActionTypes.SetFocus Error: 联系人 ${focus} 不存在`,\n                    );\n                }\n                return state;\n            }\n\n            /**\n             * 为了优化性能\n             * 如果目标联系人的旧消息个数超过50条, 仅保留50条\n             */\n            const { messages } = state.linkmans[focus];\n            const messageKeys = Object.keys(messages);\n            let reserveMessages = messages;\n            if (messageKeys.length > 50) {\n                reserveMessages = deleteObjectKeys(\n                    messages,\n                    messageKeys.slice(0, messageKeys.length - 50),\n                );\n            }\n\n            return {\n                ...state,\n                linkmans: {\n                    ...state.linkmans,\n                    [focus]: {\n                        ...state.linkmans[focus],\n                        messages: reserveMessages,\n                        unread: 0,\n                    },\n                },\n                focus,\n            };\n        }\n\n        case ActionTypes.AddLinkman: {\n            const payload = action.payload as AddLinkmanPayload;\n            const { linkman } = payload;\n            const focus = payload.focus ? linkman._id : state.focus;\n\n            let transformedLinkman = linkman;\n            switch (linkman.type) {\n                case 'group': {\n                    transformedLinkman = transformGroup(linkman);\n                    break;\n                }\n                case 'friend': {\n                    transformedLinkman = transformFriend(linkman);\n                    break;\n                }\n                case 'temporary': {\n                    transformedLinkman = transformTemporary(linkman);\n                    transformedLinkman.unread = 1;\n                    break;\n                }\n                default: {\n                    return state;\n                }\n            }\n\n            return {\n                ...state,\n                linkmans: {\n                    ...state.linkmans,\n                    [transformedLinkman._id]: transformedLinkman,\n                },\n                focus,\n            };\n        }\n\n        case ActionTypes.RemoveLinkman: {\n            const linkmans = deleteObjectKey(\n                state.linkmans,\n                action.payload as string,\n            );\n            const linkmanIds = Object.keys(linkmans);\n            const focus = linkmanIds.length > 0 ? linkmanIds[0] : '';\n            return {\n                ...state,\n                linkmans: {\n                    ...linkmans,\n                },\n                focus,\n            };\n        }\n\n        case ActionTypes.SetLinkmansLastMessages: {\n            const linkmansMessages =\n                action.payload as SetLinkmansLastMessagesPayload;\n            const { linkmans } = state;\n            const newState = { ...state, linkmans: {} };\n            Object.keys(linkmans).forEach((linkmanId) => {\n                // @ts-ignore\n                newState.linkmans[linkmanId] = {\n                    ...linkmans[linkmanId],\n                    ...(linkmansMessages[linkmanId]\n                        ? {\n                            messages: getMessagesMap(\n                                linkmansMessages[linkmanId].messages,\n                            ),\n                            unread: linkmansMessages[linkmanId].unread,\n                        }\n                        : {}),\n                };\n            });\n            return newState;\n        }\n\n        case ActionTypes.AddLinkmanHistoryMessages: {\n            const payload = action.payload as AddLinkmanHistoryMessagesPayload;\n            const messagesMap = getMessagesMap(payload.messages);\n            return {\n                ...state,\n                linkmans: {\n                    ...state.linkmans,\n                    [payload.linkmanId]: {\n                        ...state.linkmans[payload.linkmanId],\n                        messages: {\n                            ...messagesMap,\n                            ...state.linkmans[payload.linkmanId].messages,\n                        },\n                    },\n                },\n            };\n        }\n\n        case ActionTypes.AddLinkmanMessage: {\n            const payload = action.payload as AddLinkmanMessagePayload;\n            let { unread } = state.linkmans[payload.linkmanId];\n            if (state.focus !== payload.linkmanId) {\n                unread++;\n            }\n            return {\n                ...state,\n                linkmans: {\n                    ...state.linkmans,\n                    [payload.linkmanId]: {\n                        ...state.linkmans[payload.linkmanId],\n                        messages: {\n                            ...state.linkmans[payload.linkmanId].messages,\n                            [payload.message._id]: payload.message,\n                        },\n                        unread,\n                    },\n                },\n            };\n        }\n\n        case ActionTypes.DeleteMessage: {\n            const { linkmanId, messageId, shouldDelete } =\n                action.payload as DeleteMessagePayload;\n            if (!state.linkmans[linkmanId]) {\n                /* istanbul ignore next */\n                if (!__TEST__) {\n                    console.warn(\n                        `ActionTypes.DeleteMessage Error: 联系人 ${linkmanId} 不存在`,\n                    );\n                }\n                return state;\n            }\n\n            const newMessages = shouldDelete\n                ? deleteObjectKey(state.linkmans[linkmanId].messages, messageId)\n                : {\n                    ...state.linkmans[linkmanId].messages,\n                    [messageId]: convertMessage({\n                        ...state.linkmans[linkmanId].messages[messageId],\n                        deleted: true,\n                    }),\n                };\n\n            return {\n                ...state,\n                linkmans: {\n                    ...state.linkmans,\n                    [linkmanId]: {\n                        ...state.linkmans[linkmanId],\n                        messages: newMessages,\n                    },\n                },\n            };\n        }\n\n        case ActionTypes.SetLinkmanProperty: {\n            const payload = action.payload as SetLinkmanPropertyPayload;\n            return {\n                ...state,\n                linkmans: {\n                    ...state.linkmans,\n                    [payload.linkmanId]: {\n                        ...state.linkmans[payload.linkmanId],\n                        [payload.key]: payload.value,\n                    },\n                },\n            };\n        }\n\n        case ActionTypes.UpdateMessage: {\n            const payload = action.payload as UpdateMessagePayload;\n\n            let messages = {};\n            if (payload.value._id) {\n                messages = {\n                    ...deleteObjectKey(\n                        state.linkmans[payload.linkmanId].messages,\n                        payload.messageId,\n                    ),\n                    [payload.value._id]: payload.value,\n                };\n            } else {\n                messages = {\n                    ...state.linkmans[payload.linkmanId].messages,\n                    [payload.messageId]: {\n                        ...state.linkmans[payload.linkmanId].messages[\n                            payload.messageId\n                        ],\n                        ...payload.value,\n                    },\n                };\n            }\n\n            return {\n                ...state,\n                linkmans: {\n                    ...state.linkmans,\n                    [payload.linkmanId]: {\n                        ...state.linkmans[payload.linkmanId],\n                        messages,\n                    },\n                },\n            };\n        }\n\n        case ActionTypes.SetStatus: {\n            const payload = action.payload as SetStatusPayload;\n            return {\n                ...state,\n                status: {\n                    ...state.status,\n                    [payload.key]: payload.value,\n                },\n            };\n        }\n\n        default:\n            return state;\n    }\n}\n\nexport default reducer;\n"
  },
  {
    "path": "packages/web/src/state/store.ts",
    "content": "import { createStore } from 'redux';\nimport reducer from './reducer';\n\nconst store = createStore(\n    reducer,\n    window.__REDUX_DEVTOOLS_EXTENSION__ &&\n        window.__REDUX_DEVTOOLS_EXTENSION__(),\n);\nexport default store;\n"
  },
  {
    "path": "packages/web/src/styles/iconfont.less",
    "content": ":global {\n  @font-face {font-family: \"iconfont\";\n    src: url('//at.alicdn.com/t/font_590033_vsjwaybxn6j.eot?t=1569418645983'); /* IE9 */\n    src: url('//at.alicdn.com/t/font_590033_vsjwaybxn6j.eot?t=1569418645983#iefix') format('embedded-opentype'), /* IE6-IE8 */\n    url('data:application/x-font-woff2;charset=utf-8;base64,d09GMgABAAAAABBwAAsAAAAAHXAAABAhAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHEIGVgCHEgqnSKA2ATYCJAN0CzwABCAFhG0Hgh4b2BizERZsHABC5AuQ/dcJ3BgC1VT/ACVy02gMX2yxD4EWUKHf0OBjYM3Z8F5HuDVcWo7IWxX+rjQ8M5TSIJrzf/b2IsSJYyHB7aWOeoJVsRq/1ExofxsqYmjFLTWDimUoz8rsOUmXg7ZHKDgBk2/yVbFwdQIGAZAAAV9t3mXc9r1Lv2sKH6GuQ9b+5afnEjL6cpAHdPUAAUBlAnfQLt9Y4f/mSpsUUdXhqAIJg1bkZ7Mwk1n45dk9SvkAUd2r6quqrBE0OV5OiuAQNUgUssbW1Thf22ye4nfs3LkogmgWsmkvzCToU8QDBJXNrw4EgAA6AUh8YroZfLDgjKBOr57dS8D37QWbspSAH+LGpGQpmYtO4DPTmPMA5tDXJ28RFh9gwKHgU2Z3SyhHtBUfJXSsjW5LKzDkCQB3qQAKIADAAkxY7BESAHrVyRTEmeztkwyix0JiJVauVaZGXD/DTWZY860DrKOss61N1lfWL9bmlyNfSd8MeXPvo8RmgxVWKrqz6t0a2Ve8MkGQEFHqzoy7aqmz/8gTwJEUMkpc8MBCQQQNBnYQQ01Fy4EQcnzYcyYBBQdOQLhgtUUAQIpR4gxW6AQEsK4HAWCl68ERrCxCCs4BQgbOBUIJLgNDmMuB4IErwFCca8GgcDcgROD+QGjA/wOCATcDYQeeD4QYfAAQavCRQKjAR4GhDj4bCAfwaiCE4PVgaB8/DQQfvBnrwR5ejlwPzvBKuh4kwJshCEr8HgQ42JcGmsEJALggRUtACsB1A/jTwBc8ByXIp2AoyD8xDD9ldUMNMqYR74zhOodDUSXSq3RYputEj8W6dinXPN5qPdavYtQgXe+RqOoBKvk6erd+iW66fOLCLb169qqqqJoYqyg3qkfd9rUO1ebm48c2XTz+Ycqu7g7e/XThZ9m/WSz6wPpQtOMZRZr4iE7fGbccSil2D/FdvC90yP7IQ0MDCg1TLg5v8X08KQzJ9ihCE4Ng754M6jC0LdDhQsrekS8zZnBuSmkLsTER4q2Sh8xSEhTo5T17jX17rP377L37M9l9WuQzQ0oshJX5XM73gw55R966xjLW2dZ6015rXJSS94epQpZJ2ZrmDLyUtVOtB62LmSCmwT5bM9Vx6pjtZlwmmYqbQPNQ0JZW6AtVVTznrQScDA5VNdLWptx8yD05WUpQ6vNPWPHH/8fDXKn7bTEtxHHNEcNgIpctDgoxTkQkAmG7+2QIAlgwU0v68AkbmX46EN/9OzFpdGfscpOM5nVq8aPc6sMBkZQNwMHPjCdz4EoyfDhj8TQKBnrTo9fDyaxNqTgtDPRy5O7hFR8MuSwkIRZ8MkkIAlTI2CL3EcYG4gxCkzyfyDlEGsGfvbDtoYZsI2xXXWao94T1YIJVkv2EZQ8MNE/aWh03vclmninc3NeagRuG8DGetkvOK3eIYRXdF3SoaeTpS2+4bRa8186wy1utEM7w0cbAI5sjPCuExrYQqz68/E+4bM6hfI4n3Thig6r4ebcIgBoWnZKmgZWxooAUzU0ebs+WtJhXUMpofoDeXkyeXk/MXHnGGhqNbHRKg2NthURZKi0G9mX9ftFO049GmzTqyk+9kfpQdUMAnMiLCHBCtxHRvEBRaGCKy+DQbMVTW+PgAPCVdGEgAFVgZl04u9pfOa+9l/SFnXrhvkqCrOc7SWx+8KdH3d+Jj//3rv+3+2bgzjHrtD4bbPAC6quqopVuiIGKTtR9DynEu02m2SXQUCrIKDuuP3jWa1MY4vZhCywKtFsn2deDH77sSeOm3X4LNPz3/vakNwaYb+ci8nv18EyyoZWOeio0DvMDfMiM2ienqIIeoJijBbCYLLQxHYybLNdd2lGWRCRH5BaAsOjmKQpScMqPFX5re4jfzbPCJFhp1jP3ibLsoIRKcIMP/fbJsDIkcjIXwtPPZf/OQh9FE5/6duPpAkJf+eSHcvlzIMSuogAtcjouflNTx6BTH/KBGSDl7nwjuF+GwKdcoWzIti6Kk5ycjPDlHWI9lh5fyfRI3iNP5fTnW1AwtnXZxm0WVp65YhhjfTg/6lqRBQI4chZOj9oKqX7rI2+sPFvgWLf+TQsz2Rx6GhQKNWQLpm5eiqzDEtTXkF4dVRqgb3x4U3dCl/dq2tTxM3MI246q/ahzXHXzijKtnaZbt1n5em+0ySvddm4V1d0y/OVOt/JuH34Qs4ZNZLww05BDbU+HDr35dTGo713CLThydL3sHeVIjq+U5m85MGhDdbzHTd6zHbZ1PZRU/lERg2wD3gwYkXc4eWAabiHbkoStX4b4g54JoxX/WpRqrBuSZwrl5pTDbAo/lcg31oMiCMF2G4gpJOLjJkdExCeR0zYxnSysK+vWaZUx5ii+vwmJqfIR++vKOh2vGCadzjBffmw268T+pKrSPQlVNj3Rj1T5RM95fDkFGWU2obj4fJ40QWCXwCrknY+XJjyeaR4lSNM6XH+s+c4WJb009mkjB45IX/Lo8eNQfM6G3Ru4fk1sZG02VoGGBOiuhkAjpMbOXt7vPd+vXXvRs2bbj5/A5T7wqvVE1OpqPT9lcrJ5S2gds4mus91rYD9t4/zPXMzxj5UXkrzIktUoiTt0v4FScxlsYw4cpt+/08PQAIbftYzdbHt4Z+YArSwtByu6LVg8Ya4xdy/9+IHayb0f3+1lST8ojnOMs7kLFnRXuSyIttBr15hVjOXaVQtdZXiVWrLr16CnbevYX7/YCNQloKMrg3v+VvriBd1CtzrdegsMoOHhb6HO/pkXdaaLrqMLOruYE5OXRYeXTpx3KVNV/bTNGamjMiDSXBnlWCoXH11doohct/9XUW6Xoeaq86UYqtKZHE2cZ12ZCXXoay7n9Z7qaMz2HsO0nzm5x2alGc55/LCYhWmsqbRE2U6VN/zdWJ8fXTru37t2llxhFOpm+zxf3aD4NALlFRihyOk/kBWmxe/Sut9LbnnoKZP7ZYhmQw/oqXpLFzzN7uhWDK5n//5l66k1GB2V19s1/8GbzGDHdK8x01jFfYcET/ddNu+60+W6kqCpQl7Dv74qcnr1PduDyCVPBZoL0XY9auVO6odPDWjZ2EzdO+9oiiiqmrAnpotd0kxHdrdW1rm8rZthE3IKqsoqe5zmPI9fOvGpbMkMO/XDknYqbx36aRMpKarqX76zyd5UYZJnVWiKK/IpUsM7dg8stGPSY9vdOXufyN+mutipX2fWkBrZoUdQYQsmwxoMKTKTvn2JmVqD0cGtUByDggIgEBMAvyJDydimpgjjNGN881XQFG9cloBAM8RSYxxuoinOONUYAZdjDtizecfgwW7du/9gwgXh3xk4ch88ecN27BLMDbMIEH60xlYdu68qjhkdW7031rJ9PsViX73CqaFRZO9kL25svAxFYkrAGlDpMnJ+opvt0ZfACY2Ls2sMNadO1eoNz7Vyjb42MtW+awIoJqEj8ouOuI3ZO8khIdDTVrXsjx+sArUJ6OAeudP3Q4g65YDxvKV9F/I28j15K6QbealxSLbBziXL+/bhbeEv5C2TYWS0m+uLSx9RkSaee92+ghESujkEaER0FZXYDJbwKFV4fBCw+YJPm+PM28vnxeLzl8tcXM7z4AHogLABxN8dNu/PH60jdZQ+VyZ8lr2FPn/ObmG3OmwyYJS+amgsfyiyaW2i0alW4Udh6mjha1Q4OoZu459ltHTcuCEaqKcOGjR40FQ1NEPGj5OO3vkv8+cTg6+u72RpY1PV3oHkaIR7RFK6mmE5LLft+suyGijGLuk85kP6cEd+t9xufG1Rm1aVOolH7MQ9c8eyOBdbAwbtjuDdMGh95vrOXdDh8iFCOKxSHlEjjQ49uY/PUaWpw39Hv+srTDxN6TPHYc9+QwhZwRQKGEmCgY+/piudeH7NQpNX3kMviS5uVHLfAVZ/uVYaItXKW3NzTZlR05k51+apTBt6DJLnb9Yn6GMlaDvhalQmQ+KKiuKI3WT7IsQxUZlHy1VjlpEK/voXODZNsvPxLNluiyep+P5OD/ez+iQ/GmweJo8OdYojRUVENJvnTkGi6Jq7gpR2q3qVdFTm1TuHRcdNDzCF+emTDpu9ulWhVOmOq2liv4cmaE3GsPV//hwBMC2yemZO1YMx4Zm50vxy3nl0+PIHFWH8sPYbI3IgPxDRoSVySyqTCV+n4sotprL1OS2ThpYUZmjuJLnkpujOpqnKs48ZM7e3B5lVkKVP1w3qp08xpJvDtMXB+THJM/cv4KRsSAjxTvZJRR4SIkd3Kx0fPh+R7qXPzSTm+6SGj9IcaNG85sCMmOSgfG1xmNmQ7poyuJ9eWaZroV2DxE08RmJwMUjGiN0gKwvJ4pcNINksyaqoyCK7JXLxRlI4ov9kamb69CHmjWK5ZMNREO3zbrGJZiYh2UCSN89RLanOI4P6zOlXnQvNfZ6+nTymb3/kUdPASthiDsc7nTiIWb6CGej3n295ZmaZT85K+We9h/6zXNe9ZbdQKDxzFS6PA92co6NvFfllqer/lDT9nrvyS3krjXrPQSGjed5PO2Qhpgc4m6qXcHKaUp0tx02O/Sfp+Hfpb3I4ZnFObeLkLKl2yhVixpBF/dTPvf6PQfbxUou7/cMthpqlVwbzy0ajj7suZujQyJZDCgvHzU07fofZu4aJkyl+YmaB4S8qVKkewwcP7s8pPhhR0/rSjJWqFSRZ1s2edUU7d9u9xqh2fYefmQ0X9F0wkdmxg5kIrTXZYwx6m+2Hrd5RGkurGs2m0auuFMJY3R12nCYCAK1g3gC2y07QkEvpaQepsYjaLnZEA1ZdNoQGB+tkResdQCWA9mLeo/AeAfXEqqMaaVv1jtp6+XG10UdBdhFA1xBR2ijTjPHHHjM/KPaLec3MI23Umu1MQ9slGhjZh+Jt9tB80QO19D/DsRB4z5NdpK2bU/jX4ll3ds0+HbFtK0Q8lw/mHwSO/wPbsakAwIbc+zh1eQtDH5jq/qvwtGCNi+quhQwAgToM+E8wX+5hO4MqYePf0qDKJQx4cCEUfHio7MwAwoEQEYQLPtoRAfwlpRdCoT8oCGsHwM+kCyGQ2UEYSNBAKGQuqOzMB4QDtfeEC1mUCJBPFA6F8DpSPTdvgmAYXP9yxTPFOmAbu+43plsF8fpo5T+KluPu1B2DZV9IKDLi6D2dzaKLwoP7vFwb1squCV/RW5fN2uVwiHG30HkeRpvRmyAYBqd/mdsWzxTPbJvQl//GdKsgKV2+bPuPoiXmnP7I7VJA/TJJqMu2NNZ7OjMmotNbCw/OJxNY1WJ2WvxoV/TWyQUK28WBDRVFza7+4LBNTCy+brlLlxhNN0xL2o7yGyi+6/nsySkoqfzEfoK0HDhy4syFLmaKFOaaQXDmOeBSb96j6txXBJn5DDZHEZaF+m18nkJryyilQDydQ883WyqaFUrCTlhxFvhBUwhhlZgS4bKZFYq8fIBQhkYzHootA1QWh9IewlCoqAlY6fZJ+NZ0XjkVWpi6XFlEsJvgBp8NTFdXwkUqlm/9QtBDraMRAAA=') format('woff2'),\n    url('//at.alicdn.com/t/font_590033_vsjwaybxn6j.woff?t=1569418645983') format('woff'),\n    url('//at.alicdn.com/t/font_590033_vsjwaybxn6j.ttf?t=1569418645983') format('truetype'), /* chrome, firefox, opera, Safari, Android, iOS 4.2+ */\n    url('//at.alicdn.com/t/font_590033_vsjwaybxn6j.svg?t=1569418645983#iconfont') format('svg'); /* iOS 4.1- */\n  }\n  \n  .iconfont {\n    font-family: \"iconfont\" !important;\n    font-size: 16px;\n    font-style: normal;\n    -webkit-font-smoothing: antialiased;\n    -moz-osx-font-smoothing: grayscale;\n  }\n  \n  .icon-send:before {\n    content: \"\\e600\";\n  }\n  \n  .icon-share:before {\n    content: \"\\e637\";\n  }\n  \n  .icon-code:before {\n    content: \"\\ea77\";\n  }\n  \n  .icon-success:before {\n    content: \"\\e613\";\n  }\n  \n  .icon-clear:before {\n    content: \"\\eadb\";\n  }\n  \n  .icon-chat:before {\n    content: \"\\e610\";\n  }\n  \n  .icon-error:before {\n    content: \"\\e612\";\n  }\n  \n  .icon-search:before {\n    content: \"\\e6c8\";\n  }\n  \n  .icon-app:before {\n    content: \"\\e654\";\n  }\n  \n  .icon-friends:before {\n    content: \"\\e601\";\n  }\n  \n  .icon-about:before {\n    content: \"\\e6a1\";\n  }\n  \n  .icon-setting:before {\n    content: \"\\e622\";\n  }\n  \n  .icon-close:before {\n    content: \"\\e656\";\n  }\n  \n  .icon-down:before {\n    content: \"\\e80f\";\n  }\n  \n  .icon-add:before {\n    content: \"\\e604\";\n  }\n  \n  .icon-gongneng:before {\n    content: \"\\e605\";\n  }\n  \n  .icon-info:before {\n    content: \"\\e6f5\";\n  }\n  \n  .icon-warning:before {\n    content: \"\\e6be\";\n  }\n  \n  .icon-omit:before {\n    content: \"\\e618\";\n  }\n  \n  .icon-dashang:before {\n    content: \"\\e606\";\n  }\n  \n  .icon-administrator:before {\n    content: \"\\e67c\";\n  }\n  \n  .icon-groups:before {\n    content: \"\\e673\";\n  }\n  \n  .icon-login:before {\n    content: \"\\e62b\";\n  }\n  \n  .icon-logout:before {\n    content: \"\\e67d\";\n  }\n  \n  .icon-feature:before {\n    content: \"\\e68e\";\n  }\n  \n  .icon-expression:before {\n    content: \"\\e603\";\n  }\n  \n  .icon-github:before {\n    content: \"\\ef0e\";\n  }\n  \n  .icon-recall:before {\n    content: \"\\e77c\";\n  }\n   \n}"
  },
  {
    "path": "packages/web/src/styles/normalize.less",
    "content": "@import '~normalize.css/normalize.css';\n\n@font-face {\n    font-family: 'local-file';\n    src: url('~@fiora/assets/fonts/font.woff');\n}\n\n:global {\n    * {\n        user-select: none;\n    }\n    \n    html, body, #app {\n        width: 100%;\n        height: 100%;\n        overflow: hidden;\n    }\n    \n    html {\n        font-family: 'local-file', 'Arial', 'Verdana', '微软雅黑', '黑体', 'serif';\n    }\n    \n    p, h1, h2, h3, h4 {\n        margin: 0;\n    }\n    \n    div {\n        box-sizing: border-box;\n    }\n    \n    button, input, textarea {\n        outline: none;\n    }\n    \n    button {\n        cursor: pointer;\n    }\n    \n    div, p, span {\n        color: #333;\n    }\n    \n    body {overflow-y:hidden;}\n    body:hover {overflow-y:scroll;}\n    \n    ::-webkit-scrollbar {\n        display: none;\n    }\n    .show-scrollbar::-webkit-scrollbar {\n        display: block;\n        width: 6px;\n        height: 6px;\n    }\n    ::-webkit-scrollbar-thumb {\n        background-color: rgba(0, 0, 0, 0.2);\n    }\n    ::-webkit-scrollbar-track {\n        background: rgba(255, 255, 255, 0.1);\n    }\n    \n    .online {\n        background-color: rgba(94, 212, 92, 1);\n    }\n    .offline {\n        background-color: rgba(206, 12, 35, 1);\n    }\n    \n    .show {\n        display: block;\n    }\n    .hide {\n        display: none !important;\n    }\n}"
  },
  {
    "path": "packages/web/src/styles/variable.less",
    "content": "@mobile: ~\"only screen and (max-width: 500px)\";"
  },
  {
    "path": "packages/web/src/template.html",
    "content": "<!DOCTYPE html>\n<html>\n  <head>\n    <meta charset=\"utf-8\">\n    <title>fiora</title>\n    <meta name=keywords content=\"fiora,碎碎酱,聊天室,node.js聊天室,网页聊天室,web聊天室,fiora聊天室,开源聊天室\">\n    <meta name=description content=\"fiora聊天室是基于node.js和react由碎碎酱独自开发的开源网页聊天室,使用socket.io模块WebSocket协议通讯,支持Service Worker和PWA.功能丰富,并且简单易上手,很适合作为学习node.js的参考项目,node.js初学者的福音\">\n    <meta name=author content=碎碎酱>\n    <meta name=viewport content=\"viewport-fit=cover,width=device-width,initial-scale=1,maximum-scale=1,minimum-scale=1,user-scalable=no\">\n    <link rel=\"shortcut icon\" href=\"/favicon-192.png\">\n    <% if (process.env.NODE_ENV === 'production') { %>\n      <link rel=\"manifest\" href=\"/manifest.json\">\n    <% } %>\n    <!-- Add to home screen for Safari on iOS -->\n    <meta name=\"apple-mobile-web-app-capable\" content=\"yes\">\n    <meta name=\"apple-mobile-web-app-status-bar-style\" content=\"black\">\n    <meta name=\"apple-mobile-web-app-title\" content=\"fiora\">\n    <link rel=\"apple-touch-icon\" href=\"/favicon-192.png\">\n    <!-- Windows -->\n    <meta name=\"msapplication-TileImage\" content=\"/favicon-192.png\">\n    <meta name=\"msapplication-TileColor\" content=\"#333\">\n    <meta name=\"apple-mobile-web-app-capable\" content=\"yes\" />\n    <meta name=\"apple-touch-fullscreen\" content=\"yes\" />\n  </head>\n  <body>\n    <div id=\"app\"></div>\n    <!-- built files will be auto injected -->\n  </body>\n</html>\n"
  },
  {
    "path": "packages/web/src/themes.ts",
    "content": "import BackgroundImage from '@fiora/assets/images/background.jpg';\nimport BackgroundCoolImage from '@fiora/assets/images/background-cool.jpg';\n\ntype Themes = {\n    [theme: string]: {\n        primaryColor: string;\n        primaryTextColor: string;\n        backgroundImage: string;\n        aero: boolean;\n    };\n};\n\nconst themes: Themes = {\n    default: {\n        primaryColor: '74, 144, 226',\n        primaryTextColor: '247, 247, 247',\n        backgroundImage: BackgroundImage,\n        aero: false,\n    },\n    cool: {\n        primaryColor: '5,159,149',\n        primaryTextColor: '255, 255, 255',\n        backgroundImage: BackgroundCoolImage,\n        aero: false,\n    },\n};\n\nexport default themes;\n"
  },
  {
    "path": "packages/web/src/types/index.d.ts",
    "content": "declare module 'opn';\ndeclare module 'webpack';\ndeclare module 'http-proxy-middleware';\ndeclare module 'webpack-dev-middleware';\ndeclare module 'webpack-hot-middleware';\ndeclare module 'connect-history-api-fallback';\ndeclare module 'ora';\ndeclare module 'rimraf';\ndeclare module 'less-plugin-autoprefix';\ndeclare module 'extract-text-webpack-plugin';\ndeclare module 'webpack-merge';\ndeclare module 'html-webpack-plugin';\ndeclare module 'friendly-errors-webpack-plugin';\ndeclare module 'webpack-dashboard/plugin';\ndeclare module 'copy-webpack-plugin';\ndeclare module 'optimize-css-assets-webpack-plugin';\ndeclare module 'script-ext-html-webpack-plugin';\ndeclare module 'webpack-bundle-analyzer';\ndeclare module 'react-radio-buttons';\ndeclare module 'rc-tabs';\ndeclare module 'rc-tabs/lib/TabContent';\ndeclare module 'rc-tabs/lib/ScrollableInkTabBar';\ndeclare module 'rc-menu';\ndeclare module 'rc-dropdown';\ndeclare module 'rc-notification';\ndeclare module 'brace/mode/javascript';\ndeclare module 'brace/mode/typescript';\ndeclare module 'brace/mode/java';\ndeclare module 'brace/mode/c_cpp';\ndeclare module 'brace/mode/python';\ndeclare module 'brace/mode/ruby';\ndeclare module 'brace/mode/php';\ndeclare module 'brace/mode/golang';\ndeclare module 'brace/mode/csharp';\ndeclare module 'brace/mode/html';\ndeclare module 'brace/mode/css';\ndeclare module 'brace/mode/markdown';\ndeclare module 'brace/mode/sql';\ndeclare module 'brace/mode/json';\n\ndeclare module '*.less';\ndeclare module '*.json';\ndeclare module '*.png';\ndeclare module '*.jpg';\ndeclare module '*.jpeg';\ndeclare module '*.gif';\ndeclare module '*.mp3';\n\ndeclare var __TEST__: false;\n\ndeclare interface Window {\n    Notification: any;\n    __REDUX_DEVTOOLS_EXTENSION__: any;\n}\n"
  },
  {
    "path": "packages/web/src/utils/fetch.ts",
    "content": "import Message from '../components/Message';\nimport socket from '../socket';\n\nimport { SEAL_TEXT, SEAL_USER_TIMEOUT } from '../../../utils/const';\n\n/** 用户是否被封禁 */\nlet isSeal = false;\n\nexport default function fetch<T = any>(\n    event: string,\n    data = {},\n    { toast = true } = {},\n): Promise<[string | null, T | null]> {\n    if (isSeal) {\n        Message.error(SEAL_TEXT);\n        return Promise.resolve([SEAL_TEXT, null]);\n    }\n    return new Promise((resolve) => {\n        socket.emit(event, data, (res: any) => {\n            if (typeof res === 'string') {\n                if (toast) {\n                    Message.error(res);\n                }\n                /**\n                 * 服务端返回封禁状态后, 本地存储该状态\n                 * 用户再触发接口请求时, 直接拒绝\n                 */\n                if (res === SEAL_TEXT) {\n                    isSeal = true;\n                    // 用户封禁和ip封禁时效不同, 这里用的短时间\n                    setTimeout(() => {\n                        isSeal = false;\n                    }, SEAL_USER_TIMEOUT);\n                }\n                resolve([res, null]);\n            } else {\n                resolve([null, res]);\n            }\n        });\n    });\n}\n"
  },
  {
    "path": "packages/web/src/utils/getRandomHuaji.ts",
    "content": "import HuaJi0 from '@fiora/assets/images/huaji/0.jpg';\nimport HuaJi1 from '@fiora/assets/images/huaji/1.gif';\nimport HuaJi2 from '@fiora/assets/images/huaji/2.jpeg';\nimport HuaJi3 from '@fiora/assets/images/huaji/3.jpeg';\nimport HuaJi4 from '@fiora/assets/images/huaji/4.jpeg';\nimport HuaJi5 from '@fiora/assets/images/huaji/5.jpg';\nimport HuaJi6 from '@fiora/assets/images/huaji/6.jpeg';\nimport HuaJi7 from '@fiora/assets/images/huaji/7.jpg';\nimport HuaJi8 from '@fiora/assets/images/huaji/8.jpeg';\nimport HuaJi9 from '@fiora/assets/images/huaji/9.jpeg';\nimport HuaJi10 from '@fiora/assets/images/huaji/10.jpeg';\nimport HuaJi11 from '@fiora/assets/images/huaji/11.jpeg';\nimport HuaJi12 from '@fiora/assets/images/huaji/12.jpeg';\nimport HuaJi13 from '@fiora/assets/images/huaji/13.jpeg';\nimport HuaJi14 from '@fiora/assets/images/huaji/14.jpeg';\nimport HuaJi15 from '@fiora/assets/images/huaji/15.jpeg';\nimport HuaJi16 from '@fiora/assets/images/huaji/16.jpeg';\nimport HuaJi17 from '@fiora/assets/images/huaji/17.jpeg';\nimport HuaJi18 from '@fiora/assets/images/huaji/18.gif';\nimport HuaJi19 from '@fiora/assets/images/huaji/19.jpeg';\nimport HuaJi20 from '@fiora/assets/images/huaji/20.jpeg';\nimport HuaJi21 from '@fiora/assets/images/huaji/21.jpeg';\nimport HuaJi22 from '@fiora/assets/images/huaji/22.jpeg';\nimport HuaJi23 from '@fiora/assets/images/huaji/23.jpeg';\nimport HuaJi24 from '@fiora/assets/images/huaji/24.jpeg';\nimport HuaJi25 from '@fiora/assets/images/huaji/25.png';\nimport HuaJi26 from '@fiora/assets/images/huaji/26.jpeg';\nimport HuaJi27 from '@fiora/assets/images/huaji/27.jpeg';\nimport HuaJi28 from '@fiora/assets/images/huaji/28.jpeg';\nimport HuaJi29 from '@fiora/assets/images/huaji/29.jpeg';\nimport HuaJi30 from '@fiora/assets/images/huaji/30.jpeg';\nimport HuaJi31 from '@fiora/assets/images/huaji/31.jpeg';\nimport HuaJi32 from '@fiora/assets/images/huaji/32.jpg';\nimport HuaJi33 from '@fiora/assets/images/huaji/33.gif';\nimport HuaJi34 from '@fiora/assets/images/huaji/34.gif';\nimport HuaJi35 from '@fiora/assets/images/huaji/35.gif';\nimport HuaJi36 from '@fiora/assets/images/huaji/36.gif';\n\ntype Huaji = {\n    [key: number]: string;\n};\n\nconst huaji: Huaji = {\n    0: `${HuaJi0}?width=250&height=250&huaji=true`,\n    1: `${HuaJi1}?width=300&height=300&huaji=true`,\n    2: `${HuaJi2}?width=245&height=206&huaji=true`,\n    3: `${HuaJi3}?width=225&height=225&huaji=true`,\n    4: `${HuaJi4}?width=224&height=225&huaji=true`,\n    5: `${HuaJi5}?width=200&height=200&huaji=true`,\n    6: `${HuaJi6}?width=284&height=177&huaji=true7`,\n    7: `${HuaJi7}?width=300&height=300&huaji=true`,\n    8: `${HuaJi8}?width=225&height=225&huaji=true`,\n    9: `${HuaJi9}?width=204&height=247&huaji=true`,\n    10: `${HuaJi10}?width=223&height=226&huaji=true`,\n    11: `${HuaJi11}?width=198&height=255&huaji=true`,\n    12: `${HuaJi12}?width=212&height=237&huaji=true`,\n    13: `${HuaJi13}?width=290&height=174&huaji=true`,\n    14: `${HuaJi14}?width=224&height=224&huaji=true`,\n    15: `${HuaJi15}?width=224&height=224&huaji=true`,\n    16: `${HuaJi16}?width=225&height=225&huaji=true`,\n    17: `${HuaJi17}?width=225&height=225&huaji=true`,\n    18: `${HuaJi18}?width=100&height=110&huaji=true`,\n    19: `${HuaJi19}?width=225&height=225&huaji=true`,\n    20: `${HuaJi20}?width=225&height=225&huaji=true`,\n    21: `${HuaJi21}?width=225&height=225&huaji=true`,\n    22: `${HuaJi22}?width=245&height=206&huaji=true`,\n    23: `${HuaJi23}?width=225&height=225&huaji=true`,\n    24: `${HuaJi24}?width=225&height=225&huaji=true`,\n    25: `${HuaJi25}?width=225&height=225&huaji=true`,\n    26: `${HuaJi26}?width=225&height=225&huaji=true`,\n    27: `${HuaJi27}?width=180&height=180&huaji=true`,\n    28: `${HuaJi28}?width=235&height=215&huaji=true`,\n    29: `${HuaJi29}?width=278&height=182&huaji=true`,\n    30: `${HuaJi30}?width=228&height=221&huaji=true`,\n    31: `${HuaJi31}?width=239&height=211&huaji=true`,\n    32: `${HuaJi32}?width=220&height=220&huaji=true`,\n    33: `${HuaJi33}?width=220&height=220&huaji=true`,\n    34: `${HuaJi34}?width=164&height=192&huaji=true`,\n    35: `${HuaJi35}?width=130&height=62&huaji=true`,\n    36: `${HuaJi36}?width=187&height=144&huaji=true`,\n};\nconst HuajiaCount = Object.keys(huaji).length;\n\nexport default function getRandomHuaji() {\n    const number = Math.floor(Math.random() * HuajiaCount);\n    return huaji[number];\n}\n"
  },
  {
    "path": "packages/web/src/utils/inobounce.ts",
    "content": "/**\n * 阻止目标元素下不必要的滚动事件. 解决ios橡皮筋效果问题\n * @param {HTMLElement} targetElement 目标元素\n */\nexport default function inobounce(targetElement: HTMLElement) {\n    let startX = 0;\n    let startY = 0;\n\n    function handleTouchStart(e: any) {\n        startY = e.touches ? e.touches[0].screenY : e.screenY;\n        startX = e.touches ? e.touches[0].screenX : e.screenX;\n    }\n    function handleTouchMove(e: any) {\n        let el = e.target;\n\n        while (el !== e.currentTarget) {\n            const style = window.getComputedStyle(el);\n\n            if (!style) {\n                break;\n            }\n\n            if (\n                el.nodeName === 'INPUT' &&\n                el.getAttribute('type') === 'range'\n            ) {\n                return;\n            }\n\n            const overflowY = style.getPropertyValue('overflow-y');\n            const height = +parseFloat(\n                style.getPropertyValue('height'),\n            ).toFixed(0);\n            const isScrollableY =\n                overflowY === 'auto' || overflowY === 'scroll';\n            const canScrollY = el.scrollHeight > el.offsetHeight;\n            if (isScrollableY && canScrollY) {\n                const curY = e.touches ? e.touches[0].screenY : e.screenY;\n                const isAtTop = startY <= curY && el.scrollTop === 0;\n                const isAtBottom =\n                    startY >= curY && el.scrollHeight - el.scrollTop === height;\n\n                if (isAtTop || isAtBottom) {\n                    e.preventDefault();\n                }\n                return;\n            }\n\n            const overflowX = style.getPropertyValue('overflow-x');\n            const width = +parseFloat(style.getPropertyValue('width')).toFixed(\n                0,\n            );\n            const isScrollableX =\n                overflowX === 'auto' || overflowX === 'scroll';\n            const canScrollX = el.scrollWidth > el.offsetWidth;\n            if (isScrollableX && canScrollX) {\n                const curX = e.touches ? e.touches[0].screenX : e.screenX;\n                const isAtLeft = startX <= curX && el.scrollLeft === 0;\n                const isAtRight =\n                    startX >= curX && el.scrollWidth - el.scrollLeft === width;\n\n                if (isAtLeft || isAtRight) {\n                    e.preventDefault();\n                }\n                return;\n            }\n\n            el = el.parentNode;\n        }\n\n        e.preventDefault();\n    }\n\n    if (targetElement) {\n        targetElement.addEventListener('touchstart', handleTouchStart);\n        targetElement.addEventListener('touchmove', handleTouchMove);\n    }\n}\n"
  },
  {
    "path": "packages/web/src/utils/notification.ts",
    "content": "export default function notification(\n    title: string,\n    icon: string,\n    body: string,\n    tag = 'tag',\n    duration = 3000,\n) {\n    if (window.Notification && window.Notification.permission === 'granted') {\n        const n = new window.Notification(title, {\n            icon,\n            body,\n            tag,\n        });\n        n.onclick = function handleClick() {\n            window.focus();\n            this.close();\n        };\n        setTimeout(n.close.bind(n), duration);\n    }\n}\n"
  },
  {
    "path": "packages/web/src/utils/playSound.ts",
    "content": "import DefaultSound from '@fiora/assets/audios/default.mp3';\nimport AppleSound from '@fiora/assets/audios/apple.mp3';\nimport PcQQSound from '@fiora/assets/audios/pcqq.mp3';\nimport MobileQQSound from '@fiora/assets/audios/mobileqq.mp3';\nimport MoMoSound from '@fiora/assets/audios/momo.mp3';\nimport HuaJiSound from '@fiora/assets/audios/huaji.mp3';\n\ntype Sounds = {\n    [key: string]: string;\n};\n\nconst sounds: Sounds = {\n    default: DefaultSound,\n    apple: AppleSound,\n    pcqq: PcQQSound,\n    mobileqq: MobileQQSound,\n    momo: MoMoSound,\n    huaji: HuaJiSound,\n};\n\nlet prevType = 'default';\nconst $audio = document.createElement('audio');\nconst $source = document.createElement('source');\n$audio.volume = 0.6;\n$source.setAttribute('type', 'audio/mp3');\n$source.setAttribute('src', sounds[prevType]);\n$audio.appendChild($source);\ndocument.body.appendChild($audio);\n\nlet isPlaying = false;\n\nasync function play() {\n    if (!isPlaying) {\n        isPlaying = true;\n\n        try {\n            await $audio.play();\n        } catch (err) {\n            console.warn('播放新消息提示音失败', err.message);\n        } finally {\n            isPlaying = false;\n        }\n    }\n}\n\nexport default function playSound(type = 'default') {\n    if (type !== prevType) {\n        $source.setAttribute('src', sounds[type]);\n        $audio.load();\n        prevType = type;\n    }\n    play();\n}\n"
  },
  {
    "path": "packages/web/src/utils/readDiskFile.ts",
    "content": "export interface ReadFileResult {\n    /** 文件名 */\n    filename: string;\n    /** 文件拓展名 */\n    ext: string;\n    /** 文件类型 */\n    type: string;\n    /** 文件内容 */\n    result: Blob | ArrayBuffer | string;\n    /** 文件长度 */\n    length: number;\n}\n\n/**\n * 读取本地文件\n * @param {string} resultType 数据类型, {blob|base64}, 默认blob\n * @param {string} accept 可选文件类型, 默认 * / *\n */\nexport default async function readDiskFIle(\n    resultType = 'blob',\n    accept = '*/*',\n) {\n    const result: ReadFileResult | null = await new Promise((resolve) => {\n        const $input = document.createElement('input');\n        $input.style.display = 'none';\n        $input.setAttribute('type', 'file');\n        $input.setAttribute('accept', accept);\n        // 判断用户是否点击取消, 原生没有提供专门事件, 用hack的方法实现\n        $input.onclick = () => {\n            // @ts-ignore\n            $input.value = null;\n            document.body.onfocus = () => {\n                // onfocus事件会比$input.onchange事件先触发, 因此需要延迟一段时间\n                setTimeout(() => {\n                    if ($input.value.length === 0) {\n                        resolve(null);\n                    }\n                    document.body.onfocus = null;\n                }, 500);\n            };\n        };\n        $input.onchange = (e: Event) => {\n            // @ts-ignore\n            const file = e.target.files[0];\n            if (!file) {\n                return;\n            }\n\n            const reader = new FileReader();\n            reader.onloadend = function handleLoad() {\n                if (!this.result) {\n                    resolve(null);\n                    return;\n                }\n                // @ts-ignore\n                resolve({\n                    filename: file.name,\n                    ext: file.name\n                        .split('.')\n                        .pop()\n                        .toLowerCase(),\n                    type: file.type,\n                    // @ts-ignore\n                    result: this.result,\n                    length:\n                        resultType === 'blob'\n                            ? (this.result as ArrayBuffer).byteLength\n                            : (this.result as string).length,\n                });\n            };\n\n            switch (resultType) {\n                case 'blob': {\n                    reader.readAsArrayBuffer(file);\n                    break;\n                }\n                case 'base64': {\n                    reader.readAsDataURL(file);\n                    break;\n                }\n                default: {\n                    reader.readAsArrayBuffer(file);\n                }\n            }\n        };\n        $input.click();\n    });\n\n    if (result && resultType === 'blob') {\n        result.result = new Blob(\n            [new Uint8Array(result.result as ArrayBuffer)],\n            {\n                type: result.type,\n            },\n        );\n    }\n    return result;\n}\n"
  },
  {
    "path": "packages/web/src/utils/setCssVariable.ts",
    "content": "/**\n * set global css variable\n * @param color primary color, three numbers split with comma, like 255,255,255\n * @param textColor text colore, format like color\n */\nexport default function setCssVariable(color: string, textColor: string) {\n    let cssText = '';\n    for (let i = 0; i <= 10; i++) {\n        cssText += `--primary-color-${i}:rgba(${color}, ${i /\n            10});--primary-color-${i}_5:rgba(${color}, ${(i + 0.5) /\n            10});--primary-text-color-${i}:rgba(${textColor}, ${i / 10});`;\n    }\n    document.documentElement.style.cssText += cssText;\n}\n"
  },
  {
    "path": "packages/web/src/utils/uploadFile.ts",
    "content": "import * as OSS from 'ali-oss';\nimport fetch from './fetch';\n\nlet ossClient: OSS;\nlet endpoint = '/';\nexport async function initOSS() {\n    const [, token] = await fetch('getSTS');\n    if (token?.enable) {\n        // @ts-ignore\n        ossClient = new OSS({\n            region: token.region,\n            accessKeyId: token.AccessKeyId,\n            accessKeySecret: token.AccessKeySecret,\n            stsToken: token.SecurityToken,\n            bucket: token.bucket,\n        });\n        if (token.endpoint) {\n            endpoint = `//${token.endpoint}/`;\n        }\n\n        const OneHour = 1000 * 60 * 60;\n        setInterval(async () => {\n            const [, refreshToken] = await fetch('getSTS');\n            if (refreshToken?.enable) {\n                // @ts-ignore\n                ossClient = new OSS({\n                    region: refreshToken.region,\n                    accessKeyId: refreshToken.AccessKeyId,\n                    accessKeySecret: refreshToken.AccessKeySecret,\n                    stsToken: refreshToken.SecurityToken,\n                    bucket: refreshToken.bucket,\n                });\n            }\n        }, OneHour);\n    }\n}\n\nexport function getOSSFileUrl(url = '', process = '') {\n    const [rawUrl = '', extraPrams = ''] = url.split('?');\n    if (ossClient && rawUrl.startsWith('oss:')) {\n        const filename = rawUrl.slice(4);\n        // expire 5min\n        return `${ossClient.signatureUrl(filename, { expires: 300, process })}${\n            extraPrams ? `&${extraPrams}` : ''\n        }`;\n    }\n    if (/\\/\\/cdn\\.suisuijiang\\.com/.test(rawUrl)) {\n        return `${rawUrl}?x-oss-process=${process}${\n            extraPrams ? `&${extraPrams}` : ''\n        }`;\n    }\n    return `${url}`;\n}\n\n/**\n * 上传文件\n * @param blob 文件blob数据\n * @param fileName 文件名\n */\nexport default async function uploadFile(\n    blob: Blob,\n    fileName: string,\n): Promise<string> {\n    // 阿里云 OSS 不可用, 上传文件到服务端\n    if (!ossClient) {\n        const [uploadErr, result] = await fetch('uploadFile', {\n            file: blob,\n            fileName,\n        });\n        if (uploadErr) {\n            throw Error(uploadErr);\n        }\n        return result.url;\n    }\n\n    // 上传到阿里OSS\n    const result = await ossClient.put(fileName, blob);\n    if (result.res.status === 200) {\n        return endpoint + result.name;\n    }\n    return Promise.reject('上传文件失败');\n}\n"
  },
  {
    "path": "packages/web/src/utils/voice.ts",
    "content": "import axios from 'axios';\nimport fetch from './fetch';\n\nconst $audio = document.createElement('audio');\nconst $source = document.createElement('source');\n$audio.volume = 0.6;\n$source.setAttribute('type', 'audio/mp3');\n$source.setAttribute('src', '');\n$audio.appendChild($source);\ndocument.body.appendChild($audio);\n\nlet baiduToken = '';\nasync function read(text: string, cuid: string) {\n    if (!baiduToken) {\n        const [err, result] = await fetch('getBaiduToken');\n        if (err) {\n            return;\n        }\n        baiduToken = result.token;\n    }\n\n    const res = await axios.get(\n        `https://tsn.baidu.com/text2audio?tex=${text}&tok=${baiduToken}&cuid=${cuid}&ctp=1&lan=zh&per=4`,\n        { responseType: 'blob' },\n    );\n    const blob = res.data;\n    if (res.status !== 200 || blob.type === 'application/json') {\n        console.warn('合成语言失败');\n    } else {\n        $source.setAttribute('src', URL.createObjectURL(blob));\n        $audio.load();\n\n        try {\n            const playEndPromise = new Promise((resolve) => {\n                $audio.onended = resolve;\n            });\n            await $audio.play();\n            // eslint-disable-next-line consistent-return\n            return playEndPromise;\n        } catch (err) {\n            console.warn('语言朗读消息失败', err.message);\n        }\n    }\n}\n\ntype Task = {\n    text: string;\n    cuid: string;\n};\n\nconst taskQueue: Task[] = [];\nlet isWorking = false;\nasync function handleTaskQueue() {\n    isWorking = true;\n    const task = taskQueue.shift();\n    if (task) {\n        await read(task.text, task.cuid);\n        await handleTaskQueue();\n    } else {\n        isWorking = false;\n    }\n}\n\nconst voice = {\n    push(text: string, cuid: string) {\n        taskQueue.push({ text, cuid });\n        if (!isWorking) {\n            handleTaskQueue();\n        }\n    },\n};\n\nexport default voice;\n"
  },
  {
    "path": "packages/web/test/components/Avatar.spec.tsx",
    "content": "/**\n * @jest-environment jsdom\n */\n\nimport React from 'react';\nimport { render, screen, fireEvent } from '@testing-library/react';\nimport '@testing-library/jest-dom/extend-expect';\nimport Avatar, { avatarFailback } from '../../src/components/Avatar';\n\ndescribe('Avatar', () => {\n    it('shoule render without error', async () => {\n        render(<Avatar src=\"./1.jpg\" />);\n        const $img = await screen.findByRole('img');\n        expect($img).toBeInTheDocument();\n    });\n\n    it('should call props handler function when fire event', async () => {\n        const handleClick = jest.fn();\n        const handleMouseEnter = jest.fn();\n        const handleMouseLeave = jest.fn();\n        render(\n            <Avatar\n                src=\"./1.jpg\"\n                onClick={handleClick}\n                onMouseEnter={handleMouseEnter}\n                onMouseLeave={handleMouseLeave}\n            />,\n        );\n        const $img = await screen.findByRole('img');\n        fireEvent.click($img);\n        expect(handleClick).toBeCalled();\n        fireEvent.mouseEnter($img);\n        expect(handleMouseEnter).toBeCalled();\n        fireEvent.mouseLeave($img);\n        expect(handleMouseLeave).toBeCalled();\n    });\n\n    it('shoule use failback avatar when fetch error', async () => {\n        const src = 'origin.jpg';\n        render(<Avatar src={src} />);\n        const $img = (await screen.findByRole('img')) as HTMLImageElement;\n        expect($img.src).toEqual(expect.stringContaining(src));\n        fireEvent.error($img);\n        expect($img.src).toEqual(expect.stringContaining(avatarFailback));\n        fireEvent.error($img);\n        fireEvent.error($img);\n    });\n\n    it('shoule not add CDN query params', async () => {\n        const src = 'data:base64/png;xxx';\n        render(<Avatar src={src} />);\n        const $img = (await screen.findByRole('img')) as HTMLImageElement;\n        expect($img.src).not.toEqual(expect.stringContaining('x-oss-process='));\n    });\n});\n"
  },
  {
    "path": "packages/web/test/components/Button.spec.tsx",
    "content": "/**\n * @jest-environment jsdom\n */\n\nimport React from 'react';\nimport { render, screen, fireEvent } from '@testing-library/react';\nimport '@testing-library/jest-dom/extend-expect';\nimport Button from '../../src/components/Button';\n\ndescribe('Button', () => {\n    it('shoule render without error', async () => {\n        render(<Button>text</Button>);\n        const $button = screen.getByRole('button');\n        expect($button).toBeInTheDocument();\n    });\n\n    it('shoule support set custom class name and type', async () => {\n        render(\n            <Button className=\"custom\" type=\"danger\">\n                text\n            </Button>,\n        );\n        const $button = screen.getByRole('button');\n        expect($button.classList).toContain('custom');\n        expect($button.classList).toContain('danger');\n    });\n\n    it('shoule call props handler function when fire event', async () => {\n        const handleClick = jest.fn();\n        render(<Button onClick={handleClick}>text</Button>);\n        const $button = screen.getByRole('button');\n        fireEvent.click($button);\n        expect(handleClick).toHaveBeenCalled();\n    });\n});\n"
  },
  {
    "path": "packages/web/test/localStorage.spec.ts",
    "content": "import getData, { LocalStorageKey } from '../src/localStorage';\nimport config from '../../config/client';\nimport themes from '../src/themes';\n\ndescribe('client/localStorage.ts', () => {\n    it('should return localStorage value, or default value if not exists', () => {\n        expect(getData().sound).toBe(config.sound);\n        localStorage.setItem(LocalStorageKey.Sound, 'huaji');\n        expect(getData().sound).toBe('huaji');\n    });\n\n    it('should return default theme config when them not exists', () => {\n        localStorage.setItem(LocalStorageKey.Theme, 'xxx');\n        expect(getData().primaryColor).toBe(themes.cool.primaryColor);\n    });\n\n    it('should return boolean type value when value is true / false', () => {\n        localStorage.setItem(LocalStorageKey.SoundSwitch, 'true');\n        expect(getData().soundSwitch).toBe(true);\n        localStorage.setItem(LocalStorageKey.SoundSwitch, 'false');\n        expect(getData().soundSwitch).toBe(false);\n    });\n});\n"
  },
  {
    "path": "packages/web/test/state/reducer.spec.ts",
    "content": "import getFriendId from '@fiora/utils/getFriendId';\nimport reducer, { State, initialState } from '../../src/state/reducer';\nimport { Action, ActionTypes } from '../../src/state/action';\n\ndescribe('redux reducer', () => {\n    it('should user initial state', () => {\n        const action = { type: 'mock' as ActionTypes, payload: {} } as Action;\n        expect(reducer(undefined, action)).toBe(initialState);\n    });\n\n    it('should return origin state with unknown action', () => {\n        const state = {} as State;\n        const action = { type: 'mock' as ActionTypes, payload: {} } as Action;\n        expect(reducer(state, action)).toBe(state);\n    });\n\n    it('should set connect status to true', () => {\n        const state = {\n            connect: false,\n        } as State;\n        const action = {\n            type: ActionTypes.Connect,\n            payload: {},\n        };\n        const newState = reducer(state, action);\n        expect(newState.connect).toBe(true);\n    });\n\n    it('should set connect status to false', () => {\n        const state = {\n            connect: true,\n        } as State;\n        const action = {\n            type: ActionTypes.Disconnect,\n            payload: {},\n        };\n        const newState = reducer(state, action);\n        expect(newState.connect).toBe(false);\n    });\n\n    it('should set guest user and default group', () => {\n        const state = {} as State;\n        const group = {\n            _id: '1',\n            name: 'Default Group',\n        };\n        const action = {\n            type: ActionTypes.SetGuest,\n            payload: group,\n        };\n        const newState = reducer(state, action);\n        expect(newState.user).not.toBe(null);\n        expect(newState.linkmans[group._id]).toBe(group);\n        expect(newState.focus).toBe(group._id);\n    });\n\n    it('should set user and linkmans', () => {\n        const state = {\n            linkmans: {},\n        } as State;\n        const group = {\n            _id: 'group',\n            name: 'group',\n        };\n        const friend = {\n            from: '111',\n            to: {\n                _id: '222',\n                username: 'friend',\n            },\n        };\n        const action = {\n            type: ActionTypes.SetUser,\n            payload: {\n                _id: 'id',\n                username: 'user',\n                friends: [friend],\n                groups: [group],\n            },\n        };\n        const newState = reducer(state, action);\n        expect(newState.user).not.toBe(null);\n        expect(Object.keys(newState.linkmans).length).toBe(2);\n        expect(newState.linkmans[group._id].type).toBe('group');\n\n        const friendId = getFriendId(friend.from, friend.to._id);\n        expect(newState.linkmans[friendId].type).toBe('friend');\n        expect(newState.linkmans[friendId].name).toBe(friend.to.username);\n        expect(newState.focus).toBe(group._id);\n    });\n\n    it('should update user with payload data', () => {\n        const state = {\n            user: {\n                _id: 'id',\n                username: 'name',\n            },\n        } as State;\n        const action = {\n            type: ActionTypes.UpdateUserInfo,\n            payload: {\n                username: 'new',\n            },\n        };\n        const newState = reducer(state, action);\n        expect(newState.user?.username).toBe('new');\n    });\n\n    it('should set user to null', () => {\n        const state = {\n            user: {\n                _id: 'id',\n            },\n        } as State;\n        const action = {\n            type: ActionTypes.Logout,\n            payload: {},\n        };\n        const newState = reducer(state, action);\n        expect(newState.user).toEqual(null);\n    });\n\n    it('should update user avatar', () => {\n        const state = {\n            user: {\n                _id: 'id',\n                avatar: 'avatar',\n            },\n        } as State;\n        const action = {\n            type: ActionTypes.SetAvatar,\n            payload: 'new',\n        };\n        const newState = reducer(state, action);\n        expect(newState.user?.avatar).toBe('new');\n    });\n\n    it('should update focus and reduce exist messages when more than 50', () => {\n        const linkman = {\n            _id: '1',\n            messages: {},\n        };\n        const state = {\n            linkmans: {\n                [linkman._id]: linkman,\n            },\n            focus: '',\n        } as State;\n        const action = {\n            type: ActionTypes.SetFocus,\n            payload: linkman._id,\n        };\n        const newState = reducer(state, action);\n        expect(newState.focus).toBe(linkman._id);\n        expect(\n            Object.keys(newState.linkmans[newState.focus].messages),\n        ).toHaveLength(0);\n    });\n\n    it('should reduce exist messages when more than 50', () => {\n        const messages: { [id: string]: any } = {};\n        for (let i = 0; i < 51; i++) {\n            messages[i] = {\n                _id: i,\n            };\n        }\n        const linkman = {\n            _id: '1',\n            messages,\n            unread: 10,\n        };\n        const state = {\n            linkmans: {\n                [linkman._id]: linkman,\n            },\n            focus: '',\n        } as State;\n        const action = {\n            type: ActionTypes.SetFocus,\n            payload: linkman._id,\n        };\n        const newState = reducer(state, action);\n        expect(\n            Object.keys(newState.linkmans[newState.focus].messages),\n        ).toHaveLength(50);\n    });\n\n    it('should be no change when foucs not exits user id', () => {\n        const linkman = {\n            id: '1',\n        };\n        // @ts-ignore\n        const state = {\n            linkmans: {\n                [linkman.id]: linkman,\n            },\n            focus: linkman.id,\n        } as State;\n        const action = {\n            type: ActionTypes.SetFocus,\n            payload: '2',\n        };\n        const newState = reducer(state, action);\n        expect(newState).toBe(state);\n    });\n\n    it('should add new group linkman into linkmans', () => {\n        const state = {\n            linkmans: {},\n        } as State;\n        const linkman = {\n            _id: 'id',\n            name: 'name',\n            type: 'group',\n        };\n        const action = {\n            type: ActionTypes.AddLinkman,\n            payload: {\n                linkman,\n                focus: true,\n            },\n        };\n        const newState = reducer(state, action);\n        expect(Object.keys(newState.linkmans)).toHaveLength(1);\n        expect(newState.focus).toBe(linkman._id);\n    });\n\n    it('should add new friend linkman into linkmans', () => {\n        const state = {\n            linkmans: {},\n        } as State;\n        const linkman = {\n            name: 'name',\n            type: 'friend',\n            from: '111',\n            to: {\n                _id: '222',\n            },\n        };\n        const action = {\n            type: ActionTypes.AddLinkman,\n            payload: {\n                linkman,\n            },\n        };\n        const newState = reducer(state, action);\n        expect(Object.keys(newState.linkmans)).toHaveLength(1);\n        expect(newState.linkmans['111222']).toBeTruthy();\n    });\n\n    it('should add new temporary linkman into linkmans', () => {\n        const state = {\n            linkmans: {},\n        } as State;\n        const linkman = {\n            _id: 'id',\n            name: 'name',\n            type: 'temporary',\n        };\n        const action = {\n            type: ActionTypes.AddLinkman,\n            payload: {\n                linkman,\n            },\n        };\n        const newState = reducer(state, action);\n        expect(Object.keys(newState.linkmans)).toHaveLength(1);\n        expect(newState.linkmans[linkman._id].unread).toBe(1);\n    });\n\n    it('should return origin state when add unknown linkman', () => {\n        const state = {\n            linkmans: {},\n        } as State;\n        const action = {\n            type: ActionTypes.AddLinkman,\n            payload: {\n                linkman: {\n                    type: 'xxx',\n                },\n            },\n        };\n        const newState = reducer(state, action);\n        expect(newState).toBe(state);\n    });\n\n    it('should remove linkman form linkmans', () => {\n        const linkman1 = {\n            _id: '1',\n        };\n        const linkman2 = {\n            _id: '2',\n        };\n        const state = {\n            linkmans: {\n                [linkman1._id]: linkman1,\n                [linkman2._id]: linkman2,\n            },\n        } as State;\n        const action1 = {\n            type: ActionTypes.RemoveLinkman,\n            payload: linkman1._id,\n        };\n        const newState = reducer(state, action1);\n        expect(Object.keys(newState.linkmans)).toHaveLength(1);\n        expect(newState.focus).toBe(linkman2._id);\n\n        const action2 = {\n            type: ActionTypes.RemoveLinkman,\n            payload: linkman2._id,\n        };\n        expect(reducer(newState, action2).focus).toBe('');\n    });\n\n    it('should add messages to linkmans', () => {\n        const state = {\n            linkmans: {\n                1: {\n                    _id: '1',\n                    name: 'name',\n                    messages: {},\n                },\n                2: {\n                    _id: '2',\n                    name: 'name',\n                    messages: {},\n                },\n            },\n        } as unknown as State;\n        const action = {\n            type: ActionTypes.SetLinkmansLastMessages,\n            payload: {\n                '1': {\n                    messages: [\n                        {\n                            _id: 'm1',\n                            type: 'text',\n                            content: 'content',\n                        },\n                        {\n                            _id: 'm2',\n                            type: 'text',\n                            content: 'content',\n                        },\n                    ],\n                    unread: 2,\n                },\n            },\n        };\n        const newState = reducer(state, action);\n        expect(Object.keys(newState.linkmans['1'].messages).length).toBe(2);\n        expect(Object.keys(newState.linkmans['2'].messages).length).toBe(0);\n    });\n\n    it('should add messages to linkman', () => {\n        const state = {\n            linkmans: {\n                1: {\n                    _id: '1',\n                    name: 'name',\n                    messages: {},\n                },\n            },\n        } as unknown as State;\n        const action = {\n            type: ActionTypes.AddLinkmanHistoryMessages,\n            payload: {\n                linkmanId: '1',\n                messages: [\n                    {\n                        _id: 'm1',\n                        type: 'text',\n                        content: 'content',\n                    },\n                    {\n                        _id: 'm2',\n                        type: 'text',\n                        content: 'content',\n                    },\n                ],\n            },\n        };\n        const newState = reducer(state, action);\n        expect(Object.keys(newState.linkmans['1'].messages).length).toBe(2);\n    });\n\n    it('should add message to linkman', () => {\n        const linkman = {\n            _id: '1',\n            name: 'name',\n            messages: {},\n            unread: 0,\n        };\n        const state = {\n            linkmans: {\n                [linkman._id]: linkman,\n            },\n        } as State;\n        const action = {\n            type: ActionTypes.AddLinkmanMessage,\n            payload: {\n                linkmanId: '1',\n                message: {\n                    _id: 'm1',\n                    type: 'text',\n                    content: 'content',\n                },\n            },\n        };\n        const newState = reducer(state, action);\n        expect(Object.keys(newState.linkmans['1'].messages)).toHaveLength(1);\n        expect(newState.linkmans['1'].unread).toBe(1);\n    });\n\n    it('should not increase unread count when linkman is foucs', () => {\n        const linkman = {\n            _id: '1',\n            name: 'name',\n            messages: {},\n            unread: 0,\n        };\n        const state = {\n            linkmans: {\n                [linkman._id]: linkman,\n            },\n            focus: linkman._id,\n        } as State;\n        const action = {\n            type: ActionTypes.AddLinkmanMessage,\n            payload: {\n                linkmanId: '1',\n                message: {\n                    _id: 'm1',\n                    type: 'text',\n                    content: 'content',\n                },\n            },\n        };\n        const newState = reducer(state, action);\n        expect(Object.keys(newState.linkmans['1'].messages)).toHaveLength(1);\n        expect(newState.linkmans['1'].unread).toBe(0);\n    });\n\n    it('should remove message from linkman', () => {\n        const state = {\n            linkmans: {\n                1: {\n                    _id: '1',\n                    name: 'name',\n                    messages: {\n                        m1: {\n                            _id: 'm1',\n                            type: 'text',\n                            content: 'content',\n                            from: {},\n                        },\n                    },\n                    unread: 0,\n                },\n            },\n        } as unknown as State;\n        const action = {\n            type: ActionTypes.DeleteMessage,\n            payload: {\n                linkmanId: '1',\n                messageId: 'm1',\n            },\n        };\n        const newState = reducer(state, action);\n        expect(newState.linkmans['1'].messages.m1.deleted).toBe(true);\n    });\n\n    it('should return origin state when delete not exists linkman message', () => {\n        const state = {\n            linkmans: {},\n        } as State;\n        const action = {\n            type: ActionTypes.DeleteMessage,\n            payload: {\n                linkmanId: '1',\n                messageId: 'm1',\n            },\n        };\n        const newState = reducer(state, action);\n        expect(newState).toBe(state);\n    });\n\n    it('should update linkman property', () => {\n        const state = {\n            linkmans: {\n                1: {\n                    _id: '1',\n                    name: 'name',\n                    messages: {},\n                    unread: 0,\n                },\n            },\n        } as unknown as State;\n        const action = {\n            type: ActionTypes.SetLinkmanProperty,\n            payload: {\n                linkmanId: '1',\n                key: 'name',\n                value: 'new_name',\n            },\n        };\n        const newState = reducer(state, action);\n        expect(newState.linkmans['1'].name).toBe('new_name');\n    });\n\n    it('should update message from linkman', () => {\n        const state = {\n            linkmans: {\n                1: {\n                    _id: '1',\n                    name: 'name',\n                    messages: {\n                        m1: {\n                            _id: 'm1',\n                            type: 'text',\n                            content: 'content',\n                        },\n                    },\n                    unread: 0,\n                },\n            },\n        } as unknown as State;\n        const action = {\n            type: ActionTypes.UpdateMessage,\n            payload: {\n                linkmanId: '1',\n                messageId: 'm1',\n                value: {\n                    _id: 'm1',\n                    type: 'text',\n                    content: 'new_content',\n                },\n            },\n        };\n        const newState = reducer(state, action);\n        expect(newState.linkmans['1'].messages.m1.content).toBe('new_content');\n    });\n\n    it('should add instead of update message when it not exists', () => {\n        const linkman = {\n            _id: '1',\n            name: 'name',\n            messages: {},\n            unread: 0,\n        };\n        const state = {\n            linkmans: {\n                [linkman._id]: linkman,\n            },\n        } as State;\n        const action = {\n            type: ActionTypes.UpdateMessage,\n            payload: {\n                linkmanId: linkman._id,\n                messageId: 'm1',\n                value: {\n                    type: 'text',\n                    content: 'new_content',\n                },\n            },\n        };\n        const newState = reducer(state, action);\n        expect(newState.linkmans[linkman._id].messages.m1.content).toBe(\n            'new_content',\n        );\n    });\n\n    it('should update status of key', () => {\n        const state = {\n            status: {\n                aero: false,\n            },\n        } as unknown as State;\n        const action = {\n            type: ActionTypes.SetStatus,\n            payload: {\n                key: 'aero',\n                value: true,\n            },\n        };\n        const newState = reducer(state, action);\n        expect(newState.status.aero).toBe(true);\n    });\n});\n"
  },
  {
    "path": "packages/web/tsconfig.json",
    "content": "{\n  \"extends\": \"../../tsconfig\",\n  \"compilerOptions\": {\n    \"module\": \"esnext\"\n  }\n}"
  },
  {
    "path": "tsconfig.json",
    "content": "{\n    \"compilerOptions\": {\n        \"target\": \"es6\",\n        \"strict\": true,\n        \"moduleResolution\": \"node\",\n        \"experimentalDecorators\": true,\n        \"jsx\": \"react\",\n        \"allowSyntheticDefaultImports\": true,\n        \"esModuleInterop\": true,\n        \"lib\": [\"es2015\", \"es2016\", \"es2017\", \"dom\"],\n        \"resolveJsonModule\": true,\n        \"skipLibCheck\": true\n    },\n    \"exclude\": [\"node_modules\"]\n}\n"
  }
]